diff --git a/.gitignore b/.gitignore index 0cd81b6a2..47851da6e 100644 --- a/.gitignore +++ b/.gitignore @@ -87,6 +87,7 @@ !/freecad/ !/iterm2/ !/slay_the_spire_ii/ +!/eth2-quickstart/ !/rms/ !/renderdoc/ !/cloudcompare/ @@ -153,6 +154,8 @@ /iterm2/.* /slay_the_spire_ii/* /slay_the_spire_ii/.* +/eth2-quickstart/* +/eth2-quickstart/.* /rms/* /rms/.* /renderdoc/* @@ -204,6 +207,7 @@ !/iterm2/agent-harness/ !/slay_the_spire_ii/agent-harness/ !/slay_the_spire_ii/README.md +!/eth2-quickstart/agent-harness/ !/rms/agent-harness/ !/renderdoc/agent-harness/ !/cloudcompare/agent-harness/ diff --git a/README.md b/README.md index 0b483d09c..6b36f3c3e 100644 --- a/README.md +++ b/README.md @@ -861,6 +861,13 @@ Each application received complete, production-ready CLI interfaces — not demo ✅ 138 +⛓️ ETH2 QuickStart +DevOps / Ethereum +cli-anything-eth2-quickstart +eth2-quickstart shell automation + JSON health checks +✅ 18 + + 🧜 Mermaid Live Editor Diagramming cli-anything-mermaid @@ -978,6 +985,7 @@ kdenlive 155 passed ✅ (111 unit + 44 e2e) shotcut 154 passed ✅ (110 unit + 44 e2e) zoom 22 passed ✅ (22 unit + 0 e2e) drawio 138 passed ✅ (116 unit + 22 e2e) +eth2-quickstart 18 passed ✅ (18 unit + 3 e2e skipped) mermaid 10 passed ✅ (5 unit + 5 e2e) anygen 50 passed ✅ (40 unit + 10 e2e) notebooklm 21 passed ✅ (21 unit + 0 e2e) @@ -1053,6 +1061,7 @@ cli-anything/ ├── 📞 zoom/agent-harness/ # Zoom CLI (22 tests) ├── 🎵 musescore/agent-harness/ # MuseScore CLI (56 tests) ├── 📐 drawio/agent-harness/ # Draw.io CLI (138 tests) +├── ⛓️ eth2-quickstart/agent-harness/ # ETH2 QuickStart CLI (18 unit, 3 e2e skipped) ├── 🧜 mermaid/agent-harness/ # Mermaid Live Editor CLI (10 tests) ├── ✨ anygen/agent-harness/ # AnyGen CLI (50 tests) ├── 🖼️ comfyui/agent-harness/ # ComfyUI CLI (70 tests) diff --git a/eth2-quickstart/agent-harness/ETH2-QUICKSTART.md b/eth2-quickstart/agent-harness/ETH2-QUICKSTART.md new file mode 100644 index 000000000..c02b3ea4a --- /dev/null +++ b/eth2-quickstart/agent-harness/ETH2-QUICKSTART.md @@ -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. diff --git a/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/README.md b/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/README.md new file mode 100644 index 000000000..28feb6b15 --- /dev/null +++ b/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/README.md @@ -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 +``` diff --git a/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/__init__.py b/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/__init__.py new file mode 100644 index 000000000..f2d223fd5 --- /dev/null +++ b/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/__init__.py @@ -0,0 +1,5 @@ +"""cli-anything-eth2-quickstart package.""" + +__all__ = ["__version__"] + +__version__ = "1.0.0" diff --git a/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/__main__.py b/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/__main__.py new file mode 100644 index 000000000..8ea9c8eb6 --- /dev/null +++ b/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/__main__.py @@ -0,0 +1,5 @@ +from cli_anything.eth2_quickstart.eth2_quickstart_cli import main + + +if __name__ == "__main__": + main() diff --git a/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/core/__init__.py b/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/core/__init__.py new file mode 100644 index 000000000..e010d0d14 --- /dev/null +++ b/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/core/__init__.py @@ -0,0 +1 @@ +"""Core helpers for cli-anything-eth2-quickstart.""" diff --git a/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/core/commands.py b/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/core/commands.py new file mode 100644 index 000000000..d02c0d276 --- /dev/null +++ b/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/core/commands.py @@ -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.", + ], + } diff --git a/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/core/install.py b/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/core/install.py new file mode 100644 index 000000000..4b265aa27 --- /dev/null +++ b/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/core/install.py @@ -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 diff --git a/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/core/project.py b/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/core/project.py new file mode 100644 index 000000000..07a0649b3 --- /dev/null +++ b/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/core/project.py @@ -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 diff --git a/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/core/rpc.py b/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/core/rpc.py new file mode 100644 index 000000000..74c5b0770 --- /dev/null +++ b/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/core/rpc.py @@ -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 diff --git a/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/core/status.py b/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/core/status.py new file mode 100644 index 000000000..b1796cf00 --- /dev/null +++ b/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/core/status.py @@ -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"], + } diff --git a/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/core/validator.py b/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/core/validator.py new file mode 100644 index 000000000..eb63a956b --- /dev/null +++ b/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/core/validator.py @@ -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, + } diff --git a/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/eth2_quickstart_cli.py b/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/eth2_quickstart_cli.py new file mode 100644 index 000000000..d297407da --- /dev/null +++ b/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/eth2_quickstart_cli.py @@ -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"]) diff --git a/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/skills/SKILL.md b/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/skills/SKILL.md new file mode 100644 index 000000000..f634131bf --- /dev/null +++ b/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/skills/SKILL.md @@ -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. diff --git a/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/tests/TEST.md b/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/tests/TEST.md new file mode 100644 index 000000000..9a21c4aa2 --- /dev/null +++ b/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/tests/TEST.md @@ -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 ============================== +``` diff --git a/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/tests/__init__.py b/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/tests/__init__.py new file mode 100644 index 000000000..e972cad37 --- /dev/null +++ b/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for cli-anything-eth2-quickstart.""" diff --git a/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/tests/test_core.py b/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/tests/test_core.py new file mode 100644 index 000000000..77c692d13 --- /dev/null +++ b/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/tests/test_core.py @@ -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"] diff --git a/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/tests/test_full_e2e.py b/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/tests/test_full_e2e.py new file mode 100644 index 000000000..384bc68b0 --- /dev/null +++ b/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/tests/test_full_e2e.py @@ -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 diff --git a/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/utils/__init__.py b/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/utils/__init__.py new file mode 100644 index 000000000..dce03e3b5 --- /dev/null +++ b/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/utils/__init__.py @@ -0,0 +1 @@ +"""Utility helpers for cli-anything-eth2-quickstart.""" diff --git a/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/utils/eth2qs_backend.py b/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/utils/eth2qs_backend.py new file mode 100644 index 000000000..86a2ce240 --- /dev/null +++ b/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/utils/eth2qs_backend.py @@ -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]) diff --git a/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/utils/repl_skin.py b/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/utils/repl_skin.py new file mode 100644 index 000000000..47260bebd --- /dev/null +++ b/eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/utils/repl_skin.py @@ -0,0 +1,498 @@ +"""cli-anything REPL Skin — Unified terminal interface for all CLI harnesses. + +Copy this file into your CLI package at: + cli_anything//utils/repl_skin.py + +Usage: + from cli_anything..utils.repl_skin import ReplSkin + + skin = ReplSkin("shotcut", version="1.0.0") + skin.print_banner() + 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 + +# ── 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" + +# ── 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)) + + +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): + """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-/history + """ + self.software = software.lower().replace("-", "_") + self.display_name = software.replace("_", " ").title() + self.version = version + self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT) + + # History file + if history_file is None: + from pathlib import Path + 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.""" + inner = 54 + + 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}" + + 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 = "" + + print(top) + print(_box_line(title)) + print(_box_line(ver)) + 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 +} diff --git a/eth2-quickstart/agent-harness/setup.py b/eth2-quickstart/agent-harness/setup.py new file mode 100644 index 000000000..168862f39 --- /dev/null +++ b/eth2-quickstart/agent-harness/setup.py @@ -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", +) diff --git a/registry.json b/registry.json index 13165f9e2..62991b258 100644 --- a/registry.json +++ b/registry.json @@ -117,6 +117,20 @@ "contributor": "zhangxilong-43", "contributor_url": "https://github.com/zhangxilong-43" }, + { + "name": "eth2-quickstart", + "display_name": "ETH2 QuickStart", + "version": "1.0.0", + "description": "Hardened Ethereum node deployment and operations via the eth2-quickstart automation scripts", + "requires": "local eth2-quickstart checkout with scripts/eth2qs.sh; Linux host required for real install workflows", + "homepage": "https://github.com/chimera-defi/eth2-quickstart", + "install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=eth2-quickstart/agent-harness", + "entry_point": "cli-anything-eth2-quickstart", + "skill_md": "eth2-quickstart/agent-harness/cli_anything/eth2_quickstart/skills/SKILL.md", + "category": "devops", + "contributor": "chimera-defi", + "contributor_url": "https://github.com/chimera-defi" + }, { "name": "gimp", "display_name": "GIMP",