chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
)
|
||||
Reference in New Issue
Block a user