fix: removing defaults and changing config write path.
This commit is contained in:
parent
457c972a0f
commit
1f8ebbfa7a
|
|
@ -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 <key> <value>` | 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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 <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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 <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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
Loading…
Reference in New Issue