chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:55:23 +08:00
commit bc7eac8151
1701 changed files with 376767 additions and 0 deletions
@@ -0,0 +1,112 @@
# ETH2 QuickStart CLI Harness SOP
## Overview
This harness adds `cli-anything-eth2-quickstart`, a production-oriented CLI wrapper for the
[`chimera-defi/eth2-quickstart`](https://github.com/chimera-defi/eth2-quickstart) repository.
It does not reimplement node installation logic. Instead, it maps agent-friendly commands onto
the repo's canonical shell entrypoints:
- `scripts/eth2qs.sh`
- `run_1.sh`
- `run_2.sh`
- `install/utils/doctor.sh`
- `install/utils/stats.sh`
- `install/web/install_nginx.sh`
- `install/web/install_nginx_ssl.sh`
- `install/web/install_caddy.sh`
- `install/web/install_caddy_ssl.sh`
## Architecture
The package follows standard CLI-Anything layout:
- `cli_anything/eth2_quickstart/eth2_quickstart_cli.py`
Click entrypoint with REPL and machine-readable `--json` mode.
- `cli_anything/eth2_quickstart/core/project.py`
Repo-root discovery plus safe updates to `config/user_config.env`.
- `cli_anything/eth2_quickstart/utils/eth2qs_backend.py`
Thin subprocess backend for invoking the upstream wrapper/scripts.
- `cli_anything/eth2_quickstart/core/*.py`
Small orchestration helpers for install, RPC, validator guidance, and health.
## Command Mapping
### `setup-node`
Purpose: orchestrate phase execution with explicit client/MEV flags.
- `--phase phase1` -> `./scripts/eth2qs.sh phase1`
- `--phase phase2` -> `./scripts/eth2qs.sh phase2 --execution=... --consensus=... --mev=...`
- `--phase auto` without client flags -> `./scripts/eth2qs.sh ensure --apply --confirm`
- `--phase auto` with client flags -> phase 2 install
The harness also writes matching values into `config/user_config.env` when provided:
- `ETH_NETWORK`
- `EXEC_CLIENT`
- `CONS_CLIENT`
- `MEV_SOLUTION`
- `ETHGAS_NETWORK`
### `install-clients`
Direct phase 2 wrapper for execution/consensus/MEV installation, including optional ETHGas.
### `start-rpc`
Configures RPC exposure through either Nginx or Caddy by calling the upstream install scripts.
When `--server-name` is provided, the harness writes `SERVER_NAME` into `config/user_config.env`
before invoking the selected web installer.
### `configure-validator`
This command intentionally avoids importing validator keys or generating secrets. It updates
validator-related config values when provided:
- `FEE_RECIPIENT`
- `GRAFITTI`
Then it returns client-specific import and follow-up commands for the selected consensus client.
This keeps secret handling under operator control while still giving agents a composable surface.
### `status`
Returns:
- human mode: `stats` output plus current repo root
- JSON mode: `doctor --json`, `plan --json`, and raw `stats` output
### `health-check`
Returns the canonical `doctor --json` output and a short harness summary.
## Safety
- The harness requires `--confirm` for phase execution and RPC installer mutations.
- It never generates validator keys.
- It never deletes secrets or wallet data.
- It preserves the upstream reboot boundary between Phase 1 and Phase 2.
- It relies on upstream scripts for system mutations so behavior remains aligned with the source repo.
## Installation Model
The harness is meant to be installed from the CLI-Anything repo:
```bash
pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=eth2-quickstart/agent-harness
```
At runtime it operates against an `eth2-quickstart` checkout discovered by:
1. `--repo-root`
2. `ETH2QS_REPO_ROOT`
3. current working directory / parents containing `scripts/eth2qs.sh`
## Testing Strategy
- `test_core.py`
Uses mocked backend calls and temporary repo fixtures. No real node backend required.
- `test_full_e2e.py`
Auto-skips unless a real `eth2-quickstart` checkout is available via `ETH2QS_E2E_REPO_ROOT`.
E2E coverage is read-only or help-level by default so it is safe to run in CI.
@@ -0,0 +1,81 @@
# cli-anything-eth2-quickstart
CLI harness for `eth2-quickstart` so agents can deploy and operate hardened Ethereum nodes
through a consistent command surface with JSON output.
## Prerequisites
- Python 3.10+
- A local checkout of `https://github.com/chimera-defi/eth2-quickstart`
- Ubuntu host for real installation workflows
## Installation
```bash
cd eth2-quickstart/agent-harness
pip install -e .
cli-anything-eth2-quickstart --help
```
## Repo Discovery
The harness needs to know which `eth2-quickstart` checkout to operate on. It resolves the repo in
this order:
1. `--repo-root`
2. `ETH2QS_REPO_ROOT`
3. current working directory or one of its parents
```bash
export ETH2QS_REPO_ROOT=/srv/eth2-quickstart
cli-anything-eth2-quickstart --json health-check
```
## Usage
```bash
# Interactive REPL
cli-anything-eth2-quickstart
# Inspect machine-readable health
cli-anything-eth2-quickstart --json health-check
# Install phase 2 clients
cli-anything-eth2-quickstart --json install-clients \
--network mainnet \
--execution-client geth \
--consensus-client lighthouse \
--mev mev-boost \
--confirm
# Configure validator metadata without touching secrets
cli-anything-eth2-quickstart --json configure-validator \
--consensus-client lighthouse \
--fee-recipient 0x1111111111111111111111111111111111111111 \
--graffiti "CLI-Anything"
# Install and start nginx-backed RPC exposure
cli-anything-eth2-quickstart --json start-rpc \
--web-stack nginx \
--server-name rpc.example.org \
--confirm
```
## Commands
- `setup-node`
- `install-clients`
- `start-rpc`
- `configure-validator`
- `status`
- `health-check`
All commands support `--json`.
## Tests
```bash
cd eth2-quickstart/agent-harness
python3 -m pytest cli_anything/eth2_quickstart/tests/test_core.py -v
python3 -m pytest cli_anything/eth2_quickstart/tests/test_full_e2e.py -v
```
@@ -0,0 +1,5 @@
"""cli-anything-eth2-quickstart package."""
__all__ = ["__version__"]
__version__ = "1.0.0"
@@ -0,0 +1,5 @@
from cli_anything.eth2_quickstart.eth2_quickstart_cli import main
if __name__ == "__main__":
main()
@@ -0,0 +1 @@
"""Core helpers for cli-anything-eth2-quickstart."""
@@ -0,0 +1,126 @@
"""Static command metadata and validator guidance."""
from __future__ import annotations
VALID_NETWORKS = {"mainnet", "holesky"}
VALID_EXECUTION_CLIENTS = {
"geth",
"besu",
"erigon",
"nethermind",
"nimbus_eth1",
"reth",
"ethrex",
}
VALID_CONSENSUS_CLIENTS = {
"prysm",
"lighthouse",
"lodestar",
"teku",
"nimbus",
"grandine",
}
VALID_MEV_OPTIONS = {"mev-boost", "commit-boost", "none"}
VALID_WEB_STACKS = {"nginx", "caddy"}
def validator_plan(
consensus_client: str,
fee_recipient: str | None = None,
graffiti: str | None = None,
keys_dir: str | None = None,
secrets_dir: str | None = None,
wallet_password_file: str | None = None,
) -> dict:
if consensus_client not in VALID_CONSENSUS_CLIENTS:
raise ValueError(f"Unsupported consensus client: {consensus_client}")
key_locations = {
"prysm": {
"keys": keys_dir or "/path/to/validator-keys",
"secrets": secrets_dir or "~/secrets",
"config_file": "~/prysm/prysm_validator_conf.yaml",
"import_command": (
"~/prysm/prysm.sh validator accounts import "
f"--keys-dir={keys_dir or '/path/to/validator-keys'}"
),
},
"lighthouse": {
"keys": keys_dir or "~/.lighthouse/mainnet/validators",
"secrets": secrets_dir or "~/.lighthouse/mainnet/secrets",
"config_file": "/etc/systemd/system/validator.service",
"import_command": (
"lighthouse account validator import "
f"--directory {keys_dir or '/path/to/validator-keys'} "
f"--secrets-dir {secrets_dir or '~/.lighthouse/mainnet/secrets'}"
),
},
"lodestar": {
"keys": keys_dir or "~/.local/share/lodestar/validators/keystores",
"secrets": secrets_dir or "~/.local/share/lodestar/validators/secrets",
"config_file": "~/lodestar/validator.config.json",
"import_command": (
"lodestar validator import "
f"--keystoresDir {keys_dir or '/path/to/validator-keys'} "
f"--secretsDir {secrets_dir or '~/.local/share/lodestar/validators/secrets'}"
),
},
"teku": {
"keys": keys_dir or "~/.local/share/teku/validator/keys",
"secrets": secrets_dir or "~/.local/share/teku/validator/passwords",
"config_file": "~/teku/validator.yaml",
"import_command": (
"teku validator-client --help # import keys into the Teku validator key directory"
),
},
"nimbus": {
"keys": keys_dir or "~/.local/share/nimbus/validators",
"secrets": secrets_dir or "~/.local/share/nimbus/validators/secrets",
"config_file": "~/nimbus/validator.toml",
"import_command": (
"nimbus_validator_client deposits import "
f"--data-dir={keys_dir or '~/.local/share/nimbus/validators'}"
),
},
"grandine": {
"keys": keys_dir or "~/.local/share/grandine/validators",
"secrets": secrets_dir or "~/.local/share/grandine/validators/secrets",
"config_file": "~/grandine/grandine.toml",
"import_command": "Grandine validator import follows the upstream client workflow",
},
}
selected = key_locations[consensus_client]
post_import = [
"sudo systemctl restart validator",
"sudo systemctl status validator --no-pager",
"./scripts/eth2qs.sh doctor --json",
]
if consensus_client == "prysm" and wallet_password_file:
post_import.insert(
0,
f"Ensure wallet-password-file points to {wallet_password_file}",
)
updates = {}
if fee_recipient:
updates["FEE_RECIPIENT"] = fee_recipient
if graffiti:
# Upstream eth2-quickstart exports key is intentionally spelled "GRAFITTI".
updates["GRAFITTI"] = graffiti
return {
"consensus_client": consensus_client,
"config_updates": updates,
"config_file": selected["config_file"],
"keys_path": selected["keys"],
"secrets_path": selected["secrets"],
"import_command": selected["import_command"],
"post_import_commands": post_import,
"notes": [
"The harness does not import validator keys automatically.",
"Keep validator keystores and password files under operator control.",
"Restart validator service after importing keys.",
],
}
@@ -0,0 +1,153 @@
"""Install orchestration helpers."""
from __future__ import annotations
from typing import TYPE_CHECKING
from cli_anything.eth2_quickstart.core.commands import (
VALID_CONSENSUS_CLIENTS,
VALID_EXECUTION_CLIENTS,
VALID_MEV_OPTIONS,
VALID_NETWORKS,
)
from cli_anything.eth2_quickstart.core.project import upsert_user_config
if TYPE_CHECKING:
from cli_anything.eth2_quickstart.utils.eth2qs_backend import Eth2QuickStartBackend
def _validate_choice(name: str, value: str | None, allowed: set[str]) -> None:
if value is not None and value not in allowed:
raise ValueError(f"Unsupported {name}: {value}")
def _config_updates(
network: str | None,
execution_client: str | None,
consensus_client: str | None,
mev: str | None,
) -> dict[str, str]:
updates: dict[str, str] = {}
if network:
updates["ETH_NETWORK"] = network
updates["ETHGAS_NETWORK"] = network
if execution_client:
updates["EXEC_CLIENT"] = execution_client
if consensus_client:
updates["CONS_CLIENT"] = consensus_client
if mev:
updates["MEV_SOLUTION"] = mev
return updates
def install_clients(
backend: Eth2QuickStartBackend,
*,
network: str | None = None,
execution_client: str | None = None,
consensus_client: str | None = None,
mev: str | None = None,
ethgas: bool = False,
skip_deps: bool = False,
) -> dict:
_validate_choice("network", network, VALID_NETWORKS)
_validate_choice("execution client", execution_client, VALID_EXECUTION_CLIENTS)
_validate_choice("consensus client", consensus_client, VALID_CONSENSUS_CLIENTS)
_validate_choice("mev", mev, VALID_MEV_OPTIONS)
if ethgas and mev != "commit-boost":
raise ValueError("ETHGas requires mev=commit-boost")
updates = _config_updates(network, execution_client, consensus_client, mev)
config_path = None
if updates:
config_path = upsert_user_config(backend.repo_root, updates)
args = ["phase2"]
if execution_client:
args.append(f"--execution={execution_client}")
if consensus_client:
args.append(f"--consensus={consensus_client}")
if mev:
args.append(f"--mev={mev}")
if ethgas:
args.append("--ethgas")
if skip_deps:
args.append("--skip-deps")
result = backend.run_wrapper(*args)
result["config_path"] = str(config_path) if config_path else None
result["requested"] = {
"network": network,
"execution_client": execution_client,
"consensus_client": consensus_client,
"mev": mev,
"ethgas": ethgas,
"skip_deps": skip_deps,
}
return result
def setup_node(
backend: Eth2QuickStartBackend,
*,
phase: str,
network: str | None = None,
execution_client: str | None = None,
consensus_client: str | None = None,
mev: str | None = None,
ethgas: bool = False,
skip_deps: bool = False,
) -> dict:
if phase not in {"auto", "phase1", "phase2"}:
raise ValueError(f"Unsupported phase: {phase}")
if phase == "phase1":
result = backend.run_wrapper("phase1")
result["requested_phase"] = phase
return result
if phase == "phase2":
result = install_clients(
backend,
network=network,
execution_client=execution_client,
consensus_client=consensus_client,
mev=mev,
ethgas=ethgas,
skip_deps=skip_deps,
)
result["requested_phase"] = phase
return result
if any([execution_client, consensus_client, mev, ethgas]):
result = install_clients(
backend,
network=network,
execution_client=execution_client,
consensus_client=consensus_client,
mev=mev,
ethgas=ethgas,
skip_deps=skip_deps,
)
result["requested_phase"] = "auto-phase2"
return result
_validate_choice("network", network, VALID_NETWORKS)
config_path = None
updates = _config_updates(network, None, None, None)
if updates:
config_path = upsert_user_config(backend.repo_root, updates)
result = backend.run_wrapper("ensure", "--apply", "--confirm")
result["config_path"] = str(config_path) if config_path else None
result["requested"] = {
"network": network,
"execution_client": execution_client,
"consensus_client": consensus_client,
"mev": mev,
"ethgas": ethgas,
"skip_deps": skip_deps,
}
result["requested_phase"] = "auto-ensure"
return result
@@ -0,0 +1,76 @@
"""Project discovery and config helpers for eth2-quickstart."""
from __future__ import annotations
import os
import re
from pathlib import Path
REPO_ENV_VAR = "ETH2QS_REPO_ROOT"
WRAPPER_RELATIVE_PATH = Path("scripts") / "eth2qs.sh"
def find_repo_root(explicit_root: str | None = None, cwd: str | None = None) -> Path:
candidates: list[Path] = []
if explicit_root:
candidates.append(Path(explicit_root).expanduser())
env_root = os.environ.get(REPO_ENV_VAR)
if env_root:
candidates.append(Path(env_root).expanduser())
start = Path(cwd).resolve() if cwd else Path.cwd().resolve()
candidates.append(start)
candidates.extend(start.parents)
for candidate in candidates:
resolved = candidate.resolve()
if (resolved / WRAPPER_RELATIVE_PATH).is_file():
return resolved
raise RuntimeError(
"Could not locate an eth2-quickstart checkout. "
"Use --repo-root or set ETH2QS_REPO_ROOT."
)
def wrapper_path(repo_root: Path) -> Path:
return repo_root / WRAPPER_RELATIVE_PATH
def user_config_path(repo_root: Path) -> Path:
return repo_root / "config" / "user_config.env"
def ensure_user_config(repo_root: Path) -> Path:
config_path = user_config_path(repo_root)
config_path.parent.mkdir(parents=True, exist_ok=True)
if not config_path.exists():
config_path.write_text(
"# Managed by cli-anything-eth2-quickstart\n",
encoding="utf-8",
)
return config_path
def shell_quote(value: str) -> str:
return "'" + value.replace("'", "'\"'\"'") + "'"
def upsert_user_config(repo_root: Path, updates: dict[str, str]) -> Path:
config_path = ensure_user_config(repo_root)
existing = config_path.read_text(encoding="utf-8")
for key, value in updates.items():
line = f"export {key}={shell_quote(value)}"
pattern = re.compile(rf"^export {re.escape(key)}=.*$", re.MULTILINE)
if pattern.search(existing):
existing = pattern.sub(line, existing)
else:
if existing and not existing.endswith("\n"):
existing += "\n"
existing += line + "\n"
config_path.write_text(existing, encoding="utf-8")
return config_path
@@ -0,0 +1,40 @@
"""RPC exposure helpers."""
from __future__ import annotations
from typing import TYPE_CHECKING
from cli_anything.eth2_quickstart.core.commands import VALID_WEB_STACKS
from cli_anything.eth2_quickstart.core.project import upsert_user_config
if TYPE_CHECKING:
from cli_anything.eth2_quickstart.utils.eth2qs_backend import Eth2QuickStartBackend
def start_rpc(
backend: Eth2QuickStartBackend,
*,
web_stack: str,
server_name: str | None = None,
ssl: bool = False,
) -> dict:
if web_stack not in VALID_WEB_STACKS:
raise ValueError(f"Unsupported web stack: {web_stack}")
config_path = None
if server_name:
config_path = upsert_user_config(backend.repo_root, {"SERVER_NAME": server_name})
suffix = "_ssl" if ssl else ""
script = f"install/web/install_{web_stack}{suffix}.sh"
result = backend.run_script(script)
result["config_path"] = str(config_path) if config_path else None
result["rpc_url"] = (
f"{'https' if ssl else 'http'}://{server_name}/rpc" if server_name else None
)
result["ws_url"] = (
f"{'wss' if ssl else 'ws'}://{server_name}/ws" if server_name else None
)
result["web_stack"] = web_stack
result["ssl"] = ssl
return result
@@ -0,0 +1,45 @@
"""Status and health helpers."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from cli_anything.eth2_quickstart.utils.eth2qs_backend import Eth2QuickStartBackend
def _try_parse_json(text: str):
try:
return json.loads(text)
except json.JSONDecodeError:
return None
def health_check(backend: Eth2QuickStartBackend) -> dict:
result = backend.run_wrapper("doctor", "--json")
parsed = _try_parse_json(result.get("stdout", ""))
return {
"repo_root": str(backend.repo_root),
"command_result": result,
"doctor": parsed,
"ok": result["ok"],
}
def status(backend: Eth2QuickStartBackend) -> dict:
doctor_result = backend.run_wrapper("doctor", "--json")
plan_result = backend.run_wrapper("plan", "--json")
stats_result = backend.run_wrapper("stats")
return {
"repo_root": str(backend.repo_root),
"doctor": _try_parse_json(doctor_result.get("stdout", "")),
"plan": _try_parse_json(plan_result.get("stdout", "")),
"stats_raw": stats_result.get("stdout", ""),
"commands": {
"doctor": doctor_result,
"plan": plan_result,
"stats": stats_result,
},
"ok": doctor_result["ok"] and plan_result["ok"] and stats_result["ok"],
}
@@ -0,0 +1,41 @@
"""Validator configuration helpers."""
from __future__ import annotations
from typing import TYPE_CHECKING
from cli_anything.eth2_quickstart.core.commands import validator_plan
from cli_anything.eth2_quickstart.core.project import upsert_user_config
if TYPE_CHECKING:
from cli_anything.eth2_quickstart.utils.eth2qs_backend import Eth2QuickStartBackend
def configure_validator(
backend: Eth2QuickStartBackend,
*,
consensus_client: str,
fee_recipient: str | None = None,
graffiti: str | None = None,
keys_dir: str | None = None,
secrets_dir: str | None = None,
wallet_password_file: str | None = None,
) -> dict:
plan = validator_plan(
consensus_client=consensus_client,
fee_recipient=fee_recipient,
graffiti=graffiti,
keys_dir=keys_dir,
secrets_dir=secrets_dir,
wallet_password_file=wallet_password_file,
)
config_path = None
if plan["config_updates"]:
config_path = upsert_user_config(backend.repo_root, plan["config_updates"])
return {
"repo_root": str(backend.repo_root),
"config_path": str(config_path) if config_path else None,
"plan": plan,
"ok": True,
}
@@ -0,0 +1,284 @@
"""cli-anything-eth2-quickstart CLI."""
from __future__ import annotations
import json
import shlex
from typing import NoReturn
import click
from cli_anything.eth2_quickstart import __version__
from cli_anything.eth2_quickstart.core.commands import (
VALID_CONSENSUS_CLIENTS,
VALID_EXECUTION_CLIENTS,
VALID_MEV_OPTIONS,
VALID_NETWORKS,
)
from cli_anything.eth2_quickstart.core.install import install_clients, setup_node
from cli_anything.eth2_quickstart.core.rpc import start_rpc
from cli_anything.eth2_quickstart.core.status import health_check, status
from cli_anything.eth2_quickstart.core.validator import configure_validator
from cli_anything.eth2_quickstart.utils.eth2qs_backend import Eth2QuickStartBackend
CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
NETWORK_CHOICES = click.Choice(sorted(VALID_NETWORKS))
EXECUTION_CLIENT_CHOICES = click.Choice(sorted(VALID_EXECUTION_CLIENTS))
CONSENSUS_CLIENT_CHOICES = click.Choice(sorted(VALID_CONSENSUS_CLIENTS))
MEV_CHOICES = click.Choice(sorted(VALID_MEV_OPTIONS))
def emit(data, as_json: bool) -> None:
if as_json:
click.echo(json.dumps(data, indent=2, default=str))
return
if isinstance(data, dict):
for key, value in data.items():
if isinstance(value, (dict, list)):
click.echo(f"{key}: {json.dumps(value, default=str)}")
else:
click.echo(f"{key}: {value}")
return
click.echo(str(data))
def backend_from_context(ctx: click.Context) -> Eth2QuickStartBackend:
try:
return Eth2QuickStartBackend(ctx.obj["repo_root"])
except RuntimeError as exc:
fail(str(exc), ctx.obj["as_json"])
def fail(message: str, as_json: bool) -> NoReturn:
if as_json:
click.echo(json.dumps({"error": message}))
else:
click.echo(message)
raise click.exceptions.Exit(1)
def require_confirm(ctx: click.Context, confirm: bool = False) -> None:
if not (ctx.obj.get("confirm", False) or confirm):
fail("This command requires --confirm", ctx.obj["as_json"])
def handle_backend_result(result: dict, as_json: bool) -> None:
if result.get("ok"):
emit(result, as_json)
return
payload = {
"error": "Command failed",
"result": result,
}
if as_json:
click.echo(json.dumps(payload, indent=2))
else:
click.echo(f"ERROR {result.get('stderr') or result.get('stdout')}")
raise click.exceptions.Exit(1)
@click.group(context_settings=CONTEXT_SETTINGS, invoke_without_command=True)
@click.option("--repo-root", default=None, help="Path to an eth2-quickstart checkout")
@click.option("--json", "as_json", is_flag=True, default=False, help="Output as JSON")
@click.option("--confirm", is_flag=True, default=False, help="Confirm mutating operations")
@click.pass_context
def cli(ctx: click.Context, repo_root, as_json, confirm):
"""CLI harness for eth2-quickstart."""
ctx.ensure_object(dict)
ctx.obj["repo_root"] = repo_root
ctx.obj["as_json"] = as_json
ctx.obj["confirm"] = confirm
if ctx.invoked_subcommand is None:
ctx.invoke(repl)
def main():
cli(obj={})
@cli.command(hidden=True)
@click.pass_context
def repl(ctx: click.Context):
"""Interactive REPL mode."""
from cli_anything.eth2_quickstart.utils.repl_skin import ReplSkin
skin = ReplSkin("eth2-quickstart", version=__version__)
skin.print_banner()
pt_session = skin.create_prompt_session()
while True:
try:
line = skin.get_input(pt_session, project_name="repo")
except (EOFError, KeyboardInterrupt):
break
line = line.strip()
if not line:
continue
if line in {"exit", "quit"}:
break
if line == "help":
skin.help(
{
"setup-node": "Run phase1, phase2, or ensure-driven orchestration",
"install-clients": "Install execution, consensus, and MEV clients",
"start-rpc": "Install and start nginx/caddy RPC exposure",
"configure-validator": "Update validator metadata and return import guidance",
"status": "Show aggregate status",
"health-check": "Run doctor --json",
}
)
continue
try:
args = shlex.split(line)
cli.main(args=args, obj=dict(ctx.obj), standalone_mode=False)
except click.exceptions.Exit:
pass
except Exception as exc: # pragma: no cover - REPL fallback
skin.error(str(exc))
skin.print_goodbye()
@cli.command("setup-node")
@click.option("--phase", type=click.Choice(["auto", "phase1", "phase2"]), default="auto")
@click.option("--network", type=NETWORK_CHOICES, default=None)
@click.option("--execution-client", type=EXECUTION_CLIENT_CHOICES, default=None)
@click.option("--consensus-client", type=CONSENSUS_CLIENT_CHOICES, default=None)
@click.option("--mev", type=MEV_CHOICES, default=None)
@click.option("--ethgas", is_flag=True, default=False)
@click.option("--skip-deps", is_flag=True, default=False)
@click.option("--confirm", is_flag=True, default=False, help="Confirm mutating operations")
@click.pass_context
def setup_node_cmd(
ctx,
phase,
network,
execution_client,
consensus_client,
mev,
ethgas,
skip_deps,
confirm,
):
"""Set up a node using phase1, phase2, or ensure-driven orchestration."""
require_confirm(ctx, confirm)
backend = backend_from_context(ctx)
result = setup_node(
backend,
phase=phase,
network=network,
execution_client=execution_client,
consensus_client=consensus_client,
mev=mev,
ethgas=ethgas,
skip_deps=skip_deps,
)
handle_backend_result(result, ctx.obj["as_json"])
@cli.command("install-clients")
@click.option("--network", type=NETWORK_CHOICES, default=None)
@click.option("--execution-client", required=True, type=EXECUTION_CLIENT_CHOICES)
@click.option("--consensus-client", required=True, type=CONSENSUS_CLIENT_CHOICES)
@click.option("--mev", type=MEV_CHOICES, default="mev-boost")
@click.option("--ethgas", is_flag=True, default=False)
@click.option("--skip-deps", is_flag=True, default=False)
@click.option("--confirm", is_flag=True, default=False, help="Confirm mutating operations")
@click.pass_context
def install_clients_cmd(
ctx,
network,
execution_client,
consensus_client,
mev,
ethgas,
skip_deps,
confirm,
):
"""Install execution, consensus, and MEV clients via phase2."""
require_confirm(ctx, confirm)
backend = backend_from_context(ctx)
result = install_clients(
backend,
network=network,
execution_client=execution_client,
consensus_client=consensus_client,
mev=mev,
ethgas=ethgas,
skip_deps=skip_deps,
)
handle_backend_result(result, ctx.obj["as_json"])
@cli.command("start-rpc")
@click.option("--web-stack", type=click.Choice(["nginx", "caddy"]), default="nginx")
@click.option("--server-name", default=None, help="Public hostname for RPC exposure")
@click.option("--ssl/--no-ssl", default=False)
@click.option("--confirm", is_flag=True, default=False, help="Confirm mutating operations")
@click.pass_context
def start_rpc_cmd(ctx, web_stack, server_name, ssl, confirm):
"""Install and start RPC exposure via nginx or caddy."""
require_confirm(ctx, confirm)
backend = backend_from_context(ctx)
result = start_rpc(
backend,
web_stack=web_stack,
server_name=server_name,
ssl=ssl,
)
handle_backend_result(result, ctx.obj["as_json"])
@cli.command("configure-validator")
@click.option("--consensus-client", required=True, type=CONSENSUS_CLIENT_CHOICES)
@click.option("--fee-recipient", default=None)
@click.option("--graffiti", default=None)
@click.option("--keys-dir", default=None)
@click.option("--secrets-dir", default=None)
@click.option("--wallet-password-file", default=None)
@click.pass_context
def configure_validator_cmd(
ctx,
consensus_client,
fee_recipient,
graffiti,
keys_dir,
secrets_dir,
wallet_password_file,
):
"""Update validator metadata and return client-specific import guidance."""
backend = backend_from_context(ctx)
result = configure_validator(
backend,
consensus_client=consensus_client,
fee_recipient=fee_recipient,
graffiti=graffiti,
keys_dir=keys_dir,
secrets_dir=secrets_dir,
wallet_password_file=wallet_password_file,
)
emit(result, ctx.obj["as_json"])
@cli.command("status")
@click.pass_context
def status_cmd(ctx):
"""Show aggregate node status."""
backend = backend_from_context(ctx)
result = status(backend)
emit(result, ctx.obj["as_json"])
@cli.command("health-check")
@click.pass_context
def health_check_cmd(ctx):
"""Run the canonical doctor --json health check."""
backend = backend_from_context(ctx)
result = health_check(backend)
emit(result, ctx.obj["as_json"])
@@ -0,0 +1,73 @@
---
name: cli-anything-eth2-quickstart
description: >-
Use eth2-quickstart to autonomously deploy a hardened Ethereum node, install
execution and consensus clients, configure validator metadata, expose RPC
safely, and inspect node health with structured JSON output.
---
# cli-anything-eth2-quickstart
Agent-native harness for the `chimera-defi/eth2-quickstart` automation repo.
This CLI wraps the repo's canonical shell scripts instead of replacing them.
## When To Use
Use this skill when the task involves:
- bootstrapping a fresh Ethereum node host
- installing execution and consensus clients with explicit client diversity
- enabling MEV-Boost or Commit-Boost workflows
- exposing RPC through Nginx or Caddy
- updating validator fee recipient or graffiti settings without handling secrets
- checking machine-readable health with `--json`
## Core Commands
```bash
# Canonical machine-readable health
cli-anything-eth2-quickstart --json health-check
# Phase 2 install with explicit client choices
cli-anything-eth2-quickstart --json install-clients \
--network mainnet \
--execution-client geth \
--consensus-client lighthouse \
--mev mev-boost \
--confirm
# Guided node setup
cli-anything-eth2-quickstart --json setup-node \
--phase auto \
--execution-client geth \
--consensus-client prysm \
--mev commit-boost \
--confirm
# Validator metadata only; no key import
cli-anything-eth2-quickstart --json configure-validator \
--consensus-client prysm \
--fee-recipient 0x1111111111111111111111111111111111111111 \
--graffiti "CLI-Anything"
# Install nginx-backed RPC exposure
cli-anything-eth2-quickstart --json start-rpc \
--web-stack nginx \
--server-name rpc.example.org \
--confirm
```
## Safety Rules
- Always use `--json` for agent parsing.
- Require human confirmation before `setup-node`, `install-clients`, or `start-rpc`.
- Do not generate validator keys.
- Do not remove secrets or wallet material.
- Treat `configure-validator` as metadata and operator-guidance only.
- Respect the reboot boundary between Phase 1 and Phase 2.
## Runtime Expectations
- Operates on a local `eth2-quickstart` checkout.
- Discovers repo root from `--repo-root`, `ETH2QS_REPO_ROOT`, or current working directory.
- Writes compatible overrides into `config/user_config.env` when flags map directly to repo settings.
@@ -0,0 +1,67 @@
# cli-anything-eth2-quickstart Test Plan
## Unit
- repo root detection from explicit path and environment
- config file upsert behavior
- CLI help and JSON output
- phase 2 command construction
- validator guidance generation
- status and health aggregation with mocked subprocess results
## E2E
- skip automatically when no real `eth2-quickstart` checkout is configured
- verify wrapper discovery
- verify read-only commands (`help`, `health-check`) against a real checkout
## Latest Pytest Results
### Unit
```text
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-7.4.4, pluggy-1.4.0 -- /usr/bin/python3
cachedir: .pytest_cache
rootdir: /root/.openclaw/workspace/dev/CLI-Anything/eth2-quickstart/agent-harness
plugins: anyio-4.12.1
collecting ... collected 18 items
cli_anything/eth2_quickstart/tests/test_core.py::TestProjectHelpers::test_find_repo_root_from_explicit_path PASSED [ 5%]
cli_anything/eth2_quickstart/tests/test_core.py::TestProjectHelpers::test_find_repo_root_from_env PASSED [ 11%]
cli_anything/eth2_quickstart/tests/test_core.py::TestProjectHelpers::test_upsert_user_config PASSED [ 16%]
cli_anything/eth2_quickstart/tests/test_core.py::TestValidatorPlan::test_prysm_plan PASSED [ 22%]
cli_anything/eth2_quickstart/tests/test_core.py::TestValidatorPlan::test_invalid_client PASSED [ 27%]
cli_anything/eth2_quickstart/tests/test_core.py::TestCLI::test_help PASSED [ 33%]
cli_anything/eth2_quickstart/tests/test_core.py::TestCLI::test_missing_repo_root_returns_clean_json_error PASSED [ 38%]
cli_anything/eth2_quickstart/tests/test_core.py::TestCLI::test_health_check_json PASSED [ 44%]
cli_anything/eth2_quickstart/tests/test_core.py::TestCLI::test_install_clients_json PASSED [ 50%]
cli_anything/eth2_quickstart/tests/test_core.py::TestCLI::test_install_clients_rejects_unknown_execution_client PASSED [ 55%]
cli_anything/eth2_quickstart/tests/test_core.py::TestCLI::test_setup_node_auto_with_network_only_uses_ensure PASSED [ 61%]
cli_anything/eth2_quickstart/tests/test_core.py::TestCLI::test_setup_node_auto_with_client_selection_uses_phase2 PASSED [ 66%]
cli_anything/eth2_quickstart/tests/test_core.py::TestCLI::test_start_rpc_requires_confirm PASSED [ 72%]
cli_anything/eth2_quickstart/tests/test_core.py::TestCLI::test_configure_validator_json PASSED [ 77%]
cli_anything/eth2_quickstart/tests/test_core.py::TestCLI::test_configure_validator_rejects_unknown_consensus_client PASSED [ 83%]
cli_anything/eth2_quickstart/tests/test_core.py::TestCLI::test_status_json PASSED [ 88%]
cli_anything/eth2_quickstart/tests/test_core.py::TestBackendErrors::test_run_handles_missing_wrapper PASSED [ 94%]
cli_anything/eth2_quickstart/tests/test_core.py::TestBackendErrors::test_run_handles_permission_error PASSED [100%]
============================== 18 passed in 0.07s ==============================
```
### E2E
```text
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-7.4.4, pluggy-1.4.0 -- /usr/bin/python3
cachedir: .pytest_cache
rootdir: /root/.openclaw/workspace/dev/CLI-Anything/eth2-quickstart/agent-harness
plugins: anyio-4.12.1
collecting ... collected 3 items
cli_anything/eth2_quickstart/tests/test_full_e2e.py::TestRealCheckoutE2E::test_help SKIPPED [ 33%]
cli_anything/eth2_quickstart/tests/test_full_e2e.py::TestRealCheckoutE2E::test_health_check_json SKIPPED [ 66%]
cli_anything/eth2_quickstart/tests/test_full_e2e.py::TestRealCheckoutE2E::test_status_json SKIPPED [100%]
============================== 3 skipped in 0.02s ==============================
```
@@ -0,0 +1 @@
"""Tests for cli-anything-eth2-quickstart."""
@@ -0,0 +1,347 @@
"""Unit tests for cli-anything-eth2-quickstart."""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
from unittest.mock import patch
import pytest
from click.testing import CliRunner
from cli_anything.eth2_quickstart.core import project
from cli_anything.eth2_quickstart.core.commands import validator_plan
from cli_anything.eth2_quickstart.eth2_quickstart_cli import cli
@pytest.fixture
def runner():
return CliRunner()
@pytest.fixture
def repo_root(tmp_path: Path) -> Path:
repo = tmp_path / "eth2-quickstart"
(repo / "scripts").mkdir(parents=True)
(repo / "scripts" / "eth2qs.sh").write_text("#!/bin/bash\n", encoding="utf-8")
(repo / "config").mkdir()
return repo
class TestProjectHelpers:
def test_find_repo_root_from_explicit_path(self, repo_root: Path):
resolved = project.find_repo_root(str(repo_root))
assert resolved == repo_root
def test_find_repo_root_from_env(self, repo_root: Path, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("ETH2QS_REPO_ROOT", str(repo_root))
resolved = project.find_repo_root()
assert resolved == repo_root
def test_upsert_user_config(self, repo_root: Path):
config_path = project.upsert_user_config(
repo_root,
{
"ETH_NETWORK": "holesky",
"EXEC_CLIENT": "geth",
},
)
content = config_path.read_text(encoding="utf-8")
assert "export ETH_NETWORK='holesky'" in content
assert "export EXEC_CLIENT='geth'" in content
project.upsert_user_config(repo_root, {"ETH_NETWORK": "mainnet"})
updated = config_path.read_text(encoding="utf-8")
assert "export ETH_NETWORK='mainnet'" in updated
assert "export ETH_NETWORK='holesky'" not in updated
class TestValidatorPlan:
def test_prysm_plan(self):
plan = validator_plan(
consensus_client="prysm",
fee_recipient="0xabc",
graffiti="hello",
wallet_password_file="~/secrets/pass.txt",
)
assert plan["config_updates"]["FEE_RECIPIENT"] == "0xabc"
assert "validator accounts import" in plan["import_command"]
assert "wallet-password-file" in plan["post_import_commands"][0]
def test_invalid_client(self):
with pytest.raises(ValueError, match="Unsupported consensus client"):
validator_plan(consensus_client="bad-client")
class TestCLI:
def test_help(self, runner: CliRunner):
result = runner.invoke(cli, ["--help"])
assert result.exit_code == 0
assert "setup-node" in result.output
assert "health-check" in result.output
def test_missing_repo_root_returns_clean_json_error(self, runner: CliRunner):
result = runner.invoke(cli, ["--json", "health-check"])
assert result.exit_code == 1
payload = json.loads(result.output)
assert "Could not locate an eth2-quickstart checkout" in payload["error"]
@patch("cli_anything.eth2_quickstart.eth2_quickstart_cli.Eth2QuickStartBackend")
def test_health_check_json(self, backend_cls, runner: CliRunner, repo_root: Path):
backend = backend_cls.return_value
backend.repo_root = repo_root
backend.run_wrapper.return_value = {
"command": ["doctor", "--json"],
"cwd": str(repo_root),
"exit_code": 0,
"stdout": json.dumps({"summary": {"status": "pass"}}),
"stderr": "",
"ok": True,
}
result = runner.invoke(
cli,
["--repo-root", str(repo_root), "--json", "health-check"],
)
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["doctor"]["summary"]["status"] == "pass"
@patch("cli_anything.eth2_quickstart.eth2_quickstart_cli.Eth2QuickStartBackend")
def test_install_clients_json(self, backend_cls, runner: CliRunner, repo_root: Path):
backend = backend_cls.return_value
backend.repo_root = repo_root
backend.run_wrapper.return_value = {
"command": ["phase2"],
"cwd": str(repo_root),
"exit_code": 0,
"stdout": "installed",
"stderr": "",
"ok": True,
}
result = runner.invoke(
cli,
[
"--repo-root",
str(repo_root),
"--json",
"install-clients",
"--network",
"mainnet",
"--execution-client",
"geth",
"--consensus-client",
"lighthouse",
"--mev",
"mev-boost",
"--confirm",
],
)
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["requested"]["execution_client"] == "geth"
assert payload["requested"]["consensus_client"] == "lighthouse"
def test_install_clients_rejects_unknown_execution_client(self, runner: CliRunner, repo_root: Path):
result = runner.invoke(
cli,
[
"--repo-root",
str(repo_root),
"install-clients",
"--execution-client",
"bad-client",
"--consensus-client",
"lighthouse",
"--confirm",
],
)
assert result.exit_code == 2
assert "Invalid value for '--execution-client'" in result.output
@patch("cli_anything.eth2_quickstart.eth2_quickstart_cli.Eth2QuickStartBackend")
def test_setup_node_auto_with_network_only_uses_ensure(self, backend_cls, runner: CliRunner, repo_root: Path):
backend = backend_cls.return_value
backend.repo_root = repo_root
backend.run_wrapper.return_value = {
"command": ["ensure", "--apply", "--confirm"],
"cwd": str(repo_root),
"exit_code": 0,
"stdout": "ensured",
"stderr": "",
"ok": True,
}
result = runner.invoke(
cli,
[
"--repo-root",
str(repo_root),
"--json",
"setup-node",
"--network",
"holesky",
"--confirm",
],
)
assert result.exit_code == 0
backend.run_wrapper.assert_called_once_with("ensure", "--apply", "--confirm")
payload = json.loads(result.output)
assert payload["requested_phase"] == "auto-ensure"
assert payload["requested"]["network"] == "holesky"
assert payload["config_path"] is not None
@patch("cli_anything.eth2_quickstart.eth2_quickstart_cli.Eth2QuickStartBackend")
def test_setup_node_auto_with_client_selection_uses_phase2(self, backend_cls, runner: CliRunner, repo_root: Path):
backend = backend_cls.return_value
backend.repo_root = repo_root
backend.run_wrapper.return_value = {
"command": ["phase2", "--execution=geth", "--consensus=lighthouse"],
"cwd": str(repo_root),
"exit_code": 0,
"stdout": "installed",
"stderr": "",
"ok": True,
}
result = runner.invoke(
cli,
[
"--repo-root",
str(repo_root),
"--json",
"setup-node",
"--network",
"holesky",
"--execution-client",
"geth",
"--consensus-client",
"lighthouse",
"--confirm",
],
)
assert result.exit_code == 0
backend.run_wrapper.assert_called_once_with(
"phase2",
"--execution=geth",
"--consensus=lighthouse",
)
payload = json.loads(result.output)
assert payload["requested_phase"] == "auto-phase2"
assert payload["requested"]["network"] == "holesky"
@patch("cli_anything.eth2_quickstart.eth2_quickstart_cli.Eth2QuickStartBackend")
def test_start_rpc_requires_confirm(self, backend_cls, runner: CliRunner, repo_root: Path):
backend = backend_cls.return_value
backend.repo_root = repo_root
result = runner.invoke(
cli,
[
"--repo-root",
str(repo_root),
"start-rpc",
"--web-stack",
"nginx",
],
)
assert result.exit_code == 1
assert "requires --confirm" in result.output
@patch("cli_anything.eth2_quickstart.eth2_quickstart_cli.Eth2QuickStartBackend")
def test_configure_validator_json(self, backend_cls, runner: CliRunner, repo_root: Path):
backend = backend_cls.return_value
backend.repo_root = repo_root
result = runner.invoke(
cli,
[
"--repo-root",
str(repo_root),
"--json",
"configure-validator",
"--consensus-client",
"prysm",
"--fee-recipient",
"0x1111111111111111111111111111111111111111",
"--graffiti",
"test",
],
)
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["plan"]["consensus_client"] == "prysm"
assert payload["plan"]["config_updates"]["GRAFITTI"] == "test"
def test_configure_validator_rejects_unknown_consensus_client(
self, runner: CliRunner, repo_root: Path
):
result = runner.invoke(
cli,
[
"--repo-root",
str(repo_root),
"configure-validator",
"--consensus-client",
"bad-client",
],
)
assert result.exit_code == 2
assert "Invalid value for '--consensus-client'" in result.output
@patch("cli_anything.eth2_quickstart.eth2_quickstart_cli.Eth2QuickStartBackend")
def test_status_json(self, backend_cls, runner: CliRunner, repo_root: Path):
backend = backend_cls.return_value
backend.repo_root = repo_root
backend.run_wrapper.side_effect = [
{
"command": ["doctor", "--json"],
"cwd": str(repo_root),
"exit_code": 0,
"stdout": json.dumps({"summary": {"status": "warn"}}),
"stderr": "",
"ok": True,
},
{
"command": ["plan", "--json"],
"cwd": str(repo_root),
"exit_code": 0,
"stdout": json.dumps({"next_action": "phase2"}),
"stderr": "",
"ok": True,
},
{
"command": ["stats"],
"cwd": str(repo_root),
"exit_code": 0,
"stdout": "service status",
"stderr": "",
"ok": True,
},
]
result = runner.invoke(
cli,
["--repo-root", str(repo_root), "--json", "status"],
)
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["doctor"]["summary"]["status"] == "warn"
assert payload["plan"]["next_action"] == "phase2"
class TestBackendErrors:
def test_run_handles_missing_wrapper(self, repo_root: Path):
from cli_anything.eth2_quickstart.utils.eth2qs_backend import Eth2QuickStartBackend
backend = Eth2QuickStartBackend(str(repo_root))
result = backend._run(["/definitely/missing/eth2qs.sh"])
assert result["ok"] is False
assert result["exit_code"] == 127
assert "command not found" in result["stderr"]
@patch("cli_anything.eth2_quickstart.utils.eth2qs_backend.subprocess.run")
def test_run_handles_permission_error(self, run_mock, repo_root: Path):
from cli_anything.eth2_quickstart.utils.eth2qs_backend import Eth2QuickStartBackend
run_mock.side_effect = PermissionError("no execute bit")
backend = Eth2QuickStartBackend(str(repo_root))
result = backend._run(["/tmp/not-executable"])
assert result["ok"] is False
assert result["exit_code"] == 126
assert "permission denied" in result["stderr"]
@@ -0,0 +1,54 @@
"""E2E tests for cli-anything-eth2-quickstart."""
from __future__ import annotations
import json
import os
from pathlib import Path
import pytest
from click.testing import CliRunner
from cli_anything.eth2_quickstart.eth2_quickstart_cli import cli
E2E_REPO_ROOT = os.environ.get("ETH2QS_E2E_REPO_ROOT")
WRAPPER_EXISTS = bool(E2E_REPO_ROOT) and (Path(E2E_REPO_ROOT) / "scripts" / "eth2qs.sh").is_file()
pytestmark = pytest.mark.skipif(
not WRAPPER_EXISTS,
reason="Set ETH2QS_E2E_REPO_ROOT to a real eth2-quickstart checkout to run E2E tests",
)
@pytest.fixture
def runner():
return CliRunner()
class TestRealCheckoutE2E:
def test_help(self, runner: CliRunner):
result = runner.invoke(
cli,
["--repo-root", E2E_REPO_ROOT, "--help"],
)
assert result.exit_code == 0
def test_health_check_json(self, runner: CliRunner):
result = runner.invoke(
cli,
["--repo-root", E2E_REPO_ROOT, "--json", "health-check"],
)
assert result.exit_code == 0
payload = json.loads(result.output)
assert "command_result" in payload
assert payload["command_result"]["command"][-2:] == ["doctor", "--json"]
def test_status_json(self, runner: CliRunner):
result = runner.invoke(
cli,
["--repo-root", E2E_REPO_ROOT, "--json", "status"],
)
assert result.exit_code == 0
payload = json.loads(result.output)
assert "plan" in payload
assert "stats_raw" in payload
@@ -0,0 +1 @@
"""Utility helpers for cli-anything-eth2-quickstart."""
@@ -0,0 +1,56 @@
"""Subprocess backend for eth2-quickstart."""
from __future__ import annotations
import subprocess
from pathlib import Path
from cli_anything.eth2_quickstart.core.project import find_repo_root, wrapper_path
class Eth2QuickStartBackend:
def __init__(self, repo_root: str | None = None):
self.repo_root = find_repo_root(repo_root)
def _run(self, command: list[str]) -> dict:
try:
completed = subprocess.run(
command,
cwd=str(self.repo_root),
text=True,
capture_output=True,
check=False,
)
return {
"command": command,
"cwd": str(self.repo_root),
"exit_code": completed.returncode,
"stdout": completed.stdout.strip(),
"stderr": completed.stderr.strip(),
"ok": completed.returncode == 0,
}
except FileNotFoundError as exc:
return {
"command": command,
"cwd": str(self.repo_root),
"exit_code": 127,
"stdout": "",
"stderr": f"{command[0]}: command not found ({exc})",
"ok": False,
}
except PermissionError as exc:
return {
"command": command,
"cwd": str(self.repo_root),
"exit_code": 126,
"stdout": "",
"stderr": f"{command[0]}: permission denied ({exc})",
"ok": False,
}
def run_wrapper(self, *args: str) -> dict:
return self._run([str(wrapper_path(self.repo_root)), *args])
def run_script(self, relative_path: str, *args: str) -> dict:
script_path = self.repo_root / Path(relative_path)
return self._run([str(script_path), *args])
@@ -0,0 +1,567 @@
"""cli-anything REPL Skin — Unified terminal interface for all CLI harnesses.
Copy this file into your CLI package at:
cli_anything/<software>/utils/repl_skin.py
Usage:
from cli_anything.<software>.utils.repl_skin import ReplSkin
skin = ReplSkin("shotcut", version="1.0.0")
skin.print_banner() # auto-detects repo-root or packaged SKILL.md
prompt_text = skin.prompt(project_name="my_video.mlt", modified=True)
skin.success("Project saved")
skin.error("File not found")
skin.warning("Unsaved changes")
skin.info("Processing 24 clips...")
skin.status("Track 1", "3 clips, 00:02:30")
skin.table(headers, rows)
skin.print_goodbye()
"""
import os
import sys
from pathlib import Path
# ── ANSI color codes (no external deps for core styling) ──────────────
_RESET = "\033[0m"
_BOLD = "\033[1m"
_DIM = "\033[2m"
_ITALIC = "\033[3m"
_UNDERLINE = "\033[4m"
# Brand colors
_CYAN = "\033[38;5;80m" # cli-anything brand cyan
_CYAN_BG = "\033[48;5;80m"
_WHITE = "\033[97m"
_GRAY = "\033[38;5;245m"
_DARK_GRAY = "\033[38;5;240m"
_LIGHT_GRAY = "\033[38;5;250m"
# Software accent colors — each software gets a unique accent
_ACCENT_COLORS = {
"gimp": "\033[38;5;214m", # warm orange
"blender": "\033[38;5;208m", # deep orange
"inkscape": "\033[38;5;39m", # bright blue
"audacity": "\033[38;5;33m", # navy blue
"libreoffice": "\033[38;5;40m", # green
"obs_studio": "\033[38;5;55m", # purple
"kdenlive": "\033[38;5;69m", # slate blue
"shotcut": "\033[38;5;35m", # teal green
}
_DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue
# Status colors
_GREEN = "\033[38;5;78m"
_YELLOW = "\033[38;5;220m"
_RED = "\033[38;5;196m"
_BLUE = "\033[38;5;75m"
_MAGENTA = "\033[38;5;176m"
_SKILL_SOURCE_REPO = os.environ.get("CLI_ANYTHING_SKILL_REPO", "HKUDS/CLI-Anything")
# ── Brand icon ────────────────────────────────────────────────────────
# The cli-anything icon: a small colored diamond/chevron mark
_ICON = f"{_CYAN}{_BOLD}{_RESET}"
_ICON_SMALL = f"{_CYAN}{_RESET}"
# ── Box drawing characters ────────────────────────────────────────────
_H_LINE = ""
_V_LINE = ""
_TL = ""
_TR = ""
_BL = ""
_BR = ""
_T_DOWN = ""
_T_UP = ""
_T_RIGHT = ""
_T_LEFT = ""
_CROSS = ""
def _strip_ansi(text: str) -> str:
"""Remove ANSI escape codes for length calculation."""
import re
return re.sub(r"\033\[[^m]*m", "", text)
def _visible_len(text: str) -> int:
"""Get visible length of text (excluding ANSI codes)."""
return len(_strip_ansi(text))
def _display_home_path(path: str) -> str:
"""Display a path relative to the home directory when possible."""
expanded = Path(path).expanduser().resolve()
home = Path.home().resolve()
try:
relative = expanded.relative_to(home)
return f"~/{relative.as_posix()}"
except ValueError:
return str(expanded)
class ReplSkin:
"""Unified REPL skin for cli-anything CLIs.
Provides consistent branding, prompts, and message formatting
across all CLI harnesses built with the cli-anything methodology.
"""
def __init__(self, software: str, version: str = "1.0.0",
history_file: str | None = None, skill_path: str | None = None):
"""Initialize the REPL skin.
Args:
software: Software name (e.g., "gimp", "shotcut", "blender").
version: CLI version string.
history_file: Path for persistent command history.
Defaults to ~/.cli-anything-<software>/history
skill_path: Path to the SKILL.md file for agent discovery.
Auto-detected from the repo-root skills/ tree when present,
otherwise from the package's skills/ directory.
Displayed in banner for AI agents to know where to read skill info.
"""
self.software = software.lower().replace("-", "_")
self.display_name = software.replace("_", " ").title()
self.version = version
software_aliases = {"iterm2_ctl": "iterm2"}
self.skill_slug = software_aliases.get(self.software, self.software).replace("_", "-")
self.skill_id = f"cli-anything-{self.skill_slug}"
self.skill_install_cmd = (
f"npx skills add {_SKILL_SOURCE_REPO} --skill {self.skill_id} -g -y"
)
global_skill_root = Path(
os.environ.get("CLI_ANYTHING_GLOBAL_SKILLS_DIR", str(Path.home() / ".agents" / "skills"))
).expanduser()
self.global_skill_path = str(global_skill_root / self.skill_id / "SKILL.md")
# Prefer repo-root canonical skills/<skill-id>/SKILL.md when running
# inside the CLI-Anything monorepo. Fall back to the packaged
# cli_anything/<software>/skills/SKILL.md for installed harnesses.
if skill_path is None:
package_skill = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md"
repo_skill = None
for parent in Path(__file__).resolve().parents:
candidate = parent / "skills" / self.skill_id / "SKILL.md"
if candidate.is_file():
repo_skill = candidate
break
if repo_skill and repo_skill.is_file():
skill_path = str(repo_skill)
elif package_skill.is_file():
skill_path = str(package_skill)
self.skill_path = skill_path
self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT)
# History file
if history_file is None:
hist_dir = Path.home() / f".cli-anything-{self.software}"
hist_dir.mkdir(parents=True, exist_ok=True)
self.history_file = str(hist_dir / "history")
else:
self.history_file = history_file
# Detect terminal capabilities
self._color = self._detect_color_support()
def _detect_color_support(self) -> bool:
"""Check if terminal supports color."""
if os.environ.get("NO_COLOR"):
return False
if os.environ.get("CLI_ANYTHING_NO_COLOR"):
return False
if not hasattr(sys.stdout, "isatty"):
return False
return sys.stdout.isatty()
def _c(self, code: str, text: str) -> str:
"""Apply color code if colors are supported."""
if not self._color:
return text
return f"{code}{text}{_RESET}"
# ── Banner ────────────────────────────────────────────────────────
def print_banner(self):
"""Print the startup banner with branding."""
import textwrap
inner = 72
def _box_line(content: str) -> str:
"""Wrap content in box drawing, padding to inner width."""
pad = inner - _visible_len(content)
vl = self._c(_DARK_GRAY, _V_LINE)
return f"{vl}{content}{' ' * max(0, pad)}{vl}"
def _meta_lines(label: str, value: str) -> list[str]:
"""Wrap a metadata line for the banner box."""
icon = self._c(_MAGENTA, "")
label_text = self._c(_DARK_GRAY, label)
prefix = f" {icon} {label_text} "
available = max(12, inner - _visible_len(prefix))
wrapped = textwrap.wrap(
value,
width=available,
break_long_words=True,
break_on_hyphens=False,
) or [""]
lines = [f"{prefix}{self._c(_LIGHT_GRAY, wrapped[0])}"]
continuation_prefix = " " * _visible_len(prefix)
for chunk in wrapped[1:]:
lines.append(f"{continuation_prefix}{self._c(_LIGHT_GRAY, chunk)}")
return lines
top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}")
bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}")
# Title: ◆ cli-anything · Shotcut
icon = self._c(_CYAN + _BOLD, "")
brand = self._c(_CYAN + _BOLD, "cli-anything")
dot = self._c(_DARK_GRAY, "·")
name = self._c(self.accent + _BOLD, self.display_name)
title = f" {icon} {brand} {dot} {name}"
ver = f" {self._c(_DARK_GRAY, f' v{self.version}')}"
tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}"
empty = ""
meta_lines: list[str] = []
meta_lines.extend(_meta_lines("Install:", self.skill_install_cmd))
meta_lines.extend(_meta_lines("Global skill:", _display_home_path(self.global_skill_path)))
print(top)
print(_box_line(title))
print(_box_line(ver))
for line in meta_lines:
print(_box_line(line))
print(_box_line(empty))
print(_box_line(tip))
print(bot)
print()
# ── Prompt ────────────────────────────────────────────────────────
def prompt(self, project_name: str = "", modified: bool = False,
context: str = "") -> str:
"""Build a styled prompt string for prompt_toolkit or input().
Args:
project_name: Current project name (empty if none open).
modified: Whether the project has unsaved changes.
context: Optional extra context to show in prompt.
Returns:
Formatted prompt string.
"""
parts = []
# Icon
if self._color:
parts.append(f"{_CYAN}{_RESET} ")
else:
parts.append("> ")
# Software name
parts.append(self._c(self.accent + _BOLD, self.software))
# Project context
if project_name or context:
ctx = context or project_name
mod = "*" if modified else ""
parts.append(f" {self._c(_DARK_GRAY, '[')}")
parts.append(self._c(_LIGHT_GRAY, f"{ctx}{mod}"))
parts.append(self._c(_DARK_GRAY, ']'))
parts.append(self._c(_GRAY, " "))
return "".join(parts)
def prompt_tokens(self, project_name: str = "", modified: bool = False,
context: str = ""):
"""Build prompt_toolkit formatted text tokens for the prompt.
Use with prompt_toolkit's FormattedText for proper ANSI handling.
Returns:
list of (style, text) tuples for prompt_toolkit.
"""
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
tokens = []
tokens.append(("class:icon", ""))
tokens.append(("class:software", self.software))
if project_name or context:
ctx = context or project_name
mod = "*" if modified else ""
tokens.append(("class:bracket", " ["))
tokens.append(("class:context", f"{ctx}{mod}"))
tokens.append(("class:bracket", "]"))
tokens.append(("class:arrow", " "))
return tokens
def get_prompt_style(self):
"""Get a prompt_toolkit Style object matching the skin.
Returns:
prompt_toolkit.styles.Style
"""
try:
from prompt_toolkit.styles import Style
except ImportError:
return None
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
return Style.from_dict({
"icon": "#5fdfdf bold", # cyan brand color
"software": f"{accent_hex} bold",
"bracket": "#585858",
"context": "#bcbcbc",
"arrow": "#808080",
# Completion menu
"completion-menu.completion": "bg:#303030 #bcbcbc",
"completion-menu.completion.current": f"bg:{accent_hex} #000000",
"completion-menu.meta.completion": "bg:#303030 #808080",
"completion-menu.meta.completion.current": f"bg:{accent_hex} #000000",
# Auto-suggest
"auto-suggest": "#585858",
# Bottom toolbar
"bottom-toolbar": "bg:#1c1c1c #808080",
"bottom-toolbar.text": "#808080",
})
# ── Messages ──────────────────────────────────────────────────────
def success(self, message: str):
"""Print a success message with green checkmark."""
icon = self._c(_GREEN + _BOLD, "")
print(f" {icon} {self._c(_GREEN, message)}")
def error(self, message: str):
"""Print an error message with red cross."""
icon = self._c(_RED + _BOLD, "")
print(f" {icon} {self._c(_RED, message)}", file=sys.stderr)
def warning(self, message: str):
"""Print a warning message with yellow triangle."""
icon = self._c(_YELLOW + _BOLD, "")
print(f" {icon} {self._c(_YELLOW, message)}")
def info(self, message: str):
"""Print an info message with blue dot."""
icon = self._c(_BLUE, "")
print(f" {icon} {self._c(_LIGHT_GRAY, message)}")
def hint(self, message: str):
"""Print a subtle hint message."""
print(f" {self._c(_DARK_GRAY, message)}")
def section(self, title: str):
"""Print a section header."""
print()
print(f" {self._c(self.accent + _BOLD, title)}")
print(f" {self._c(_DARK_GRAY, _H_LINE * len(title))}")
# ── Status display ────────────────────────────────────────────────
def status(self, label: str, value: str):
"""Print a key-value status line."""
lbl = self._c(_GRAY, f" {label}:")
val = self._c(_WHITE, f" {value}")
print(f"{lbl}{val}")
def status_block(self, items: dict[str, str], title: str = ""):
"""Print a block of status key-value pairs.
Args:
items: Dict of label -> value pairs.
title: Optional title for the block.
"""
if title:
self.section(title)
max_key = max(len(k) for k in items) if items else 0
for label, value in items.items():
lbl = self._c(_GRAY, f" {label:<{max_key}}")
val = self._c(_WHITE, f" {value}")
print(f"{lbl}{val}")
def progress(self, current: int, total: int, label: str = ""):
"""Print a simple progress indicator.
Args:
current: Current step number.
total: Total number of steps.
label: Optional label for the progress.
"""
pct = int(current / total * 100) if total > 0 else 0
bar_width = 20
filled = int(bar_width * current / total) if total > 0 else 0
bar = "" * filled + "" * (bar_width - filled)
text = f" {self._c(_CYAN, bar)} {self._c(_GRAY, f'{pct:3d}%')}"
if label:
text += f" {self._c(_LIGHT_GRAY, label)}"
print(text)
# ── Table display ─────────────────────────────────────────────────
def table(self, headers: list[str], rows: list[list[str]],
max_col_width: int = 40):
"""Print a formatted table with box-drawing characters.
Args:
headers: Column header strings.
rows: List of rows, each a list of cell strings.
max_col_width: Maximum column width before truncation.
"""
if not headers:
return
# Calculate column widths
col_widths = [min(len(h), max_col_width) for h in headers]
for row in rows:
for i, cell in enumerate(row):
if i < len(col_widths):
col_widths[i] = min(
max(col_widths[i], len(str(cell))), max_col_width
)
def pad(text: str, width: int) -> str:
t = str(text)[:width]
return t + " " * (width - len(t))
# Header
header_cells = [
self._c(_CYAN + _BOLD, pad(h, col_widths[i]))
for i, h in enumerate(headers)
]
sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
header_line = f" {sep.join(header_cells)}"
print(header_line)
# Separator
sep_parts = [self._c(_DARK_GRAY, _H_LINE * w) for w in col_widths]
sep_line = self._c(_DARK_GRAY, f" {'───'.join([_H_LINE * w for w in col_widths])}")
print(sep_line)
# Rows
for row in rows:
cells = []
for i, cell in enumerate(row):
if i < len(col_widths):
cells.append(self._c(_LIGHT_GRAY, pad(str(cell), col_widths[i])))
row_sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
print(f" {row_sep.join(cells)}")
# ── Help display ──────────────────────────────────────────────────
def help(self, commands: dict[str, str]):
"""Print a formatted help listing.
Args:
commands: Dict of command -> description pairs.
"""
self.section("Commands")
max_cmd = max(len(c) for c in commands) if commands else 0
for cmd, desc in commands.items():
cmd_styled = self._c(self.accent, f" {cmd:<{max_cmd}}")
desc_styled = self._c(_GRAY, f" {desc}")
print(f"{cmd_styled}{desc_styled}")
print()
# ── Goodbye ───────────────────────────────────────────────────────
def print_goodbye(self):
"""Print a styled goodbye message."""
print(f"\n {_ICON_SMALL} {self._c(_GRAY, 'Goodbye!')}\n")
# ── Prompt toolkit session factory ────────────────────────────────
def create_prompt_session(self):
"""Create a prompt_toolkit PromptSession with skin styling.
Returns:
A configured PromptSession, or None if prompt_toolkit unavailable.
"""
try:
from prompt_toolkit import PromptSession
from prompt_toolkit.history import FileHistory
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
from prompt_toolkit.formatted_text import FormattedText
style = self.get_prompt_style()
session = PromptSession(
history=FileHistory(self.history_file),
auto_suggest=AutoSuggestFromHistory(),
style=style,
enable_history_search=True,
)
return session
except ImportError:
return None
def get_input(self, pt_session, project_name: str = "",
modified: bool = False, context: str = "") -> str:
"""Get input from user using prompt_toolkit or fallback.
Args:
pt_session: A prompt_toolkit PromptSession (or None).
project_name: Current project name.
modified: Whether project has unsaved changes.
context: Optional context string.
Returns:
User input string (stripped).
"""
if pt_session is not None:
from prompt_toolkit.formatted_text import FormattedText
tokens = self.prompt_tokens(project_name, modified, context)
return pt_session.prompt(FormattedText(tokens)).strip()
else:
raw_prompt = self.prompt(project_name, modified, context)
return input(raw_prompt).strip()
# ── Toolbar builder ───────────────────────────────────────────────
def bottom_toolbar(self, items: dict[str, str]):
"""Create a bottom toolbar callback for prompt_toolkit.
Args:
items: Dict of label -> value pairs to show in toolbar.
Returns:
A callable that returns FormattedText for the toolbar.
"""
def toolbar():
from prompt_toolkit.formatted_text import FormattedText
parts = []
for i, (k, v) in enumerate(items.items()):
if i > 0:
parts.append(("class:bottom-toolbar.text", ""))
parts.append(("class:bottom-toolbar.text", f" {k}: "))
parts.append(("class:bottom-toolbar", v))
return FormattedText(parts)
return toolbar
# ── ANSI 256-color to hex mapping (for prompt_toolkit styles) ─────────
_ANSI_256_TO_HEX = {
"\033[38;5;33m": "#0087ff", # audacity navy blue
"\033[38;5;35m": "#00af5f", # shotcut teal
"\033[38;5;39m": "#00afff", # inkscape bright blue
"\033[38;5;40m": "#00d700", # libreoffice green
"\033[38;5;55m": "#5f00af", # obs purple
"\033[38;5;69m": "#5f87ff", # kdenlive slate blue
"\033[38;5;75m": "#5fafff", # default sky blue
"\033[38;5;80m": "#5fd7d7", # brand cyan
"\033[38;5;208m": "#ff8700", # blender deep orange
"\033[38;5;214m": "#ffaf00", # gimp warm orange
}
+33
View File
@@ -0,0 +1,33 @@
from setuptools import find_namespace_packages, setup
with open("cli_anything/eth2_quickstart/README.md", "r", encoding="utf-8") as fh:
long_description = fh.read()
setup(
name="cli-anything-eth2-quickstart",
version="1.0.0",
description="CLI harness for eth2-quickstart - hardened Ethereum node deployment and operations",
long_description=long_description,
long_description_content_type="text/markdown",
url="https://github.com/HKUDS/CLI-Anything",
packages=find_namespace_packages(include=["cli_anything.*"]),
install_requires=[
"click>=8.0.0",
"prompt-toolkit>=3.0.0",
],
extras_require={
"dev": [
"pytest>=7.0.0",
]
},
entry_points={
"console_scripts": [
"cli-anything-eth2-quickstart=cli_anything.eth2_quickstart.eth2_quickstart_cli:main",
]
},
package_data={
"cli_anything.eth2_quickstart": ["skills/*.md"],
},
include_package_data=True,
python_requires=">=3.10",
)