diff --git a/honcho-cli/README.md b/honcho-cli/README.md index 7f6a9dab..9cb8e767 100644 --- a/honcho-cli/README.md +++ b/honcho-cli/README.md @@ -32,12 +32,14 @@ Either way, you'll get the `honcho` command on your PATH. ## Quick Start ```bash -honcho init # interactive wizard: API key, workspace, default peer +honcho init # confirm/set apiKey + environment in ~/.honcho/config.json honcho doctor # verify your config + connectivity honcho # show banner + command list ``` -`honcho init` walks you through picking a workspace and default peer from your available choices, tests the connection, and writes config to `~/.honcho/config.toml`. +`honcho init` reads `apiKey` and `environment` from the top-level of `~/.honcho/config.json` (the same file other Honcho tools — plugins, host integrations — share). If both are present, it confirms them with you; if either is missing (or you decline), it prompts for the missing value(s) and writes them back. Host-specific entries under `hosts` are left untouched. + +Per-command scoping (workspace / peer / session) is handled via `-w` / `-p` / `-s` flags or `HONCHO_*` env vars — not persisted as CLI defaults. ## Commands @@ -45,7 +47,7 @@ honcho # show banner + command list | Command | Description | |---------|-------------| -| `honcho init` | Interactive setup wizard (or `--yes` for non-interactive) | +| `honcho init` | Confirm/set `apiKey` + `environment` in `~/.honcho/config.json` | | `honcho doctor` | Health check: config, connectivity, workspace, peer, queue | ### Workspaces @@ -106,7 +108,6 @@ honcho # show banner + command list | Command | Description | |---------|-------------| | `honcho config show` | Show current config (API key redacted) | -| `honcho config set ` | Set a single config value | ## Agent Usage @@ -133,39 +134,31 @@ Errors are structured: Non-interactive onboarding: ```bash -# Full flags -honcho init --yes --api-key $HONCHO_API_KEY --workspace my-ws --peer my-peer - -# Or rely on existing config / env vars to fill in missing values -HONCHO_API_KEY=xxx honcho init --yes --workspace my-ws - -# If config already exists, this just validates and exits 0 -honcho init --yes +# Pre-seed via flags / env vars; `honcho init` still prompts for anything missing +HONCHO_API_KEY=xxx honcho init --base-url local ``` ## Context Threading -Set defaults once, then skip IDs on subsequent commands: - -```bash -honcho config set peer_id peer_abc123 -honcho peer inspect # uses default -honcho peer card # uses default -honcho peer inspect other_id # positional arg overrides -``` - -Or override per invocation: +Workspace / peer / session come from flags or env vars — not persisted defaults: ```bash +# Per-command flags honcho --workspace prod --peer ajspig peer card + +# Or export once per shell +export HONCHO_WORKSPACE_ID=prod +export HONCHO_PEER_ID=ajspig +honcho peer card +honcho peer inspect other_id # positional arg still takes precedence ``` ## Environment Variables | Variable | Description | |----------|-------------| -| `HONCHO_BASE_URL` | API base URL (default `https://api.honcho.dev`) | -| `HONCHO_API_KEY` | Admin JWT | +| `HONCHO_BASE_URL` | API base URL pre-fill for `honcho init` only — ignored at runtime | +| `HONCHO_API_KEY` | Admin JWT pre-fill for `honcho init` only — ignored at runtime | | `HONCHO_WORKSPACE_ID` | Default workspace | | `HONCHO_PEER_ID` | Default peer | | `HONCHO_SESSION_ID` | Default session | @@ -184,12 +177,33 @@ honcho --workspace prod --peer ajspig peer card ## Configuration -Config lives at `~/.honcho/config.toml` with this precedence (highest first): +The CLI shares `~/.honcho/config.json` with sibling Honcho tools. It owns two +top-level keys: `apiKey` and either `environment` (`"local"` / `"production"`) or +`baseUrl` (for custom deployments). Everything else at the top level — +`hosts`, `sessions`, `saveMessages`, `sessionStrategy`, etc. — is left +untouched. -1. CLI flags (`--workspace`, `--peer`, ...) -2. Environment variables (`HONCHO_*`) -3. Config file -4. Defaults +Example: + +```json +{ + "apiKey": "hch-v3-...", + "environment": "production", + "hosts": { "claude_code": { "...": "..." } } +} +``` + +Precedence (highest first): + +- **`apiKey`**: read only from `~/.honcho/config.json`. No env-var fallback at + runtime — a missing config file is a hard error. (`honcho init` still + accepts `--api-key` / `HONCHO_API_KEY` as a one-time pre-fill for the + write-to-file prompt.) This keeps a single, inspectable source of truth + for authentication. +- **`base_url`**: CLI flag `--base-url` → `HONCHO_BASE_URL` → config file + → default. +- **`workspace_id` / `peer_id` / `session_id`**: flag (`-w` / `-p` / `-s`) + → env var (`HONCHO_WORKSPACE_ID` etc.). Not persisted to the config file. ## Development diff --git a/honcho-cli/src/honcho_cli/commands/config_cmd.py b/honcho-cli/src/honcho_cli/commands/config_cmd.py index 88c67bfa..4e2a10c4 100644 --- a/honcho-cli/src/honcho_cli/commands/config_cmd.py +++ b/honcho-cli/src/honcho_cli/commands/config_cmd.py @@ -1,54 +1,23 @@ -"""Config management commands: init, set, show.""" +"""Config inspection command: ``honcho config show``. + +Writing to ``~/.honcho/config.json`` is done only via ``honcho init``, which +manages the two CLI-owned keys (``apiKey`` + ``environment`` / ``baseUrl``). +Workspace / peer / session scoping is per-command via flags / env vars, not +persisted defaults. +""" from __future__ import annotations -from dataclasses import fields -from typing import Optional - import typer from honcho_cli.config import CLIConfig -from honcho_cli.output import print_error, print_result, status +from honcho_cli.output import print_result -app = typer.Typer(help="Manage CLI configuration.") - - -@app.command() -def init( - base_url: str = typer.Option("https://api.honcho.dev", prompt="Base URL"), - api_key: str = typer.Option("", prompt="API key (admin JWT)"), - workspace_id: str = typer.Option("", prompt="Default workspace ID"), -) -> None: - """Interactive setup: set base_url, api_key, default workspace.""" - config = CLIConfig( - base_url=base_url, - api_key=api_key, - workspace_id=workspace_id, - ) - config.save() - status(f"Config saved to {config.save.__func__}") - print_result(config.redacted()) - - -@app.command("set") -def set_value( - key: str = typer.Argument(help="Config key (base_url, api_key, workspace_id, peer_id, session_id)"), - value: str = typer.Argument(help="Config value"), -) -> None: - """Set a config value.""" - valid_keys = {f.name for f in fields(CLIConfig)} - if key not in valid_keys: - print_error("INVALID_KEY", f"Unknown config key: {key}", {"valid_keys": sorted(valid_keys)}) - raise typer.Exit(1) - - config = CLIConfig.load() - setattr(config, key, value) - config.save() - status(f"Set {key}") +app = typer.Typer(help="Inspect CLI configuration.") @app.command() def show() -> None: - """Show current config (redacted keys).""" + """Show current config (api key redacted).""" config = CLIConfig.load() print_result(config.redacted()) diff --git a/honcho-cli/src/honcho_cli/commands/peer.py b/honcho-cli/src/honcho_cli/commands/peer.py index 396f26f7..3bb487ef 100644 --- a/honcho-cli/src/honcho_cli/commands/peer.py +++ b/honcho-cli/src/honcho_cli/commands/peer.py @@ -25,7 +25,7 @@ def _get_peer_id(peer_id: str | None) -> str: if not pid: from honcho_cli.output import print_error - print_error("NO_PEER", "No peer ID provided. Use --peer, set HONCHO_PEER_ID, or run `honcho config set peer_id `.") + print_error("NO_PEER", "No peer ID provided. Pass --peer/-p or set HONCHO_PEER_ID.") raise typer.Exit(1) return validate_resource_id(pid, "peer") diff --git a/honcho-cli/src/honcho_cli/commands/session.py b/honcho-cli/src/honcho_cli/commands/session.py index d8569a52..efecc467 100644 --- a/honcho-cli/src/honcho_cli/commands/session.py +++ b/honcho-cli/src/honcho_cli/commands/session.py @@ -25,7 +25,7 @@ def _get_session_id(session_id: str | None) -> str: if not sid: from honcho_cli.output import print_error - print_error("NO_SESSION", "No session ID provided. Use --session, set HONCHO_SESSION_ID, or run `honcho config set session_id `.") + print_error("NO_SESSION", "No session ID provided. Pass --session/-s or set HONCHO_SESSION_ID.") raise typer.Exit(1) return validate_resource_id(sid, "session") diff --git a/honcho-cli/src/honcho_cli/commands/setup.py b/honcho-cli/src/honcho_cli/commands/setup.py index 960c42c8..cc273e26 100644 --- a/honcho-cli/src/honcho_cli/commands/setup.py +++ b/honcho-cli/src/honcho_cli/commands/setup.py @@ -1,48 +1,72 @@ """Top-level onboarding and health-check commands. -`honcho init` — interactive onboarding (human) or flags (agent/CI) +`honcho init` — confirm or set apiKey + Honcho URL in ~/.honcho/config.json `honcho doctor` — verify connectivity, config validity, queue health """ from __future__ import annotations -from typing import Optional +import json +import os import typer from rich.console import Console from rich.panel import Panel from honcho_cli import __version__ -from honcho_cli.config import CLIConfig, CONFIG_FILE +from honcho_cli.config import ( + CONFIG_FILE, + DEFAULT_BASE_URL, + CLIConfig, +) from honcho_cli.main import BANNER -from honcho_cli.output import print_error, print_result, status +from honcho_cli.output import print_error, print_result _console = Console(stderr=True) -# --- Style palette --- -# Brand blue is the primary accent; success/error use semantic colors. BRAND = "#B6DAFD" -SUCCESS = BRAND -ERROR = "red" -WARN = "yellow" ICON_OK = "[green]✓[/green]" ICON_FAIL = "[red]✗[/red]" -ICON_WARN = "[yellow]![/yellow]" ICON_RUN = f"[{BRAND}]→[/{BRAND}]" +# --------------------------------------------------------------------------- # +# shared helpers + +def _redact(api_key: str) -> str: + if not api_key: + return "" + if len(api_key) <= 16: + return "***" + return api_key[:8] + "..." + api_key[-4:] + + +def _read_file_values() -> tuple[str, str]: + """Return (apiKey, environmentUrl) persisted on disk (or empty strings).""" + if not CONFIG_FILE.exists(): + return "", "" + try: + with open(CONFIG_FILE, encoding="utf-8") as f: + data = json.load(f) + except (json.JSONDecodeError, OSError): + return "", "" + if not isinstance(data, dict): + return "", "" + key = data.get("apiKey") if isinstance(data.get("apiKey"), str) else "" + url = data.get("environmentUrl") if isinstance(data.get("environmentUrl"), str) else "" + return key, url + + def _test_connection(base_url: str, api_key: str) -> tuple[bool, str]: - """Test connectivity to Honcho API by listing workspaces via SDK.""" + """Probe the Honcho API by listing workspaces. Returns (ok, detail).""" try: from honcho import Honcho - client = Honcho(base_url=base_url, api_key=api_key) - # List workspaces as a connectivity + auth check - list(client.workspaces()) + list(Honcho(base_url=base_url, api_key=api_key).workspaces()) return True, "OK" except Exception as e: msg = str(e) - if "ConnectError" in msg or "Connection refused" in msg: + if "Connection refused" in msg or "ConnectError" in msg: return False, "Connection refused — is the server running?" if "timed out" in msg.lower() or "Timeout" in msg: return False, "Request timed out" @@ -51,293 +75,122 @@ def _test_connection(base_url: str, api_key: str) -> tuple[bool, str]: return False, msg -def _list_workspaces(base_url: str, api_key: str) -> list[str]: - """Fetch workspace IDs from the API.""" - from honcho import Honcho - - client = Honcho(base_url=base_url, api_key=api_key) - return list(client.workspaces()) +def _resolve_source(param: str | None, env_val: str, env_name: str, flag_name: str, file_val: str) -> tuple[str, str]: + """Return (value, source_label) for one field, flag/env > file.""" + if param: + return param, f"{env_name} env var" if env_val and env_val == param else f"{flag_name} flag" + return (file_val, str(CONFIG_FILE)) if file_val else ("", "") -def _list_peers(base_url: str, api_key: str, workspace_id: str) -> list: - """Fetch peers from a workspace.""" - from honcho import Honcho - - client = Honcho(base_url=base_url, api_key=api_key, workspace_id=workspace_id) - page = client.peers() - return list(page) - - -def _workspace_summary(base_url: str, api_key: str, workspace_id: str) -> dict: - """Fetch lightweight activity summary for a workspace. - - Returns dict with peer_count and conclusion_count (size of derived memory). - """ - from honcho import Honcho - from honcho.http import routes - - summary = {"peer_count": 0, "conclusion_count": 0} - try: - client = Honcho(base_url=base_url, api_key=api_key, workspace_id=workspace_id) - - # Peer count from first page (cheap; full count would require pagination) - try: - peers = list(client.peers()) - summary["peer_count"] = len(peers) - except Exception: - pass - - # Conclusion count: fetch page 1 with size=1 and read the total - try: - data = client._http.post( - routes.conclusions_list(workspace_id), - body={"page": 1, "size": 1}, - ) - summary["conclusion_count"] = data.get("total", 0) if isinstance(data, dict) else 0 - except Exception: - pass - except Exception: - pass - - return summary - +# --------------------------------------------------------------------------- # +# honcho init def init( - api_key: Optional[str] = typer.Option(None, "--api-key", envvar="HONCHO_API_KEY", help="API key (admin JWT)"), - base_url: str = typer.Option("https://api.honcho.dev", "--base-url", envvar="HONCHO_BASE_URL", help="Honcho API base URL"), - workspace: Optional[str] = typer.Option(None, "--workspace", help="Workspace ID to use"), - peer: Optional[str] = typer.Option(None, "--peer", help="Default peer ID"), - yes: bool = typer.Option(False, "--yes", "-y", help="Non-interactive mode (requires --api-key and --workspace)"), + api_key: str | None = typer.Option(None, "--api-key", envvar="HONCHO_API_KEY", help="API key (admin JWT)"), + base_url: str | None = typer.Option(None, "--base-url", envvar="HONCHO_BASE_URL", help="Honcho API URL (e.g. https://api.honcho.dev, http://localhost:8000)"), json_output: bool = typer.Option(False, "--json", help="Force JSON output"), ) -> None: - """Set up the CLI: credentials and defaults for who/where you are. + """Confirm or set ``apiKey`` + Honcho URL in ``~/.honcho/config.json``. - Stores your API key, default workspace, and default peer in - ~/.honcho/config.toml so subsequent commands don't need flags. - After init, `honcho peer card` returns your default peer's card, - `honcho peer list` lists peers in your default workspace, etc. + For each value we either (a) show what we have and ask for a Y/N + confirmation, or (b) prompt for it if missing. Foreign top-level keys + (``hosts``, ``sessions``, …) are preserved. - Interactive wizard (human): - honcho init - - Agent/CI-safe (falls back to existing config/env for missing values): - honcho init --api-key $KEY --workspace my-app --yes + Workspace / peer / session scoping is per-command via ``-w`` / ``-p`` / + ``-s`` or ``HONCHO_*`` env vars — never persisted. """ from honcho_cli.output import set_json_mode, use_json if json_output: set_json_mode(True) - # --- Non-interactive path --- - if yes: - # Fall back to existing config for any values not explicitly provided - existing = CLIConfig.load() - resolved_url = base_url - resolved_key = api_key or existing.api_key - resolved_ws = workspace or existing.workspace_id - resolved_peer = peer or existing.peer_id - - if not resolved_key: - print_error("MISSING_FLAG", "--api-key is required (not found in config or env)", {}) - raise typer.Exit(1) - if not resolved_ws: - print_error("MISSING_FLAG", "--workspace is required (not found in config or env)", {}) - raise typer.Exit(1) - - # Test connection - ok, detail = _test_connection(resolved_url, resolved_key) - if not ok: - print_error("CONNECTION_FAILED", f"Cannot reach {resolved_url}: {detail}", {"base_url": resolved_url}) - raise typer.Exit(1) - - config = CLIConfig( - base_url=resolved_url, - api_key=resolved_key, - workspace_id=resolved_ws, - peer_id=resolved_peer, - ) - config.save() - print_result(config.redacted()) - return - - # --- Interactive wizard --- - banner_content = f"[bold #B6DAFD]{BANNER}[/bold #B6DAFD]\n\n Memory that reasons" - _console.print() - _console.print(Panel(banner_content, expand=False, subtitle=f"Honcho CLI · v{__version__}")) - _console.print() - - _console.print("[bold]Welcome! Let's set up your Honcho CLI.[/bold]") - _console.print( - "[dim]This stores your credentials and default workspace/peer in ~/.honcho/config.toml\n" - "so you don't have to pass --workspace and --peer on every command.[/dim]\n" + file_key, file_url = _read_file_values() + key_val, key_src = _resolve_source( + api_key, os.environ.get("HONCHO_API_KEY", ""), "HONCHO_API_KEY", "--api-key", file_key, ) + url_val, url_src = _resolve_source( + base_url, os.environ.get("HONCHO_BASE_URL", ""), "HONCHO_BASE_URL", "--base-url", file_url, + ) + url_val = url_val.strip() - # Step 1: Base URL - base_url = typer.prompt(" Base URL", default=base_url) + if not use_json(): + _console.print() + _console.print(Panel( + f"[bold {BRAND}]{BANNER}[/bold {BRAND}]\n\n Memory that reasons", + expand=False, subtitle=f"Honcho CLI · v{__version__}", + )) + _console.print() + if not key_val and not url_val: + _console.print(f"[bold]No existing config at {CONFIG_FILE} — let's create one.[/bold]\n") - # Step 2: API key - if not api_key: - api_key = typer.prompt(" API key") - if not api_key: + final_key = _confirm_or_prompt_api_key(key_val, key_src) + final_url = _confirm_or_prompt_url(url_val, url_src) + + # Persist if anything changed or if the value came from env/flag. + if final_key != file_key or final_url != file_url: + CLIConfig(base_url=final_url, api_key=final_key).save() + if not use_json(): + _console.print(f" {ICON_OK} [dim]Saved to {CONFIG_FILE}[/dim]") + + _check_connection(final_url, final_key) + + if use_json(): + print_result({"apiKey": _redact(final_key), "baseUrl": final_url}) + + +def _confirm_or_prompt_api_key(value: str, source: str) -> str: + from honcho_cli.output import use_json + + if value: + if not use_json(): + _console.print(f" API key: [dim]{_redact(value)}[/dim] [{BRAND}](from {source})[/{BRAND}]") + if use_json() or typer.confirm(" Use this API key?", default=True): + return value + # Never set ``default=`` to the raw key — typer would echo it in brackets. + new = typer.prompt(" API key") + if not new: print_error("MISSING_VALUE", "API key is required", {}) raise typer.Exit(1) + return new - # Step 3: Test connection - _console.print(f"\n {ICON_RUN} [dim]Testing connection...[/dim]", end=" ") + +def _confirm_or_prompt_url(value: str, source: str) -> str: + from honcho_cli.output import use_json + + if value: + if not use_json(): + _console.print(f" Honcho URL: [dim]{value}[/dim] [{BRAND}](from {source})[/{BRAND}]") + if use_json() or typer.confirm(" Use this URL?", default=True): + return value + if not use_json(): + _console.print(" [dim](e.g. https://api.honcho.dev for managed, http://localhost:8000 for local)[/dim]") + return typer.prompt(" Honcho URL", default=DEFAULT_BASE_URL).strip() + + +def _check_connection(base_url: str, api_key: str) -> None: + from honcho_cli.output import use_json + + if not use_json(): + _console.print(f"\n {ICON_RUN} [dim]Testing connection to {base_url}...[/dim]", end=" ") ok, detail = _test_connection(base_url, api_key) - if ok: + if not ok: + if use_json(): + print_error("CONNECTION_FAILED", detail, {"base_url": base_url}) + else: + _console.print(f"{ICON_FAIL} [red]Failed[/red]: {detail}") + raise typer.Exit(1) + if not use_json(): _console.print(f"{ICON_OK} [green]Connected[/green]") - else: - _console.print(f"{ICON_FAIL} [red]Failed[/red]: {detail}") - if not typer.confirm(" Continue anyway?", default=False): - raise typer.Exit(1) - # Step 4: Workspace selection - workspace_id = workspace - if not workspace_id: - try: - workspaces = _list_workspaces(base_url, api_key) - except Exception: - workspaces = [] - - if workspaces: - _console.print(f"\n [bold]Available workspaces[/bold] ({len(workspaces)}):") - _console.print( - " [dim]Pick where you spend the most time — this is your default \"home\" workspace.\n" - " You can switch any time by passing --workspace or via env var.[/dim]" - ) - - # Fetch summary stats in parallel for ranking + display - from concurrent.futures import ThreadPoolExecutor - - ws_list = [str(w) for w in workspaces[:20]] - summaries: dict[str, dict] = {} - with _console.status(f"[{BRAND}]Fetching workspace activity...[/{BRAND}]", spinner="dots", spinner_style=BRAND): - with ThreadPoolExecutor(max_workers=8) as pool: - results = pool.map( - lambda w: (w, _workspace_summary(base_url, api_key, w)), - ws_list, - ) - for w, s in results: - summaries[w] = s - - # Recommend the workspace with the largest derived memory (conclusion_count) - recommended = max(ws_list, key=lambda w: summaries[w].get("conclusion_count", 0)) - - from rich.table import Table - - table = Table(show_header=True, header_style=f"bold {BRAND}", box=None, padding=(0, 2)) - table.add_column("#", style="dim", width=3) - table.add_column("Workspace") - table.add_column("Peers", justify="right") - table.add_column("Conclusions", justify="right") - table.add_column("") - - for i, w in enumerate(ws_list, 1): - s = summaries.get(w, {}) - marker = f"[{BRAND}]★ recommended[/{BRAND}]" if w == recommended else "" - table.add_row( - str(i), - w, - str(s.get("peer_count", 0)), - str(s.get("conclusion_count", 0)), - marker, - ) - _console.print(table) - _console.print() - - rec_idx = ws_list.index(recommended) + 1 - choice = typer.prompt( - " Enter workspace ID or number from list", - default=str(rec_idx), - ) - - # Allow picking by number - try: - idx = int(choice) - 1 - if 0 <= idx < len(ws_list): - workspace_id = ws_list[idx] - else: - workspace_id = choice - except ValueError: - workspace_id = choice - else: - workspace_id = typer.prompt(" Workspace ID") - - # Step 5: Peer selection - peer_id = peer - if not peer_id: - try: - peers = _list_peers(base_url, api_key, workspace_id) - except Exception: - peers = [] - - if peers: - _console.print(f"\n [bold]Peers in workspace[/bold] ({len(peers)}):") - _console.print( - " [dim]The peer you're querying as. Used as the implicit observer for\n" - " conclusion searches and the default target for `honcho peer card`.[/dim]" - ) - for i, p in enumerate(peers[:20], 1): - _console.print(f" {i}. {p.id}") - _console.print() - - choice = typer.prompt( - " Default peer ID or number (leave blank to skip)", - default="", - ) - - if choice: - try: - idx = int(choice) - 1 - if 0 <= idx < len(peers): - peer_id = peers[idx].id - else: - peer_id = choice - except ValueError: - peer_id = choice - else: - peer_id = typer.prompt(" Default peer ID (leave blank to skip)", default="") - - # Step 6: Confirm and write - config = CLIConfig( - base_url=base_url, - api_key=api_key, - workspace_id=workspace_id, - peer_id=peer_id or "", - ) - - _console.print("\n [bold]Configuration:[/bold]") - for k, v in config.redacted().items(): - if v: - _console.print(f" {k}: {v}") - - _console.print(f"\n Config file: {CONFIG_FILE}") - if not typer.confirm("\n Save this configuration?", default=True): - _console.print(" [dim]Aborted.[/dim]") - raise typer.Exit(0) - - config.save() - _console.print(f"\n {ICON_OK} [bold]Config saved[/bold] [dim]{CONFIG_FILE}[/dim]") - _console.print("\n [bold]Get started:[/bold]") - _console.print(f" [{BRAND}]honcho doctor[/{BRAND}] [dim]Verify your setup[/dim]") - _console.print(f" [{BRAND}]honcho workspace list[/{BRAND}] [dim]List workspaces[/dim]") - _console.print(f" [{BRAND}]honcho conclusion list[/{BRAND}] [dim]Browse stored conclusions[/dim]") - _console.print(f" [{BRAND}]honcho peer list[/{BRAND}] [dim]List peers in workspace[/dim]") - _console.print() +# --------------------------------------------------------------------------- # +# honcho doctor def doctor( json_output: bool = typer.Option(False, "--json", help="Force JSON output"), ) -> None: - """Verify connectivity, config validity, and queue health. - - Checks: - 1. Config file exists and parses - 2. API connectivity - 3. Workspace is reachable - 4. Default peer exists - 5. Queue health + """Verify config, connectivity, and — when scoped via ``-w`` / ``-p`` — + workspace, peer, and queue health. """ from honcho_cli.output import set_json_mode, use_json @@ -346,89 +199,71 @@ def doctor( checks: list[dict] = [] - def _check(name: str, passed: bool, detail: str = "") -> None: - checks.append({"check": name, "ok": passed, "detail": detail}) + def _add(name: str, ok: bool, detail: str = "") -> None: + checks.append({"check": name, "ok": ok, "detail": detail}) if not use_json(): - icon = ICON_OK if passed else ICON_FAIL - msg = f" {icon} {name:<22}" + icon = ICON_OK if ok else ICON_FAIL + line = f" {icon} {name:<22}" if detail: - msg += f" [dim]{detail}[/dim]" - _console.print(msg) + line += f" [dim]{detail}[/dim]" + _console.print(line) if not use_json(): _console.print(f"\n[bold {BRAND}]Honcho Doctor[/bold {BRAND}]\n") - # 1. Config file config = CLIConfig.load() - config_exists = CONFIG_FILE.exists() - _check("Config file", config_exists, str(CONFIG_FILE) if config_exists else f"{CONFIG_FILE} not found") + _add("Config file", CONFIG_FILE.exists(), + str(CONFIG_FILE) if CONFIG_FILE.exists() else f"{CONFIG_FILE} not found") + _add("API key configured", bool(config.api_key), + "set" if config.api_key else "missing — run `honcho init`") - # 2. API key present - has_key = bool(config.api_key) - _check("API key configured", has_key, "set" if has_key else "missing — run `honcho init`") - - # 3. Connectivity if config.base_url and config.api_key: - ok, detail = _test_connection(config.base_url, config.api_key) - _check("API connectivity", ok, detail) + _add("API connectivity", *_test_connection(config.base_url, config.api_key)) else: - _check("API connectivity", False, "skipped — no base_url or api_key") + _add("API connectivity", False, "skipped — no base_url or api_key") - # 4. Workspace - ws_ok = False + # Workspace / peer / queue run only when scoped via -w / -p. + ws_ok, client = False, None if config.workspace_id and config.api_key: try: from honcho import Honcho - client = Honcho( - base_url=config.base_url, - api_key=config.api_key, - workspace_id=config.workspace_id, - ) - # Try to access the workspace config to verify it exists + client = Honcho(base_url=config.base_url, api_key=config.api_key, workspace_id=config.workspace_id) client.get_configuration() ws_ok = True - _check("Workspace reachable", True, config.workspace_id) + _add("Workspace reachable", True, config.workspace_id) except Exception as e: - _check("Workspace reachable", False, f"{config.workspace_id}: {e}") - else: - _check("Workspace reachable", False, "no workspace_id configured") + _add("Workspace reachable", False, f"{config.workspace_id}: {e}") + if ws_ok: + try: + q = client.queue_status() + _add("Queue health", True, f"{q.completed_work_units}/{q.total_work_units} completed, {q.pending_work_units} pending") + except Exception: + _add("Queue health", True, "endpoint not available (non-critical)") - # 5. Peer - if config.peer_id and ws_ok: - try: - peer = client.peer(config.peer_id) - # Try to access the peer to verify it exists - peer.get_card() - _check("Default peer exists", True, config.peer_id) - except Exception as e: - _check("Default peer exists", False, f"{config.peer_id}: {e}") - elif config.peer_id: - _check("Default peer exists", False, "skipped — workspace not reachable") - else: - _check("Default peer exists", False, "no peer_id configured (optional)") + if config.peer_id: + if ws_ok and client is not None: + try: + client.peer(config.peer_id).get_card() + _add("Peer exists", True, config.peer_id) + except Exception as e: + _add("Peer exists", False, f"{config.peer_id}: {e}") + else: + _add("Peer exists", False, "skipped — workspace not reachable") - # 6. Queue health - if ws_ok: - try: - q = client.queue_status() - summary = f"{q.completed_work_units}/{q.total_work_units} completed, {q.pending_work_units} pending" - _check("Queue health", True, summary) - except Exception: - _check("Queue health", True, "endpoint not available (non-critical)") - else: - _check("Queue health", False, "skipped — workspace not reachable") - - if not use_json(): - passed = sum(1 for c in checks if c["ok"]) - total = len(checks) - color = SUCCESS if passed == total else (WARN if passed > total // 2 else ERROR) - _console.print(f"\n [{color}]{passed}/{total}[/{color}] checks passed\n") + passed = sum(1 for c in checks if c["ok"]) + total = len(checks) if use_json(): - print_result({"checks": checks, "passed": sum(1 for c in checks if c["ok"]), "total": len(checks)}) + print_result({"checks": checks, "passed": passed, "total": total}) + else: + color = BRAND if passed == total else ("yellow" if passed > total // 2 else "red") + hint = "" if config.workspace_id else " [dim](pass -w / -p to include workspace, peer, queue checks)[/dim]" + _console.print(f"\n [{color}]{passed}/{total}[/{color}] checks passed{hint}\n") - # Exit non-zero if critical checks fail - critical_failed = any(not c["ok"] for c in checks if c["check"] in ("Config file", "API connectivity", "Workspace reachable")) - if critical_failed: + # apiKey must live in config.json → missing file is a hard failure. + critical = {"Config file", "API key configured", "API connectivity"} + if config.workspace_id: + critical.add("Workspace reachable") + if any(not c["ok"] for c in checks if c["check"] in critical): raise typer.Exit(1) diff --git a/honcho-cli/src/honcho_cli/commands/workspace.py b/honcho-cli/src/honcho_cli/commands/workspace.py index ef759203..53cf95cc 100644 --- a/honcho-cli/src/honcho_cli/commands/workspace.py +++ b/honcho-cli/src/honcho_cli/commands/workspace.py @@ -21,7 +21,7 @@ def _get_workspace_id(workspace_id: str | None) -> str: config = get_resolved_config() wid = workspace_id or config.workspace_id if not wid: - print_error("NO_WORKSPACE", "No workspace ID provided. Use --workspace, set HONCHO_WORKSPACE_ID, or run `honcho config set workspace_id `.") + print_error("NO_WORKSPACE", "No workspace ID provided. Pass --workspace/-w or set HONCHO_WORKSPACE_ID.") raise typer.Exit(1) return validate_resource_id(wid, "workspace") diff --git a/honcho-cli/src/honcho_cli/config.py b/honcho-cli/src/honcho_cli/config.py index 235b737b..85358d33 100644 --- a/honcho-cli/src/honcho_cli/config.py +++ b/honcho-cli/src/honcho_cli/config.py @@ -1,31 +1,42 @@ """Configuration management for Honcho CLI. -Config stored at ~/.honcho/config.toml with env var overrides. +Config stored at ``~/.honcho/config.json`` with env var overrides. + +The CLI owns exactly two top-level keys in that file: + + apiKey -- Honcho admin JWT + environmentUrl -- Honcho API URL (full URL, e.g. https://api.honcho.dev) + +All other top-level keys (``hosts``, ``sessions``, ``saveMessages``, +``sessionStrategy``, …) are written by sibling Honcho tools and are +preserved untouched on save. + +Workspace / peer / session scoping is intentionally *not* persisted here — +pass ``-w`` / ``-p`` / ``-s`` flags or set ``HONCHO_WORKSPACE_ID`` / +``HONCHO_PEER_ID`` / ``HONCHO_SESSION_ID`` per command instead. """ from __future__ import annotations +import json import os -import sys -from dataclasses import dataclass, field, fields +from dataclasses import dataclass, fields from pathlib import Path -if sys.version_info >= (3, 11): - import tomllib -else: - try: - import tomllib - except ImportError: - import tomli as tomllib # type: ignore[no-redef] - - CONFIG_DIR = Path.home() / ".honcho" -CONFIG_FILE = CONFIG_DIR / "config.toml" +CONFIG_FILE = CONFIG_DIR / "config.json" -# Env var mapping: field_name -> env var +DEFAULT_BASE_URL = "https://api.honcho.dev" + +# Env var mapping for runtime overrides. +# +# NOTE: ``api_key`` and ``base_url`` are intentionally NOT here. Both must +# live in ``~/.honcho/config.json`` so there's a single, inspectable source +# of truth for where you're connecting and with what credentials. +# (``honcho init`` still accepts ``--api-key`` / ``HONCHO_API_KEY`` and +# ``--base-url`` / ``HONCHO_BASE_URL`` as one-time pre-fills for the +# write-to-file prompts.) ENV_MAP: dict[str, str] = { - "base_url": "HONCHO_BASE_URL", - "api_key": "HONCHO_API_KEY", "workspace_id": "HONCHO_WORKSPACE_ID", "peer_id": "HONCHO_PEER_ID", "session_id": "HONCHO_SESSION_ID", @@ -34,9 +45,14 @@ ENV_MAP: dict[str, str] = { @dataclass class CLIConfig: - """CLI configuration with layered resolution: flag > env > file > default.""" + """CLI configuration with layered resolution: flag > env > file > default. - base_url: str = "https://api.honcho.dev" + ``workspace_id`` / ``peer_id`` / ``session_id`` exist on this dataclass so + flag/env overrides flow through ``get_client_kwargs()``, but they are + never read from or written to the config file — they're per-command. + """ + + base_url: str = DEFAULT_BASE_URL api_key: str = "" workspace_id: str = "" peer_id: str = "" @@ -47,15 +63,21 @@ class CLIConfig: """Load config from file, then overlay env vars.""" config = cls() - # Layer 1: config file if CONFIG_FILE.exists(): - with open(CONFIG_FILE, "rb") as f: - data = tomllib.load(f) - for fld in fields(cls): - if fld.name in data: - setattr(config, fld.name, data[fld.name]) + try: + with open(CONFIG_FILE, encoding="utf-8") as f: + data = json.load(f) + except (json.JSONDecodeError, OSError): + data = {} + + if isinstance(data, dict): + url = data.get("environmentUrl") + if isinstance(url, str) and url: + config.base_url = url + key = data.get("apiKey") + if isinstance(key, str): + config.api_key = key - # Layer 2: env vars for fld_name, env_var in ENV_MAP.items(): val = os.environ.get(env_var) if val: @@ -64,13 +86,30 @@ class CLIConfig: return config def save(self) -> None: - """Write current config to ~/.honcho/config.toml.""" + """Write ``apiKey`` + ``environmentUrl`` to config.json. + + Preserves unrelated top-level keys (``hosts``, ``sessions``, + ``saveMessages``, ``sessionStrategy``, …) that other tools write. + """ CONFIG_DIR.mkdir(parents=True, exist_ok=True) - lines = [] - for fld in fields(self): - val = getattr(self, fld.name) - lines.append(f'{fld.name} = "{val}"') - CONFIG_FILE.write_text("\n".join(lines) + "\n") + + data: dict = {} + if CONFIG_FILE.exists(): + try: + with open(CONFIG_FILE, encoding="utf-8") as f: + loaded = json.load(f) + if isinstance(loaded, dict): + data = loaded + except (json.JSONDecodeError, OSError): + data = {} + + data["environmentUrl"] = self.base_url + if self.api_key: + data["apiKey"] = self.api_key + else: + data.pop("apiKey", None) + + CONFIG_FILE.write_text(json.dumps(data, indent=2) + "\n") def redacted(self) -> dict[str, str]: """Return config dict with api_key redacted.""" diff --git a/honcho-cli/src/honcho_cli/skills/CONTEXT.md b/honcho-cli/src/honcho_cli/skills/CONTEXT.md index ff0973e1..545fefbf 100644 --- a/honcho-cli/src/honcho_cli/skills/CONTEXT.md +++ b/honcho-cli/src/honcho_cli/skills/CONTEXT.md @@ -26,11 +26,19 @@ description: A terminal for Honcho — memory that reasons. ## Config -Stored at `~/.honcho/config.toml`. Set defaults to avoid repeating IDs: +Shared with other Honcho tools at `~/.honcho/config.json`. The CLI owns only +`apiKey` and `environment` (or `baseUrl`) at the top level. Host-specific +entries under `hosts` are untouched. + +Run `honcho init` to confirm or set those two values. Workspace / peer / +session are per-command — pass them via flags or env vars: ```bash -honcho config set workspace_id my-workspace -honcho config set peer_id my-peer +honcho peer card -w my-workspace -p my-peer +# or +export HONCHO_WORKSPACE_ID=my-workspace +export HONCHO_PEER_ID=my-peer +honcho peer card ``` ## Command Groups diff --git a/honcho-cli/tests/test_config.py b/honcho-cli/tests/test_config.py index 0d61636c..fb8178e0 100644 --- a/honcho-cli/tests/test_config.py +++ b/honcho-cli/tests/test_config.py @@ -1,57 +1,103 @@ """Tests for config management.""" +import json import os -from pathlib import Path -from unittest.mock import patch import pytest - from honcho_cli.config import CLIConfig -class TestCLIConfig: - def test_defaults(self): - config = CLIConfig() - assert config.base_url == "https://api.honcho.dev" - assert config.api_key == "" - assert config.workspace_id == "" +@pytest.fixture +def cfg_path(tmp_path, monkeypatch): + """Redirect CONFIG_FILE to tmp_path and clear HONCHO_* env vars.""" + f = tmp_path / "config.json" + monkeypatch.setattr("honcho_cli.config.CONFIG_FILE", f) + monkeypatch.setattr("honcho_cli.config.CONFIG_DIR", tmp_path) + for key in [k for k in os.environ if k.startswith("HONCHO_")]: + monkeypatch.delenv(key) + return f - def test_env_override(self): - with patch.dict(os.environ, {"HONCHO_API_KEY": "test-key", "HONCHO_BASE_URL": "http://localhost:8000"}): - config = CLIConfig.load() - assert config.api_key == "test-key" - assert config.base_url == "http://localhost:8000" - def test_save_and_load(self, tmp_path): - config_file = tmp_path / "config.toml" - # Clear env vars so they don't override file values - clean_env = {k: v for k, v in os.environ.items() if not k.startswith("HONCHO_")} - with patch("honcho_cli.config.CONFIG_FILE", config_file), patch("honcho_cli.config.CONFIG_DIR", tmp_path), patch.dict(os.environ, clean_env, clear=True): - config = CLIConfig( - base_url="http://localhost:8000", - api_key="test-key-123", - workspace_id="my-ws", - ) - config.save() +class TestLoad: + def test_defaults_when_no_file(self, cfg_path): + loaded = CLIConfig.load() + assert loaded.base_url == "https://api.honcho.dev" + assert loaded.api_key == "" + assert loaded.workspace_id == "" - loaded = CLIConfig.load() - assert loaded.base_url == "http://localhost:8000" - assert loaded.api_key == "test-key-123" - assert loaded.workspace_id == "my-ws" + def test_malformed_file_uses_defaults(self, cfg_path): + cfg_path.write_text("not-json{{{") + assert CLIConfig.load().api_key == "" - def test_redacted(self): - config = CLIConfig(api_key="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.abcdef") - redacted = config.redacted() - assert "eyJhbGci" in redacted["api_key"] - assert "cdef" in redacted["api_key"] - assert "..." in redacted["api_key"] + def test_reads_environment_url(self, cfg_path): + cfg_path.write_text(json.dumps({"apiKey": "k", "environmentUrl": "http://localhost:8000"})) + loaded = CLIConfig.load() + assert loaded.base_url == "http://localhost:8000" + assert loaded.api_key == "k" - def test_redacted_short_key(self): - config = CLIConfig(api_key="short") - redacted = config.redacted() - assert redacted["api_key"] == "***" + def test_ignores_unknown_url_fields(self, cfg_path): + """Legacy `environment` / `baseUrl` keys are NOT consulted — only environmentUrl.""" + cfg_path.write_text(json.dumps({ + "apiKey": "k", + "environment": "production", + "baseUrl": "https://stale.example.com", + })) + assert CLIConfig.load().base_url == "https://api.honcho.dev" # default - def test_redacted_empty_key(self): - config = CLIConfig(api_key="") - redacted = config.redacted() - assert redacted["api_key"] == "" + def test_api_key_and_base_url_ignore_env(self, cfg_path, monkeypatch): + """Both apiKey and base_url must come from config.json — env vars are ignored at runtime.""" + cfg_path.write_text(json.dumps({"environmentUrl": "https://api.honcho.dev"})) + monkeypatch.setenv("HONCHO_API_KEY", "env-key") + monkeypatch.setenv("HONCHO_BASE_URL", "http://localhost:8000") + loaded = CLIConfig.load() + assert loaded.api_key == "" + assert loaded.base_url == "https://api.honcho.dev" + + +class TestSave: + def test_writes_only_cli_owned_keys(self, cfg_path): + """apiKey + environmentUrl are written; workspace/peer/session are not.""" + CLIConfig( + base_url="http://localhost:8000", + api_key="test-key-123", + workspace_id="my-ws", # must NOT be persisted + peer_id="ajspig", + session_id="s1", + ).save() + assert json.loads(cfg_path.read_text()) == { + "environmentUrl": "http://localhost:8000", + "apiKey": "test-key-123", + } + + def test_preserves_foreign_keys(self, cfg_path): + """Other tools' top-level keys (hosts, sessions, ...) are untouched.""" + seed = { + "apiKey": "old-key", + "environmentUrl": "https://api.honcho.dev", + "saveMessages": True, + "sessions": {"/Users/ajspig": "home-chat"}, + "hosts": {"claude_code": {"peerName": "ajspig", "workspace": "agents"}}, + "sessionStrategy": "chat-instance", + } + cfg_path.write_text(json.dumps(seed)) + cfg = CLIConfig.load() + cfg.api_key = "new-key" + cfg.save() + + on_disk = json.loads(cfg_path.read_text()) + assert on_disk["apiKey"] == "new-key" + assert on_disk["environmentUrl"] == "https://api.honcho.dev" + for k in ("saveMessages", "sessions", "hosts", "sessionStrategy"): + assert on_disk[k] == seed[k] + + +@pytest.mark.parametrize( + "api_key, check", + [ + ("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.abcdef", lambda v: v.startswith("eyJhbGci") and v.endswith("cdef") and "..." in v), + ("short", lambda v: v == "***"), + ("", lambda v: v == ""), + ], +) +def test_api_key_redaction(api_key, check): + assert check(CLIConfig(api_key=api_key).redacted()["api_key"])