From 5531ff0feeddbdd55c686070efbf029193458e71 Mon Sep 17 00:00:00 2001 From: ajspig <46900795+ajspig@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:31:34 -0400 Subject: [PATCH] Running Honcho Locally via Honcho CLI (#1029) * feat(cli): add honcho start/stop/status for a local Docker stack * feat(cli): fix status command * feat(cli): improving how we pull docker images and writing a config,toml * feat(cli): add honcho start --setup wizard for local stack config * feat(cli): cleaning up unnecessary func, and error throwing * feat(cli): minor clean up in stack.py * feat(cli): read setup wizard defaults from the image config.toml * feat(cli): cleaning up unused commands * feat(cli): adding ignored docker-compose.yml * feat(cli): forward host LLM env into honcho start * feat(cli): share start/stop progress helpers via output.py and cleaning up language --- .gitignore | 4 +- docs/snippets/cli-commands.mdx | 63 +++ docs/v3/documentation/reference/cli.mdx | 21 + honcho-cli/README.md | 31 ++ honcho-cli/pyproject.toml | 4 + honcho-cli/src/honcho_cli/_help.py | 1 + honcho-cli/src/honcho_cli/commands/stack.py | 400 +++++++++++++++ honcho-cli/src/honcho_cli/local/__init__.py | 12 + honcho-cli/src/honcho_cli/local/docker.py | 436 ++++++++++++++++ honcho-cli/src/honcho_cli/local/env.py | 183 +++++++ honcho-cli/src/honcho_cli/local/health.py | 53 ++ honcho-cli/src/honcho_cli/local/profile.py | 157 ++++++ honcho-cli/src/honcho_cli/local/setup.py | 469 ++++++++++++++++++ .../honcho_cli/local/templates/__init__.py | 1 + .../local/templates/docker-compose.yml | 100 ++++ .../src/honcho_cli/local/templates/init.sql | 1 + honcho-cli/src/honcho_cli/main.py | 4 + honcho-cli/src/honcho_cli/output.py | 20 + honcho-cli/tests/test_local.py | 127 +++++ honcho-cli/tests/test_setup.py | 66 +++ honcho-cli/tests/test_start.py | 159 ++++++ skills/honcho-cli/SKILL.md | 2 + 22 files changed, 2312 insertions(+), 2 deletions(-) create mode 100644 honcho-cli/src/honcho_cli/commands/stack.py create mode 100644 honcho-cli/src/honcho_cli/local/__init__.py create mode 100644 honcho-cli/src/honcho_cli/local/docker.py create mode 100644 honcho-cli/src/honcho_cli/local/env.py create mode 100644 honcho-cli/src/honcho_cli/local/health.py create mode 100644 honcho-cli/src/honcho_cli/local/profile.py create mode 100644 honcho-cli/src/honcho_cli/local/setup.py create mode 100644 honcho-cli/src/honcho_cli/local/templates/__init__.py create mode 100644 honcho-cli/src/honcho_cli/local/templates/docker-compose.yml create mode 100644 honcho-cli/src/honcho_cli/local/templates/init.sql create mode 100644 honcho-cli/tests/test_local.py create mode 100644 honcho-cli/tests/test_setup.py create mode 100644 honcho-cli/tests/test_start.py diff --git a/.gitignore b/.gitignore index 9fa90b3e..d7ad4cda 100644 --- a/.gitignore +++ b/.gitignore @@ -6,8 +6,8 @@ api/docker-compose.yml *.db data redis-data -docker-compose.yml -compose.yml +/docker-compose.yml +/compose.yml diff --git a/docs/snippets/cli-commands.mdx b/docs/snippets/cli-commands.mdx index 800d45a7..739416b0 100644 --- a/docs/snippets/cli-commands.mdx +++ b/docs/snippets/cli-commands.mdx @@ -505,6 +505,69 @@ honcho session view [] +## honcho start + +Start a local Honcho stack (API, deriver, Postgres, Redis). + +Requires Docker. Uses cloud LLM providers. Does not change the CLI's +configured server URL — pass HONCHO_BASE_URL to talk to this stack. +``--setup basic`` or ``--setup advanced`` runs an interactive config wizard. + +```bash +honcho start +``` + + + Local stack profile name. + + + Host port for the API. + + + Host port for Postgres. + + + Host port for Redis. + + + Interactive config wizard: basic (provider/model) or advanced (embeddings, deriver, dialectic, dreams, flush). + + + Honcho image to pull and pin by digest (default: ghcr.io/plastic-labs/honcho:latest). + + + Seconds to wait for /health after compose up. + + +## honcho status + +Show local stack endpoints and container health. + +With no ``--profile``, lists every stack under ``~/.honcho/profiles/``. + +```bash +honcho status +``` + + + Limit to this profile. Omit to show every local stack. + + +## honcho stop + +Stop the local stack started by `honcho start`. Keeps data unless --wipe. + +```bash +honcho stop +``` + + + Local stack profile name. + + + Also delete volumes (Postgres data). + + ## honcho workspace List, create, inspect, delete, and search workspaces. diff --git a/docs/v3/documentation/reference/cli.mdx b/docs/v3/documentation/reference/cli.mdx index 2b460d48..5df031d6 100644 --- a/docs/v3/documentation/reference/cli.mdx +++ b/docs/v3/documentation/reference/cli.mdx @@ -22,10 +22,31 @@ uvx honcho-cli ```bash honcho init # confirm/set apiKey + Honcho URL in ~/.honcho/config.json +honcho start # optional: local API + deriver + Postgres + Redis (Docker) honcho doctor # verify your config + connectivity honcho # show banner + command list ``` +## Local stack + +`honcho start` runs a personal Honcho server on your machine via Docker (API, deriver, Postgres, Redis). It is not the managed service at `api.honcho.dev`. Deriver and dialectic call your cloud LLM provider (OpenAI, Anthropic, or Gemini) with a key you supply. Stack files live under `~/.honcho/profiles/local/`. The first start writes `config.toml` there from the image; later starts leave that file alone so your edits persist. + +Pass `--setup basic` or `--setup advanced` for an interactive wizard that writes curated LLM/feature overrides into the profile `.env` (environment variables win over `config.toml`). This is TTY-only. `basic` covers provider and chat model; `advanced` also covers embeddings, deriver/dialectic models, dreams, and deriver flush. Re-running `--setup` while the stack is up recreates the API and deriver containers. + +This does **not** change `environmentUrl` in the shared config file. To talk to the local stack: + +```bash +HONCHO_BASE_URL=http://127.0.0.1:8000 honcho workspace list +``` + +```bash +LLM_OPENAI_API_KEY=sk-... honcho start +honcho start --setup basic +honcho status +honcho stop # keep data +honcho stop --wipe # also delete volumes +``` + ## Configuration The CLI resolves config in this order: **flag → env var → config file → default**. diff --git a/honcho-cli/README.md b/honcho-cli/README.md index b191bcdb..f1585a89 100644 --- a/honcho-cli/README.md +++ b/honcho-cli/README.md @@ -23,6 +23,7 @@ uv tool install honcho-cli ```bash honcho init # confirm/set apiKey + Honcho URL in ~/.honcho/config.json +honcho start # optional: local API + deriver + Postgres + Redis (Docker) honcho doctor # verify your config + connectivity honcho # show banner + command list ``` @@ -31,6 +32,31 @@ honcho # show banner + command list Per-command scoping (workspace / peer / session) is handled via `-w` / `-p` / `-s` flags or `HONCHO_*` env vars — not persisted as CLI defaults. +### Local stack + +`honcho start` runs a personal Honcho server on your machine (API, deriver, Postgres, Redis) via Docker. Inference is cloud-side: set `LLM_OPENAI_API_KEY`, `LLM_ANTHROPIC_API_KEY`, or `LLM_GEMINI_API_KEY` (env overrides `config.toml`). Stack files live under `~/.honcho/profiles/local/` and are not committed to a project. + +On first start, the CLI pulls `ghcr.io/plastic-labs/honcho:latest` and **pins that digest** in `profile.json`, then copies the image's `config.toml.example` to `config.toml` in the same directory. `honcho start` never overwrites `config.toml` after that — including when you re-pin the image. Delete the file yourself if you want a fresh copy from a new image. + +Pass `--setup basic` or `--setup advanced` for an interactive wizard that writes curated LLM/feature overrides into the profile `.env` (env wins over `config.toml`). TTY only; re-runnable. `basic` asks provider + chat model; `advanced` also covers embeddings, deriver/dialectic models, dreams, and snappy deriver flush. Everything else stays in `config.toml`. + +`honcho start` does **not** change `environmentUrl` in `~/.honcho/config.json` (that file is shared with plugins). To talk to the local stack for one command: + +```bash +HONCHO_BASE_URL=http://127.0.0.1:8000 honcho workspace list +``` + +To make local the default, run `honcho init --base-url http://127.0.0.1:8000`. + +```bash +LLM_OPENAI_API_KEY=sk-... honcho start +honcho start --setup basic +honcho start --setup advanced +honcho status +honcho stop # keep data +honcho stop --wipe # also delete volumes +``` + ## Commands ### Onboarding @@ -38,6 +64,9 @@ Per-command scoping (workspace / peer / session) is handled via `-w` / `-p` / `- | Command | Description | |---------|-------------| | `honcho init` | Confirm/set `apiKey` + `environmentUrl` in `~/.honcho/config.json` | +| `honcho start` | Start a local Honcho stack (API, deriver, Postgres, Redis). Requires Docker and a cloud LLM key. `--setup basic` / `--setup advanced` runs an interactive config wizard (TTY only). Does not change `environmentUrl`. | +| `honcho stop` | Stop the local stack. `--wipe` also deletes volumes. | +| `honcho status` | Show every local stack (or `--profile` for one). | | `honcho doctor` | Health check: config, connectivity, workspace, peer, queue | ### Workspaces @@ -157,6 +186,8 @@ Precedence (highest first): **flag → env var → config file → default**. | `HONCHO_PEER_ID` | `-p` / `--peer` | Peer scope | | `HONCHO_SESSION_ID` | `-s` / `--session` | Session scope | | `HONCHO_JSON` | `--json` | Force JSON output (`1` / `true`) | +| `HONCHO_PROFILE` | `--profile` (start/stop/status) | Local stack profile (default: `local`) | +| `LLM_OPENAI_API_KEY` | — | Provider key for `honcho start` (also `LLM_ANTHROPIC_API_KEY`, `LLM_GEMINI_API_KEY`) | ```bash # Per-command flags diff --git a/honcho-cli/pyproject.toml b/honcho-cli/pyproject.toml index 5eb859fd..2a5e59e2 100644 --- a/honcho-cli/pyproject.toml +++ b/honcho-cli/pyproject.toml @@ -38,6 +38,10 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/honcho_cli"] +[tool.hatch.build.targets.wheel.force-include] +"src/honcho_cli/local/templates/docker-compose.yml" = "honcho_cli/local/templates/docker-compose.yml" +"src/honcho_cli/local/templates/init.sql" = "honcho_cli/local/templates/init.sql" + [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/honcho-cli/src/honcho_cli/_help.py b/honcho-cli/src/honcho_cli/_help.py index d47d344d..5e8b48be 100644 --- a/honcho-cli/src/honcho_cli/_help.py +++ b/honcho-cli/src/honcho_cli/_help.py @@ -67,6 +67,7 @@ def print_welcome(console: Console) -> None: start_rows = [ ("honcho init", "configure API key and server URL"), + ("honcho start", "run a local Honcho stack (Docker)"), ("honcho doctor", "verify connection and workspace health"), ] cmd_rows = [ diff --git a/honcho-cli/src/honcho_cli/commands/stack.py b/honcho-cli/src/honcho_cli/commands/stack.py new file mode 100644 index 00000000..136a8ffe --- /dev/null +++ b/honcho-cli/src/honcho_cli/commands/stack.py @@ -0,0 +1,400 @@ +"""Local stack lifecycle: ``honcho start``, ``honcho stop``, ``honcho status``. + +Does not mutate ``~/.honcho/config.json``. The CLI stays pointed at whatever +``honcho init`` configured (typically api.honcho.dev). Print the local URL +and a one-shot ``HONCHO_BASE_URL=...`` hint instead. +""" + +from __future__ import annotations + +import typer +from rich.console import Console + +from honcho_cli.branding import BRAND, ICON_FAIL, ICON_OK +from honcho_cli.local import ( + DEFAULT_HEALTH_TIMEOUT, + DEFAULT_IMAGE, + DEFAULT_PROFILE, + STACK_SERVICES, +) +from honcho_cli.local.docker import ( + DockerError, + allocate_host_ports, + compose_down, + compose_ps, + compose_up, + pin_image, + seed_config_toml, + services_running, +) +from honcho_cli.local.env import has_provider_key, render_stack, settings_from_environ +from honcho_cli.local.health import stack_healthy, wait_for_health +from honcho_cli.local.profile import ( + LocalProfile, + list_profile_names, + load_profile, + resolve_profile_name, + save_profile, +) +from honcho_cli.local.setup import ( + SETUP_MODES, + answers_drop_keys, + answers_to_env, + run_setup, +) +from honcho_cli.output import ( + fail, + ok, + print_error, + print_json, + print_result, + set_json_mode, + step, + use_json, +) + +_console = Console(stderr=True) + +_MISSING_LLM_KEY = ( + "Set LLM_OPENAI_API_KEY, LLM_ANTHROPIC_API_KEY, or LLM_GEMINI_API_KEY, " + "or run honcho start --setup basic." +) + + +def _die(code: str, message: str, details: dict | None = None) -> None: + print_error(code, message, details) + raise typer.Exit(1) + + +def _validate_setup(setup: str | None) -> str | None: + if setup is None: + return None + mode = setup.strip().lower() + if mode not in SETUP_MODES: + _die( + "INVALID_SETUP", + f"Unknown setup mode {setup!r}. Use --setup basic or --setup advanced.", + {"setup": setup}, + ) + if use_json(): + _die( + "SETUP_REQUIRES_TTY", + "honcho start --setup is interactive. Run it in a terminal without --json.", + {"setup": mode}, + ) + return mode + + +def _payload( + profile: LocalProfile, status: str, services: dict[str, str] | None = None +) -> dict: + return { + "profile": profile.name, + "status": status, + "image": profile.image, + "endpoints": profile.endpoints(), + "services": services or {}, + "hint": f"HONCHO_BASE_URL={profile.base_url} honcho workspace list", + } + + +def _print_stack(payload: dict) -> None: + if use_json(): + print_json(payload) + return + endpoints = payload["endpoints"] + _console.print() + table_data = { + "API": endpoints["api"], + "Docs": endpoints["docs"], + "Postgres": endpoints["postgres"], + "Redis": endpoints["redis"], + } + print_result(table_data) + _console.print() + _console.print( + " [dim]CLI still points at your configured server (typically api.honcho.dev).[/dim]" + ) + _console.print(f" [dim]To talk to this stack:[/dim] {payload['hint']}") + _console.print() + + +def _print_running(profile: LocalProfile) -> None: + _print_stack(_payload(profile, "running", services_running(compose_ps(profile)))) + + +def _seed_config(profile: LocalProfile) -> None: + if seed_config_toml(profile): + ok("config.toml") + + +def _inspect(profile: LocalProfile) -> tuple[dict[str, str], bool]: + """Compose service states and whether the API is healthy.""" + return services_running(compose_ps(profile)), stack_healthy(profile) + + +def start( + profile_name: str = typer.Option( + DEFAULT_PROFILE, + "--profile", + envvar="HONCHO_PROFILE", + help="Local stack profile name", + ), + api_port: int | None = typer.Option( + None, "--api-port", min=1, max=65535, help="Host port for the API" + ), + db_port: int | None = typer.Option( + None, "--db-port", min=1, max=65535, help="Host port for Postgres" + ), + redis_port: int | None = typer.Option( + None, "--redis-port", min=1, max=65535, help="Host port for Redis" + ), + setup: str | None = typer.Option( + None, + "--setup", + help="Interactive config wizard: basic (provider/model) or advanced " + "(embeddings, deriver, dialectic, dreams, flush)", + ), + image: str | None = typer.Option( + None, + "--image", + help=f"Honcho image to pull and pin by digest (default: {DEFAULT_IMAGE})", + ), + timeout: int = typer.Option( + DEFAULT_HEALTH_TIMEOUT, + "--timeout", + min=1, + help="Seconds to wait for /health after compose up", + ), + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """Start a local Honcho stack (API, deriver, Postgres, Redis). + + Requires Docker. Uses cloud LLM providers. Does not change the CLI's + configured server URL — pass HONCHO_BASE_URL to talk to this stack. + ``--setup basic`` or ``--setup advanced`` runs an interactive config wizard. + """ + if json_output: + set_json_mode(True) + + setup = _validate_setup(setup) + name = resolve_profile_name(profile_name) + profile = load_profile(name).overlay( + api_port=api_port, + db_port=db_port, + redis_port=redis_port, + image=image, + ) + pinned_ports = frozenset( + name + for name, value in ( + ("api", api_port), + ("database", db_port), + ("redis", redis_port), + ) + if value is not None + ) + + if not use_json(): + _console.print(f"\n[bold {BRAND}]Honcho Start[/bold {BRAND}]\n") + + try: + already_running = stack_healthy(profile) + if already_running and not setup: + ok(f"Already running ({profile.base_url})") + _print_running(profile) + return + + if not already_running: + profile, remapped = allocate_host_ports(profile, pinned=pinned_ports) + for service, (old, new) in remapped.items(): + step(f"Port {old} in use; {service} on {new}") + + step(f"Pinning {profile.image}") + pinned_image = pin_image(profile.image) + profile = profile.overlay(image=pinned_image) + ok(pinned_image) + + extra = settings_from_environ() + drop: tuple[str, ...] = () + _seed_config(profile) + if setup: + answers = run_setup( + setup, + profile.env_file(), + config_path=profile.config_file(), + ) + extra.update(answers_to_env(answers)) + drop = answers_drop_keys(answers) + ok(f"Wrote overrides to {profile.env_file()}") + _console.print( + f" [dim]Other settings live in {profile.config_file()}[/dim]" + ) + elif not has_provider_key(profile, extra): + _die("MISSING_LLM_KEY", _MISSING_LLM_KEY) + + step(f"Writing stack config to {profile.dir()}") + save_profile(profile) + render_stack(profile, extra=extra, drop=drop) + ok(f"Profile '{profile.name}'") + + step("Starting containers" if not already_running else "Recreating api + deriver") + compose_up( + profile, + recreate=("api", "deriver") if already_running else (), + ) + + step(f"Waiting for API at {profile.base_url}/health") + if not wait_for_health(profile, timeout=float(timeout)): + fail("Timed out waiting for /health") + _die( + "HEALTH_TIMEOUT", + f"Stack started but {profile.base_url}/health did not become ready within {timeout}s. " + f"Check `docker compose -p {profile.project_name} logs`.", + { + "base_url": profile.base_url, + "timeout": timeout, + "project": profile.project_name, + }, + ) + + ok("Honcho is running") + _print_running(profile) + except DockerError as e: + e.exit() + + +def stop( + profile_name: str = typer.Option( + DEFAULT_PROFILE, + "--profile", + envvar="HONCHO_PROFILE", + help="Local stack profile name", + ), + wipe: bool = typer.Option( + False, "--wipe", help="Also delete volumes (Postgres data)" + ), + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """Stop the local stack started by `honcho start`. Keeps data unless --wipe.""" + if json_output: + set_json_mode(True) + + name = resolve_profile_name(profile_name) + profile = load_profile(name) + + try: + if not profile.compose_file().exists(): + payload = _payload(profile, "stopped") + if use_json(): + print_json(payload) + else: + _console.print( + f" [dim]No local stack for profile '{profile.name}'.[/dim]" + ) + return + + running = bool(compose_ps(profile)) + if not running and not wipe: + if use_json(): + print_json(_payload(profile, "stopped")) + else: + _console.print( + f" [dim]Profile '{profile.name}' is already stopped.[/dim]" + ) + return + + compose_down(profile, wipe=wipe) + except DockerError as e: + e.exit() + + state = "wiped" if wipe else "stopped" + ok(f"Stopped profile '{profile.name}'" + (" (volumes removed)" if wipe else "")) + if use_json(): + print_json(_payload(profile, state)) + + +def _status_one(profile: LocalProfile) -> bool: + """Print one profile's status. Return True when the API is healthy.""" + try: + services, running = _inspect(profile) + except DockerError as e: + e.exit() + data = _payload(profile, "running" if running else "stopped", services) + if not use_json(): + icon = ICON_OK if running else ICON_FAIL + _console.print(f"\n {icon} profile '{profile.name}' is {data['status']}\n") + if services: + for svc in STACK_SERVICES: + detail = services.get(svc, "missing") + _console.print(f" {svc:<10} [dim]{detail}[/dim]") + _print_stack(data) + return running + + +def status( + profile_name: str | None = typer.Option( + None, + "--profile", + envvar="HONCHO_PROFILE", + help="Limit to this profile. Omit to show every local stack.", + ), + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), +) -> None: + """Show local stack endpoints and container health. + + With no ``--profile``, lists every stack under ``~/.honcho/profiles/``. + """ + if json_output: + set_json_mode(True) + + if profile_name: + name = resolve_profile_name(profile_name) + profile = load_profile(name) + if not profile.compose_file().exists(): + _die( + "STACK_NOT_FOUND", + f"No local stack for profile '{profile.name}'. Run `honcho start` first.", + {"profile": profile.name}, + ) + if not _status_one(profile): + raise typer.Exit(1) + return + + names = list_profile_names() + if not names: + _die( + "STACK_NOT_FOUND", + "No local stacks. Run `honcho start` first.", + ) + + if len(names) == 1: + if not _status_one(load_profile(names[0])): + raise typer.Exit(1) + return + + rows: list[dict] = [] + try: + for name in names: + profile = load_profile(name) + services, running = _inspect(profile) + rows.append( + _payload(profile, "running" if running else "stopped", services) + ) + except DockerError as e: + e.exit() + if use_json(): + print_json({"profiles": rows}) + return + _console.print() + print_result( + [ + { + "profile": row["profile"], + "status": row["status"], + "api": row["endpoints"]["api"], + } + for row in rows + ], + columns=["profile", "status", "api"], + ) diff --git a/honcho-cli/src/honcho_cli/local/__init__.py b/honcho-cli/src/honcho_cli/local/__init__.py new file mode 100644 index 00000000..1fe4ac83 --- /dev/null +++ b/honcho-cli/src/honcho_cli/local/__init__.py @@ -0,0 +1,12 @@ +"""Local Honcho stack: profiles, Compose rendering, Docker, health checks.""" + +from __future__ import annotations + +DEFAULT_PROFILE = "local" +DEFAULT_API_PORT = 8000 +DEFAULT_DB_PORT = 5432 +DEFAULT_REDIS_PORT = 6379 +DEFAULT_IMAGE = "ghcr.io/plastic-labs/honcho:latest" +DEFAULT_HEALTH_TIMEOUT = 180 + +STACK_SERVICES = ("api", "deriver", "database", "redis") diff --git a/honcho-cli/src/honcho_cli/local/docker.py b/honcho-cli/src/honcho_cli/local/docker.py new file mode 100644 index 00000000..fbf1025f --- /dev/null +++ b/honcho-cli/src/honcho_cli/local/docker.py @@ -0,0 +1,436 @@ +"""Docker daemon + Compose helpers for the local stack.""" + +from __future__ import annotations + +import json +import os +import socket +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +from honcho_cli.local import STACK_SERVICES +from honcho_cli.local.profile import LocalProfile +from honcho_cli.output import print_error + +_DAEMON_DOWN_MARKERS = ( + "cannot connect to the docker daemon", + "is the docker daemon running", + "failed to connect to the docker api", + "error during connect", +) +_COMPOSE_MISSING_MARKERS = ( + "'compose' is not a docker command", + "unknown command: compose", + "docker: unknown command", +) +_CRED_HELPER_MARKERS = ("error getting credentials", "docker-credential-desktop") + + +class DockerError(Exception): + """Docker is missing, the daemon is down, or a Compose command failed.""" + + def __init__(self, code: str, message: str, details: dict | None = None): + super().__init__(message) + self.code = code + self.message = message + self.details = details or {} + + def exit(self) -> None: + print_error(self.code, self.message, self.details or None) + raise SystemExit(1) + + +def port_available(port: int, host: str = "127.0.0.1") -> bool: + """True when nothing is accepting connections on ``host:port``.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.settimeout(0.2) + return sock.connect_ex((host, port)) != 0 + + +def allocate_host_ports( + profile: LocalProfile, + *, + pinned: frozenset[str] = frozenset(), +) -> tuple[LocalProfile, dict[str, tuple[int, int]]]: + """Move api/db/redis host ports that are already bound. + + Names in ``pinned`` (``api`` / ``database`` / ``redis``) were set by a + flag and fail instead of moving. + """ + taken: set[int] = set() + remapped: dict[str, tuple[int, int]] = {} + chosen: dict[str, int] = {} + for name, field, flag in ( + ("api", "api_port", "--api-port"), + ("database", "db_port", "--db-port"), + ("redis", "redis_port", "--redis-port"), + ): + preferred = getattr(profile, field) + port = preferred + if name in pinned: + if preferred in taken or not port_available(preferred): + raise DockerError( + "PORT_IN_USE", + f"Host port {preferred} for {name} is already in use. " + f"Pass {flag} with a free port, or stop the other process.", + {"port": preferred, "service": name, "flag": flag}, + ) + else: + while port in taken or not port_available(port): + port += 1 + if port > preferred + 100: + raise DockerError( + "PORT_IN_USE", + f"Could not find a free host port near {preferred}.", + {"preferred": preferred}, + ) + if port != preferred: + remapped[name] = (preferred, port) + taken.add(port) + chosen[field] = port + return profile.overlay(**chosen), remapped + + +def compose_argv(profile: LocalProfile) -> list[str]: + return [ + "docker", + "compose", + "-f", + str(profile.compose_file()), + "--project-directory", + str(profile.dir()), + "-p", + profile.project_name, + ] + + +_CONFIG_PATHS = ("/app/config.toml.example", "/app/config.toml") +_CONFIG_HEADER = ( + "# Copied from {image} by honcho start. This file is not overwritten on later starts.\n" + "# Secrets belong in .env (environment variables win over this file).\n\n" +) + + +def image_is_digest(ref: str) -> bool: + """True when ``ref`` is already pinned to a content digest.""" + return "@sha256:" in ref.lower() + + +def image_repository(ref: str) -> str: + """Strip a tag or digest from a Docker image reference.""" + if "@" in ref: + return ref.split("@", 1)[0] + last_slash = ref.rfind("/") + last_colon = ref.rfind(":") + if last_colon > last_slash: + return ref[:last_colon] + return ref + + +def pin_image(image: str) -> str: + """Pull ``image`` if needed and return a digest-pinned reference. + + ``ghcr.io/plastic-labs/honcho:latest`` becomes + ``ghcr.io/plastic-labs/honcho@sha256:...`` so the profile does not + float when ``:latest`` moves. Already-pinned refs are left alone. + """ + if image_is_digest(image): + if not _image_exists(image): + _pull(image) + return image + _pull(image) + digest = _repo_digest(image) + if not digest: + raise DockerError( + "IMAGE_PIN_FAILED", + f"Pulled {image} but could not resolve a registry digest to pin.", + {"image": image}, + ) + return digest + + +def seed_config_toml(profile: LocalProfile) -> bool: + """Copy the image's ``config.toml.example`` into the profile if missing. + + Returns True when a file was written. Never overwrites an existing + ``config.toml``. + """ + dest = profile.config_file() + dest.parent.mkdir(parents=True, exist_ok=True) + if dest.exists(): + return False + copied = _copy_from_image(profile.image, _CONFIG_PATHS) + if copied is None: + raise DockerError( + "CONFIG_MISSING", + f"Could not copy config.toml from {profile.image}.", + {"image": profile.image}, + ) + dest.write_text(_CONFIG_HEADER.format(image=profile.image) + copied) + return True + + +def compose_up( + profile: LocalProfile, + *, + recreate: tuple[str, ...] = (), +) -> None: + """``docker compose up -d``. Compose output goes to stderr. + + ``recreate`` names services to ``--force-recreate`` (used after ``--setup`` + on an already-running stack so new ``.env`` values take effect). + """ + args = ["up", "-d"] + if recreate: + args.extend(["--force-recreate", *recreate]) + _run_compose(profile, args) + + +def compose_down(profile: LocalProfile, *, wipe: bool = False) -> None: + args = ["down"] + if wipe: + args.append("-v") + _run_compose(profile, args, capture=False) + + +def compose_ps(profile: LocalProfile) -> list[dict]: + """Parsed ``docker compose ps --format json`` (array or NDJSON).""" + proc = _run_compose(profile, ["ps", "--format", "json"], capture=True, check=False) + if proc.returncode != 0: + return [] + return _parse_ps(proc.stdout or "") + + +def services_running(ps: list[dict]) -> dict[str, str]: + """Map service name → state for the four stack services. + + State is ``running``, ``healthy``, ``exited``, etc. Prefer Docker's + Health field when present. + """ + out: dict[str, str] = {} + for row in ps: + service = str(row.get("Service") or row.get("Name") or "") + # "honcho-local-api-1" → try Service first; fall back to suffix match + if service not in STACK_SERVICES: + for name in STACK_SERVICES: + if ( + service == name + or service.endswith(f"-{name}-1") + or f"_{name}_" in service + ): + service = name + break + else: + continue + health = str(row.get("Health") or "").lower() + state = str(row.get("State") or row.get("Status") or "").lower() + if health: + out[service] = health + elif "health" in state: + # e.g. "running (healthy)" + out[service] = state + else: + out[service] = state or "unknown" + return out + + +def stack_containers_up(ps: list[dict]) -> bool: + """True when all four services are running (deriver has no healthcheck).""" + states = services_running(ps) + if any(name not in states for name in STACK_SERVICES): + return False + for state in states.values(): + if "exit" in state or state in {"dead", "paused"}: + return False + if "running" not in state and "healthy" not in state: + return False + return True + + +def _unavailable(proc: subprocess.CompletedProcess[str]) -> DockerError | None: + """Map a failed docker/compose process to a user-facing error, if obvious.""" + text = f"{proc.stderr or ''}{proc.stdout or ''}" + lower = text.lower() + if any(marker in lower for marker in _DAEMON_DOWN_MARKERS): + return DockerError( + "DOCKER_NOT_RUNNING", + "Docker is installed but the daemon is not running. Start it and retry.", + ) + if any(marker in lower for marker in _COMPOSE_MISSING_MARKERS): + return DockerError( + "DOCKER_COMPOSE_MISSING", + "Honcho start requires Docker Compose v2 (the `docker compose` plugin).", + ) + if any(marker in text for marker in _CRED_HELPER_MARKERS): + return DockerError( + "DOCKER_CREDENTIALS", + "Docker could not read registry credentials " + "(docker-credential-desktop is not on PATH). " + "Quit and reopen your terminal, or add Docker Desktop's bin " + "directory to PATH, then retry.", + {"exit_code": proc.returncode}, + ) + return None + + +def _run_compose( + profile: LocalProfile, + args: list[str], + *, + capture: bool = False, + check: bool = True, +) -> subprocess.CompletedProcess[str]: + cmd = compose_argv(profile) + args + cwd: Path = profile.dir() + try: + proc = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) + except FileNotFoundError as e: + raise DockerError( + "DOCKER_NOT_INSTALLED", + "Docker is not installed. Install Docker Desktop (or another Compose-v2 runtime) and retry.", + ) from e + except OSError as e: + raise DockerError("COMPOSE_FAILED", str(e), {"command": cmd}) from e + if not capture: + if proc.stdout: + sys.stderr.write(proc.stdout) + if proc.stderr: + sys.stderr.write(proc.stderr) + if proc.returncode != 0: + classified = _unavailable(proc) + if classified is not None: + raise classified + if check and proc.returncode != 0: + raise DockerError( + "COMPOSE_FAILED", + "docker compose failed. See output above, or run `docker compose -p " + f"{profile.project_name} logs`.", + {"project": profile.project_name, "exit_code": proc.returncode}, + ) + return proc + + +def _run_docker( + args: list[str], + *, + check: bool = False, +) -> subprocess.CompletedProcess[str]: + try: + proc = subprocess.run( + ["docker", *args], + capture_output=True, + text=True, + ) + except FileNotFoundError as e: + raise DockerError( + "DOCKER_NOT_INSTALLED", + "Docker is not installed. Install Docker Desktop (or another Compose-v2 runtime) and retry.", + ) from e + except OSError as e: + raise DockerError("DOCKER_FAILED", str(e), {"command": args}) from e + if proc.returncode == 0: + return proc + classified = _unavailable(proc) + if classified is not None: + raise classified + if check: + raise DockerError( + "DOCKER_FAILED", + f"docker {' '.join(args)} failed.", + { + "exit_code": proc.returncode, + "stderr": (proc.stderr or "")[-500:], + }, + ) + return proc + + +def _pull(image: str) -> None: + proc = _run_docker(["pull", image], check=False) + if proc.stdout: + sys.stderr.write(proc.stdout) + if proc.stderr: + sys.stderr.write(proc.stderr) + if proc.returncode != 0: + raise DockerError( + "IMAGE_PULL_FAILED", + f"Failed to pull {image}.", + {"image": image, "exit_code": proc.returncode}, + ) + + +def _image_exists(image: str) -> bool: + return _run_docker(["image", "inspect", image], check=False).returncode == 0 + + +def _repo_digest(image: str) -> str | None: + proc = _run_docker( + ["image", "inspect", "--format", "{{json .RepoDigests}}", image], + check=False, + ) + if proc.returncode != 0: + return None + try: + digests = json.loads((proc.stdout or "").strip() or "[]") + except json.JSONDecodeError: + return None + if not isinstance(digests, list): + return None + repo = image_repository(image) + for item in digests: + if isinstance(item, str) and item.startswith(repo + "@"): + return item + for item in digests: + if isinstance(item, str) and "@sha256:" in item: + return item + return None + + +def _copy_from_image(image: str, paths: tuple[str, ...]) -> str | None: + """Create a stopped container and copy the first path that exists.""" + name = f"honcho-seed-{os.getpid()}-{time.time_ns()}" + created = _run_docker(["create", "--name", name, image], check=False) + if created.returncode != 0: + cid = (created.stdout or "").strip() or name + _run_docker(["rm", "-f", cid], check=False) + return None + cid = (created.stdout or "").strip() or name + try: + with tempfile.TemporaryDirectory(prefix="honcho-cfg-") as tmp: + dest = Path(tmp) / "config.toml" + for path in paths: + if dest.exists(): + dest.unlink() + copied = _run_docker(["cp", f"{cid}:{path}", str(dest)], check=False) + if copied.returncode == 0 and dest.exists(): + return dest.read_text(encoding="utf-8") + finally: + _run_docker(["rm", "-f", cid], check=False) + return None + + +def _parse_ps(stdout: str) -> list[dict]: + text = stdout.strip() + if not text: + return [] + if text.startswith("["): + try: + data = json.loads(text) + except json.JSONDecodeError: + return [] + return data if isinstance(data, list) else [] + rows: list[dict] = [] + for line in text.splitlines(): + line = line.strip() + if not line: + continue + try: + row = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(row, dict): + rows.append(row) + return rows diff --git a/honcho-cli/src/honcho_cli/local/env.py b/honcho-cli/src/honcho_cli/local/env.py new file mode 100644 index 00000000..ecf9c2c1 --- /dev/null +++ b/honcho-cli/src/honcho_cli/local/env.py @@ -0,0 +1,183 @@ +"""Render Compose + ``.env`` for a local stack profile.""" + +from __future__ import annotations + +import os +from contextlib import suppress +from importlib.resources import files +from pathlib import Path + +from honcho_cli.local.profile import LocalProfile + +# Keys honcho start owns. Unknown lines in an existing .env are preserved. +MANAGED_KEYS = ( + "AUTH_USE_AUTH", + "LOG_LEVEL", + "HONCHO_IMAGE", + "API_PORT", + "DB_PORT", + "REDIS_PORT", +) + +# Host env forwarded into the profile .env (overrides config.toml). +_SETTINGS_PREFIXES = ( + "LLM_", + "EMBEDDING_", + "DERIVER_", + "DIALECTIC_", + "DREAM_", + "SUMMARY_", +) +_LLM_KEYS = ( + "LLM_OPENAI_API_KEY", + "LLM_ANTHROPIC_API_KEY", + "LLM_GEMINI_API_KEY", +) + +_HEADER = ( + "# Generated by honcho start. Extra keys below the managed block are preserved." +) + +_PLACEHOLDERS = frozenset( + { + "", + "your-api-key-here", + "changeme", + "sk-...", + } +) + + +def is_placeholder_key(value: str | None) -> bool: + """True when ``value`` is missing or a known template placeholder.""" + if value is None: + return True + return value.strip() in _PLACEHOLDERS + + +def settings_from_environ() -> dict[str, str]: + """Host env vars that map to Honcho settings. Empty/placeholder values skipped.""" + return { + k: v + for k, v in os.environ.items() + if k.startswith(_SETTINGS_PREFIXES) and not is_placeholder_key(v) + } + + +def read_env_file(path: Path) -> dict[str, str]: + """Parse a dotenv file into a dict. Last assignment of a key wins.""" + if not path.exists(): + return {} + try: + lines = path.read_text(encoding="utf-8").splitlines() + except OSError: + return {} + out: dict[str, str] = {} + for line in lines: + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in stripped: + continue + k, _, v = stripped.partition("=") + out[k.strip()] = _unquote(v.strip()) + return out + + +def read_env_value(path: Path, key: str) -> str | None: + """Return the raw value for ``key`` in a dotenv file, or None.""" + return read_env_file(path).get(key) + + +def has_provider_key(profile: LocalProfile, extra: dict[str, str]) -> bool: + """True when host extra or profile ``.env`` has a real LLM API key.""" + stored = {**read_env_file(profile.env_file()), **extra} + return any(not is_placeholder_key(stored.get(k)) for k in _LLM_KEYS) + + +def managed_env(profile: LocalProfile) -> dict[str, str]: + """Values written into the managed block of ``.env``.""" + return { + "AUTH_USE_AUTH": "false", + "LOG_LEVEL": "INFO", + "HONCHO_IMAGE": profile.image, + "API_PORT": str(profile.api_port), + "DB_PORT": str(profile.db_port), + "REDIS_PORT": str(profile.redis_port), + } + + +def upsert_env( + path: Path, + updates: dict[str, str], + *, + drop: tuple[str, ...] = (), +) -> None: + """Write ``updates``, preserving unrelated user lines. + + Managed keys are written first (stable order), then any other keys in + ``updates``. Keys in ``drop`` are removed and not rewritten. Drops a + previous generated header so it is not duplicated. + """ + drop_set = frozenset(drop) + extras: list[str] = [] + if path.exists(): + for line in path.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if stripped == _HEADER or stripped.startswith( + "# Generated by honcho start" + ): + continue + if not stripped or stripped.startswith("#"): + extras.append(line) + continue + if "=" in stripped: + k, _, _ = stripped.partition("=") + name = k.strip() + if name in updates or name in MANAGED_KEYS or name in drop_set: + continue + extras.append(line) + + managed = [f"{k}={updates[k]}" for k in MANAGED_KEYS if k in updates] + extra_updates = [ + f"{k}={v}" for k, v in updates.items() if k not in MANAGED_KEYS + ] + body = [_HEADER, *managed] + if extra_updates: + if not body[-1].startswith("#"): + body.append("") + body.extend(extra_updates) + if extras: + # Keep a blank line between generated and user keys when there are extras. + if extras[0].strip(): + body.append("") + body.extend(extras) + path.write_text("\n".join(body) + "\n") + with suppress(OSError): + os.chmod(path, 0o600) + + +def render_stack( + profile: LocalProfile, + extra: dict[str, str] | None = None, + drop: tuple[str, ...] = (), +) -> None: + """Write compose, init.sql, and .env into the profile directory.""" + directory = profile.dir() + directory.mkdir(parents=True, exist_ok=True) + with suppress(OSError): + os.chmod(directory, 0o700) + + templates = files("honcho_cli.local.templates") + compose = templates.joinpath("docker-compose.yml").read_text(encoding="utf-8") + init_sql = templates.joinpath("init.sql").read_text(encoding="utf-8") + profile.compose_file().write_text(compose) + (directory / "init.sql").write_text(init_sql) + updates = managed_env(profile) + if extra: + updates.update(extra) + upsert_env(profile.env_file(), updates, drop=drop) + + +def _unquote(value: str) -> str: + if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}: + return value[1:-1] + return value diff --git a/honcho-cli/src/honcho_cli/local/health.py b/honcho-cli/src/honcho_cli/local/health.py new file mode 100644 index 00000000..05593b99 --- /dev/null +++ b/honcho-cli/src/honcho_cli/local/health.py @@ -0,0 +1,53 @@ +"""Poll the local API health endpoint.""" + +from __future__ import annotations + +import time + +import httpx + +from honcho_cli.local.docker import compose_ps, services_running, stack_containers_up +from honcho_cli.local.profile import LocalProfile + + +def api_healthy(base_url: str, *, timeout: float = 2.0) -> bool: + """True when ``GET /health`` returns HTTP 200.""" + try: + with httpx.Client(timeout=timeout) as client: + response = client.get(base_url.rstrip("/") + "/health") + return response.status_code == 200 + except httpx.HTTPError: + return False + + +def stack_healthy(profile: LocalProfile) -> bool: + """True when Compose services are up and the API answers /health.""" + if not profile.compose_file().exists(): + return False + ps = compose_ps(profile) + if not stack_containers_up(ps): + return False + return api_healthy(profile.base_url) + + +def wait_for_health( + profile: LocalProfile, + *, + timeout: float, + interval: float = 1.0, +) -> bool: + """Poll until the API is healthy or ``timeout`` seconds elapse. + + Returns False on timeout. Fails fast if a required container has exited. + """ + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + ps = compose_ps(profile) + states = services_running(ps) + for _name, state in states.items(): + if "exit" in state or state in {"dead"}: + return False + if api_healthy(profile.base_url) and stack_containers_up(ps): + return True + time.sleep(interval) + return api_healthy(profile.base_url) diff --git a/honcho-cli/src/honcho_cli/local/profile.py b/honcho-cli/src/honcho_cli/local/profile.py new file mode 100644 index 00000000..69565071 --- /dev/null +++ b/honcho-cli/src/honcho_cli/local/profile.py @@ -0,0 +1,157 @@ +"""Named local-stack profiles under ``$HONCHO_CONFIG_DIR/profiles``. + +A profile is a Compose project directory, not an auth identity. +Resolution: ``--profile`` > ``HONCHO_PROFILE`` > ``local``. +""" + +from __future__ import annotations + +import json +import os +import re +from contextlib import suppress +from dataclasses import dataclass, replace + +from honcho_cli.local import ( + DEFAULT_API_PORT, + DEFAULT_DB_PORT, + DEFAULT_IMAGE, + DEFAULT_PROFILE, + DEFAULT_REDIS_PORT, +) +from honcho_cli.output import print_error + +_PROFILE_NAME = re.compile(r"^[a-z][a-z0-9_-]{0,62}$") + + +def profiles_dir(): + from honcho_cli import config as cfg + + return cfg.CONFIG_DIR / "profiles" + + +def validate_profile_name(name: str) -> str: + if name and _PROFILE_NAME.match(name): + return name + print_error( + "INVALID_PROFILE", + "Profile name must be lowercase alphanumeric, starting with a letter " + "(hyphens and underscores allowed).", + {"profile": name}, + ) + raise SystemExit(1) + + +def resolve_profile_name(flag: str | None) -> str: + raw = ( + (flag or "").strip() + or (os.environ.get("HONCHO_PROFILE") or "").strip() + or DEFAULT_PROFILE + ) + return validate_profile_name(raw) + + +def list_profile_names() -> list[str]: + """Profile directories that already have a Compose file.""" + root = profiles_dir() + if not root.is_dir(): + return [] + names: list[str] = [] + for path in sorted(root.iterdir()): + if ( + path.is_dir() + and _PROFILE_NAME.match(path.name) + and (path / "docker-compose.yml").exists() + ): + names.append(path.name) + return names + + +@dataclass +class LocalProfile: + """Ports and image for one local stack.""" + + name: str + api_port: int = DEFAULT_API_PORT + db_port: int = DEFAULT_DB_PORT + redis_port: int = DEFAULT_REDIS_PORT + image: str = DEFAULT_IMAGE + + @property + def project_name(self) -> str: + return f"honcho-{self.name}" + + @property + def base_url(self) -> str: + return f"http://127.0.0.1:{self.api_port}" + + def dir(self): + return profiles_dir() / self.name + + def compose_file(self): + return self.dir() / "docker-compose.yml" + + def env_file(self): + return self.dir() / ".env" + + def profile_file(self): + return self.dir() / "profile.json" + + def config_file(self): + return self.dir() / "config.toml" + + def endpoints(self) -> dict[str, str]: + return { + "api": self.base_url, + "docs": f"{self.base_url}/docs", + "postgres": f"postgresql://postgres:postgres@127.0.0.1:{self.db_port}/postgres", + "redis": f"redis://127.0.0.1:{self.redis_port}/0", + } + + def overlay(self, **fields) -> LocalProfile: + return replace(self, **{k: v for k, v in fields.items() if v is not None}) + + +def load_profile(name: str) -> LocalProfile: + profile = LocalProfile(name=validate_profile_name(name)) + path = profile.profile_file() + if not path.exists(): + return profile + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return profile + if not isinstance(data, dict): + return profile + image = data.get("image") + return replace( + profile, + api_port=_port(data.get("apiPort"), profile.api_port), + db_port=_port(data.get("dbPort"), profile.db_port), + redis_port=_port(data.get("redisPort"), profile.redis_port), + image=image if isinstance(image, str) and image else profile.image, + ) + + +def save_profile(profile: LocalProfile) -> None: + directory = profile.dir() + directory.mkdir(parents=True, exist_ok=True) + with suppress(OSError): + os.chmod(directory, 0o700) + payload = { + "apiPort": profile.api_port, + "dbPort": profile.db_port, + "redisPort": profile.redis_port, + "image": profile.image, + } + profile.profile_file().write_text(json.dumps(payload, indent=2) + "\n") + + +def _port(value: object, default: int) -> int: + if isinstance(value, bool): + return default + try: + parsed = int(value) # type: ignore[arg-type] + except (TypeError, ValueError): + return default + return parsed if 1 <= parsed <= 65535 else default diff --git a/honcho-cli/src/honcho_cli/local/setup.py b/honcho-cli/src/honcho_cli/local/setup.py new file mode 100644 index 00000000..5b21728e --- /dev/null +++ b/honcho-cli/src/honcho_cli/local/setup.py @@ -0,0 +1,469 @@ +"""Interactive ``honcho start --setup`` wizard. + +Writes curated LLM/feature overrides for the local stack. Secrets and knobs +go to the profile ``.env`` (env wins over ``config.toml``). Prompts are TTY +only — the start command rejects ``--setup`` in JSON / non-TTY mode. +""" + +from __future__ import annotations + +import tomllib +from dataclasses import dataclass +from pathlib import Path + +import typer +from rich.console import Console + +from honcho_cli.local.env import ( + is_placeholder_key, + read_env_file, + settings_from_environ, +) +from honcho_cli.output import print_error + +SETUP_MODES = ("basic", "advanced") +DIALECTIC_LEVELS = ("minimal", "low", "medium", "high", "max") +PROVIDERS = ("openai", "anthropic", "gemini", "openai-compatible") +EMBEDDING_TRANSPORTS = ("openai", "gemini") + +_CHAT_PREFIXES = ( + "DERIVER_MODEL_CONFIG", + "SUMMARY_MODEL_CONFIG", + "DREAM_DEDUCTION_MODEL_CONFIG", + "DREAM_INDUCTION_MODEL_CONFIG", + *(f"DIALECTIC_LEVELS__{level}__MODEL_CONFIG" for level in DIALECTIC_LEVELS), +) + +_PROVIDER_KEY_ENV = { + "openai": "LLM_OPENAI_API_KEY", + "openai-compatible": "LLM_OPENAI_API_KEY", + "anthropic": "LLM_ANTHROPIC_API_KEY", + "gemini": "LLM_GEMINI_API_KEY", +} + +_console = Console(stderr=True) + + +@dataclass(frozen=True) +class TomlSetupDefaults: + """Model/feature defaults copied from the image ``config.toml``. + + Honcho only ships OpenAI chat/embedding defaults. Other providers have + no suggested model in that file — the wizard does not invent one. + """ + + chat_transport: str | None = None + chat_model: str | None = None + embed_transport: str | None = None + embed_model: str | None = None + embed_dims: int | None = None + dreams_enabled: bool | None = None + flush_enabled: bool | None = None + + +def load_toml_setup_defaults(path: Path | None) -> TomlSetupDefaults: + """Read prompt defaults from the profile ``config.toml`` (image-aligned).""" + if path is None or not path.is_file(): + return TomlSetupDefaults() + try: + with path.open("rb") as fh: + data = tomllib.load(fh) + deriver = data.get("deriver") or {} + chat = deriver.get("model_config") or {} + embedding = data.get("embedding") or {} + embed = embedding.get("model_config") or {} + dream = data.get("dream") or {} + dims = embedding.get("VECTOR_DIMENSIONS") + return TomlSetupDefaults( + chat_transport=chat.get("transport"), + chat_model=chat.get("model"), + embed_transport=embed.get("transport"), + embed_model=embed.get("model"), + embed_dims=dims if isinstance(dims, int) and dims > 0 else None, + dreams_enabled=dream.get("ENABLED"), + flush_enabled=deriver.get("FLUSH_ENABLED"), + ) + except (OSError, tomllib.TOMLDecodeError, TypeError, AttributeError): + return TomlSetupDefaults() + + +def chat_model_default( + provider: str, + env: dict[str, str], + toml: TomlSetupDefaults, + *, + inferred: str | None = None, +) -> str: + """Prefer a previous wizard choice, else the image toml when transports match.""" + if inferred is None: + inferred = infer_provider(env) + if inferred == provider: + current = env.get("DERIVER_MODEL_CONFIG__MODEL") + if current: + return current + if toml.chat_model and _provider_matches_transport(provider, toml.chat_transport): + return toml.chat_model + return "" + + +def _provider_matches_transport(provider: str, transport: str | None) -> bool: + if not transport: + return False + return transport_of(provider) == transport + + +@dataclass(frozen=True) +class SetupAnswers: + """Curated knobs collected by the wizard (or tests).""" + + mode: str + provider: str + api_key: str + chat_model: str + base_url: str | None = None + embedding_api_key: str | None = None + embedding_key_transport: str | None = None + embedding_transport: str | None = None + embedding_model: str | None = None + embedding_dimensions: int | None = None + deriver_model: str | None = None + dialectic_model: str | None = None + dreams_enabled: bool | None = None + flush_enabled: bool | None = None + + +def transport_of(provider: str) -> str: + """Honcho ``MODEL_CONFIG.transport`` for a wizard provider id.""" + return "openai" if provider == "openai-compatible" else provider + + +def answers_to_env(answers: SetupAnswers) -> dict[str, str]: + """Map wizard answers to Honcho env overrides.""" + transport = transport_of(answers.provider) + env: dict[str, str] = {} + + env[_PROVIDER_KEY_ENV[answers.provider]] = answers.api_key + if answers.base_url: + env["LLM_OPENAI_BASE_URL"] = answers.base_url + + if answers.embedding_api_key and answers.embedding_key_transport: + embed_key = ( + "LLM_OPENAI_API_KEY" + if answers.embedding_key_transport == "openai" + else "LLM_GEMINI_API_KEY" + ) + env[embed_key] = answers.embedding_api_key + + for prefix in _CHAT_PREFIXES: + env[f"{prefix}__TRANSPORT"] = transport + env[f"{prefix}__MODEL"] = answers.chat_model + + if answers.deriver_model: + env["DERIVER_MODEL_CONFIG__TRANSPORT"] = transport + env["DERIVER_MODEL_CONFIG__MODEL"] = answers.deriver_model + + if answers.dialectic_model: + for level in DIALECTIC_LEVELS: + env[f"DIALECTIC_LEVELS__{level}__MODEL_CONFIG__TRANSPORT"] = transport + env[f"DIALECTIC_LEVELS__{level}__MODEL_CONFIG__MODEL"] = ( + answers.dialectic_model + ) + + if answers.embedding_transport: + env["EMBEDDING_MODEL_CONFIG__TRANSPORT"] = answers.embedding_transport + if answers.embedding_model: + env["EMBEDDING_MODEL_CONFIG__MODEL"] = answers.embedding_model + if answers.embedding_dimensions is not None: + env["EMBEDDING_VECTOR_DIMENSIONS"] = str(answers.embedding_dimensions) + elif answers.embedding_key_transport == "gemini": + # Basic + Anthropic chat: a Gemini key is unused unless embeddings switch. + env["EMBEDDING_MODEL_CONFIG__TRANSPORT"] = "gemini" + + if answers.dreams_enabled is not None: + env["DREAM_ENABLED"] = "true" if answers.dreams_enabled else "false" + if answers.flush_enabled is not None: + env["DERIVER_FLUSH_ENABLED"] = "true" if answers.flush_enabled else "false" + return env + + +def answers_drop_keys(answers: SetupAnswers) -> tuple[str, ...]: + """Keys to remove so a previous wizard run cannot leak into this one.""" + if answers.provider == "openai-compatible": + return () + return ("LLM_OPENAI_BASE_URL",) + + +def run_setup( + mode: str, + env_path: Path, + *, + config_path: Path | None = None, +) -> SetupAnswers: + """Prompt for ``basic`` or ``advanced`` knobs. Enter keeps the default.""" + env = read_env_file(env_path) + env.update(settings_from_environ()) + defaults = load_toml_setup_defaults(config_path) + _console.print() + _console.print( + " [dim]Configure the local stack. Press Enter to keep the default.[/dim]" + ) + _console.print( + " [dim]These values go in .env (they override config.toml).[/dim]" + ) + _console.print() + + inferred = infer_provider(env) + provider = _choose( + "LLM provider", + [ + ("openai", "OpenAI"), + ("anthropic", "Anthropic"), + ("gemini", "Gemini"), + ("openai-compatible", "OpenAI-compatible (OpenRouter, vLLM, Ollama, …)"), + ], + inferred if inferred in PROVIDERS else "openai", + ) + + base_url: str | None = None + if provider == "openai-compatible": + base_url = _prompt_text( + "OpenAI-compatible base URL", + env.get("LLM_OPENAI_BASE_URL") or "https://openrouter.ai/api/v1", + ) + + key_env = _PROVIDER_KEY_ENV[provider] + api_key = _prompt_secret("API key", env.get(key_env)) + + chat_default = chat_model_default( + provider, env, defaults, inferred=inferred + ) + chat_model = _prompt_text( + "Chat model (deriver, dialectic, summary, dream)", + chat_default, + required=True, + ) + + embedding_api_key: str | None = None + embedding_key_transport: str | None = None + embedding_transport: str | None = None + embedding_model: str | None = None + embedding_dimensions: int | None = None + deriver_model: str | None = None + dialectic_model: str | None = None + dreams_enabled: bool | None = None + flush_enabled: bool | None = None + + if mode == "advanced": + embedding_transport = _choose( + "Embedding provider", + [("openai", "OpenAI"), ("gemini", "Gemini")], + _default_embedding_transport(provider, env, defaults), + ) + same_embed = env.get("EMBEDDING_MODEL_CONFIG__TRANSPORT") == embedding_transport + current_embed = env.get("EMBEDDING_MODEL_CONFIG__MODEL") if same_embed else None + embed_from_toml = ( + defaults.embed_model + if defaults.embed_transport == embedding_transport + else None + ) + embedding_model = ( + _prompt_text("Embedding model", current_embed or embed_from_toml or "") + or None + ) + dim_default = ( + int(env["EMBEDDING_VECTOR_DIMENSIONS"]) + if env.get("EMBEDDING_VECTOR_DIMENSIONS", "").isdigit() + else (defaults.embed_dims or 1536) + ) + embedding_dimensions = _prompt_int("Embedding dimensions", dim_default) + embedding_key_transport, embedding_api_key = _embedding_key_if_needed( + provider, embedding_transport, env + ) + deriver_model = _prompt_text("Deriver model", chat_model) + dialectic_model = _prompt_text("Dialectic model (all reasoning levels)", chat_model) + dreams_enabled = _choose_bool( + "Dreams (periodic deeper reasoning)", + _env_bool( + env.get("DREAM_ENABLED"), + default=True if defaults.dreams_enabled is None else defaults.dreams_enabled, + ), + ) + flush_enabled = _choose_bool( + "Snappy local deriver (flush work immediately, skip batching)", + _env_bool( + env.get("DERIVER_FLUSH_ENABLED"), + default=False if defaults.flush_enabled is None else defaults.flush_enabled, + ), + ) + elif provider == "anthropic": + embedding_key_transport = _choose( + "Embeddings (Anthropic has none — pick a provider)", + [("openai", "OpenAI"), ("gemini", "Gemini")], + "openai", + ) + embed_key_env = _PROVIDER_KEY_ENV[ + "openai" if embedding_key_transport == "openai" else "gemini" + ] + embedding_api_key = _prompt_secret("Embedding API key", env.get(embed_key_env)) + + _console.print() + return SetupAnswers( + mode=mode, + provider=provider, + api_key=api_key, + chat_model=chat_model, + base_url=base_url, + embedding_api_key=embedding_api_key, + embedding_key_transport=embedding_key_transport, + embedding_transport=embedding_transport, + embedding_model=embedding_model, + embedding_dimensions=embedding_dimensions, + deriver_model=deriver_model, + dialectic_model=dialectic_model, + dreams_enabled=dreams_enabled, + flush_enabled=flush_enabled, + ) + + +def infer_provider(env: dict[str, str]) -> str: + """Best-effort provider from an existing profile ``.env``.""" + if env.get("LLM_OPENAI_BASE_URL"): + return "openai-compatible" + transport = env.get("DERIVER_MODEL_CONFIG__TRANSPORT") + if transport in ("anthropic", "gemini", "openai"): + return transport + if env.get("LLM_ANTHROPIC_API_KEY") and not env.get("LLM_OPENAI_API_KEY"): + return "anthropic" + if env.get("LLM_GEMINI_API_KEY") and not env.get("LLM_OPENAI_API_KEY"): + return "gemini" + return "openai" + + +def _default_embedding_transport( + provider: str, env: dict[str, str], defaults: TomlSetupDefaults +) -> str: + current = env.get("EMBEDDING_MODEL_CONFIG__TRANSPORT") + if current in EMBEDDING_TRANSPORTS: + return current + if defaults.embed_transport in EMBEDDING_TRANSPORTS: + return defaults.embed_transport + if provider == "gemini": + return "gemini" + return "openai" + + +def _embedding_key_if_needed( + chat_provider: str, + embed_transport: str, + env: dict[str, str], +) -> tuple[str | None, str | None]: + """Prompt for an embedding key when the chat provider cannot supply it.""" + chat_transport = transport_of(chat_provider) + if embed_transport == chat_transport or ( + chat_provider == "openai-compatible" and embed_transport == "openai" + ): + return None, None + key_env = _PROVIDER_KEY_ENV[embed_transport] + key = _prompt_secret(f"{embed_transport} embedding API key", env.get(key_env)) + return embed_transport, key + + +def _choose(label: str, options: list[tuple[str, str]], default: str) -> str: + ids = [item[0] for item in options] + default_idx = ids.index(default) + 1 if default in ids else 1 + _console.print(f" [dim]{label}[/dim]") + for i, (_oid, desc) in enumerate(options, 1): + _console.print(f" [dim]({i})[/dim] {desc}") + raw = typer.prompt( + " Choice", + default=str(default_idx), + show_default=True, + prompt_suffix=": ", + ).strip() + try: + idx = int(raw) + except ValueError: + if raw in ids: + return raw + return options[default_idx - 1][0] + if 1 <= idx <= len(options): + return options[idx - 1][0] + return options[default_idx - 1][0] + + +def _choose_bool(label: str, default: bool) -> bool: + return ( + _choose(label, [("true", "On"), ("false", "Off")], "true" if default else "false") + == "true" + ) + + +def _prompt_text(label: str, default: str, *, required: bool = False) -> str: + while True: + raw = typer.prompt( + f" {label}", + default=default, + show_default=bool(default), + prompt_suffix=": ", + ).strip() + value = raw or default + if value or not required: + return value + _console.print(" [red]A model name is required[/red]") + + +def _prompt_int(label: str, default: int) -> int: + while True: + raw = typer.prompt( + f" {label}", + default=str(default), + show_default=True, + prompt_suffix=": ", + ).strip() + try: + value = int(raw) + except ValueError: + _console.print(" [red]Enter an integer[/red]") + continue + if value > 0: + return value + _console.print(" [red]Must be a positive integer[/red]") + + +def _prompt_secret(label: str, current: str | None) -> str: + if current and not is_placeholder_key(current): + _console.print(f" [dim]Current {label}: {_redact(current)}[/dim]") + _console.print(" [dim](1)[/dim] Keep current key") + _console.print(" [dim](2)[/dim] Enter a new key") + choice = typer.prompt( + " Choice", default="1", show_default=True, prompt_suffix=": " + ).strip() + if choice != "2": + return current + _console.print(f" [dim]{label}[/dim]") + raw = typer.prompt( + f" {label}", + default="", + show_default=False, + hide_input=True, + prompt_suffix=": ", + ).strip() + if not raw or is_placeholder_key(raw): + print_error( + "MISSING_LLM_KEY", + f"{label} is required.", + ) + raise typer.Exit(1) + return raw + + +def _redact(key: str) -> str: + if len(key) <= 4: + return "***" + return "***" + key[-4:] + + +def _env_bool(value: str | None, *, default: bool) -> bool: + if value is None: + return default + return value.strip().lower() in ("1", "true", "yes", "on") diff --git a/honcho-cli/src/honcho_cli/local/templates/__init__.py b/honcho-cli/src/honcho_cli/local/templates/__init__.py new file mode 100644 index 00000000..e7802aa6 --- /dev/null +++ b/honcho-cli/src/honcho_cli/local/templates/__init__.py @@ -0,0 +1 @@ +"""Package data for the local stack (Compose template + Postgres init).""" diff --git a/honcho-cli/src/honcho_cli/local/templates/docker-compose.yml b/honcho-cli/src/honcho_cli/local/templates/docker-compose.yml new file mode 100644 index 00000000..4a19af67 --- /dev/null +++ b/honcho-cli/src/honcho_cli/local/templates/docker-compose.yml @@ -0,0 +1,100 @@ +# Managed by `honcho start`. Re-rendered on every start — edit .env and config.toml, not this file. +# +# Images: ghcr.io/plastic-labs/honcho (API + deriver), pgvector/pgvector:pg15, redis:8.2 +# Ports bind to 127.0.0.1. Auth is off (AUTH_USE_AUTH=false in .env). + +services: + api: + image: ${HONCHO_IMAGE:-ghcr.io/plastic-labs/honcho:latest} + entrypoint: ["sh", "docker/entrypoint.sh"] + depends_on: + database: + condition: service_healthy + redis: + condition: service_healthy + ports: + - "127.0.0.1:${API_PORT:-8000}:8000" + healthcheck: + test: + [ + "CMD", + "/app/.venv/bin/python", + "-c", + "import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=2).read()", + ] + interval: 5s + timeout: 5s + retries: 5 + start_period: 10s + volumes: + - lancedb-data:/app/lancedb_data + - ./config.toml:/app/config.toml:ro + environment: + - DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@database:5432/postgres + - CACHE_URL=redis://redis:6379/0?suppress=true + - CACHE_ENABLED=true + env_file: + - path: .env + required: false + restart: unless-stopped + + deriver: + image: ${HONCHO_IMAGE:-ghcr.io/plastic-labs/honcho:latest} + entrypoint: ["/app/.venv/bin/python", "-m", "src.deriver"] + depends_on: + api: + condition: service_healthy + database: + condition: service_healthy + redis: + condition: service_healthy + volumes: + - lancedb-data:/app/lancedb_data + - ./config.toml:/app/config.toml:ro + environment: + - DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@database:5432/postgres + - CACHE_URL=redis://redis:6379/0?suppress=true + - CACHE_ENABLED=true + env_file: + - path: .env + required: false + restart: unless-stopped + + database: + image: pgvector/pgvector:pg15 + restart: unless-stopped + ports: + - "127.0.0.1:${DB_PORT:-5432}:5432" + command: ["postgres", "-c", "max_connections=200"] + environment: + - POSTGRES_DB=postgres + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + - POSTGRES_HOST_AUTH_METHOD=trust + - PGDATA=/var/lib/postgresql/data/pgdata + volumes: + - ./init.sql:/docker-entrypoint-initdb.d/init.sql + - pgdata:/var/lib/postgresql/data/ + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"] + interval: 5s + timeout: 5s + retries: 5 + + redis: + image: redis:8.2 + restart: unless-stopped + ports: + - "127.0.0.1:${REDIS_PORT:-6379}:6379" + volumes: + - redis-data:/data + healthcheck: + test: ["CMD-SHELL", "redis-cli ping"] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + pgdata: + redis-data: + lancedb-data: diff --git a/honcho-cli/src/honcho_cli/local/templates/init.sql b/honcho-cli/src/honcho_cli/local/templates/init.sql new file mode 100644 index 00000000..0aa0fc22 --- /dev/null +++ b/honcho-cli/src/honcho_cli/local/templates/init.sql @@ -0,0 +1 @@ +CREATE EXTENSION IF NOT EXISTS vector; diff --git a/honcho-cli/src/honcho_cli/main.py b/honcho-cli/src/honcho_cli/main.py index 7ed8aa9b..8a9c16f6 100644 --- a/honcho-cli/src/honcho_cli/main.py +++ b/honcho-cli/src/honcho_cli/main.py @@ -64,9 +64,13 @@ def main( # Register top-level commands from honcho_cli.commands.setup import doctor, init +from honcho_cli.commands.stack import start, status, stop app.command()(init) app.command()(doctor) +app.command()(start) +app.command()(stop) +app.command()(status) @app.command("help", hidden=True) diff --git a/honcho-cli/src/honcho_cli/output.py b/honcho-cli/src/honcho_cli/output.py index 2f0ec5e9..e95f17e6 100644 --- a/honcho-cli/src/honcho_cli/output.py +++ b/honcho-cli/src/honcho_cli/output.py @@ -15,6 +15,8 @@ from rich.console import Console from rich.table import Table from rich.text import Text +from honcho_cli.branding import ICON_FAIL, ICON_OK, ICON_RUN + console = Console(stderr=True) stdout_console = Console() @@ -106,6 +108,24 @@ def status(msg: str) -> None: console.print(f"[dim]{msg}[/dim]") +def step(msg: str) -> None: + """Print a progress step. No-op in JSON mode.""" + if not use_json(): + console.print(f" {ICON_RUN} {msg}") + + +def ok(msg: str) -> None: + """Print a success line. No-op in JSON mode.""" + if not use_json(): + console.print(f" {ICON_OK} {msg}") + + +def fail(msg: str) -> None: + """Print a failure line. No-op in JSON mode.""" + if not use_json(): + console.print(f" {ICON_FAIL} {msg}") + + # Stable peer-color palette for transcript rendering. Brand blue first so the # primary peer lands on brand when there's only one speaker. _PEER_COLORS = ( diff --git a/honcho-cli/tests/test_local.py b/honcho-cli/tests/test_local.py new file mode 100644 index 00000000..02e5cf2f --- /dev/null +++ b/honcho-cli/tests/test_local.py @@ -0,0 +1,127 @@ +"""Local-stack contracts: profile files, env merge, image pin, port remap.""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest +from honcho_cli.local.docker import ( + DockerError, + allocate_host_ports, + pin_image, + seed_config_toml, +) +from honcho_cli.local.env import managed_env, read_env_value, render_stack, upsert_env +from honcho_cli.local.profile import LocalProfile, load_profile, save_profile + + +@pytest.fixture +def cfg_dir(tmp_path, monkeypatch): + monkeypatch.setattr("honcho_cli.config.CONFIG_DIR", tmp_path) + monkeypatch.setattr("honcho_cli.config.CONFIG_FILE", tmp_path / "config.json") + for k in [k for k in os.environ if k.startswith("HONCHO_")]: + monkeypatch.delenv(k) + return tmp_path + + +def test_profile_roundtrip_has_no_secrets(cfg_dir): + profile = LocalProfile( + name="local", + api_port=8001, + image="ghcr.io/plastic-labs/honcho@sha256:abc", + ) + save_profile(profile) + loaded = load_profile("local") + assert loaded.api_port == 8001 + assert loaded.image.endswith("@sha256:abc") + on_disk = json.loads(profile.profile_file().read_text()) + assert "LLM" not in json.dumps(on_disk) + assert set(on_disk) == {"apiPort", "dbPort", "redisPort", "image"} + + +def test_upsert_preserves_extra_env_keys(tmp_path): + path = tmp_path / ".env" + path.write_text("CUSTOM_FLAG=keep-me\n# user comment\n") + upsert_env(path, managed_env(LocalProfile(name="local"))) + text = path.read_text() + assert "CUSTOM_FLAG=keep-me" in text + assert "user comment" in text + assert text.count("Generated by honcho start") == 1 + + +def test_upsert_writes_non_managed_and_preserves_later(tmp_path): + path = tmp_path / ".env" + first = managed_env(LocalProfile(name="local")) + first["DERIVER_MODEL_CONFIG__MODEL"] = "gpt-test" + upsert_env(path, first) + upsert_env(path, managed_env(LocalProfile(name="local"))) + later = path.read_text() + assert "DERIVER_MODEL_CONFIG__MODEL=gpt-test" in later + + +def test_render_stack_uses_published_image(cfg_dir): + profile = LocalProfile(name="local") + render_stack(profile) + compose = profile.compose_file().read_text() + assert "ghcr.io/plastic-labs/honcho" in compose + assert "build:" not in compose + assert compose.count("./config.toml:/app/config.toml:ro") == 2 + assert read_env_value(profile.env_file(), "AUTH_USE_AUTH") == "false" + assert oct(profile.env_file().stat().st_mode)[-3:] == "600" + + +def test_pin_latest_to_matching_digest(monkeypatch): + pulls: list[str] = [] + + def fake_run(args, *, check=False): + if args[:1] == ["pull"]: + pulls.append(args[1]) + return subprocess.CompletedProcess(args, 0, stdout="", stderr="") + if args[:2] == ["image", "inspect"]: + body = json.dumps( + [ + "ghcr.io/plastic-labs/honcho@sha256:deadbeef", + "ghcr.io/other/honcho@sha256:nope", + ] + ) + return subprocess.CompletedProcess(args, 0, stdout=body, stderr="") + raise AssertionError(args) + + monkeypatch.setattr("honcho_cli.local.docker._run_docker", fake_run) + assert pin_image("ghcr.io/plastic-labs/honcho:latest") == ( + "ghcr.io/plastic-labs/honcho@sha256:deadbeef" + ) + assert pulls == ["ghcr.io/plastic-labs/honcho:latest"] + + +def test_seed_config_toml_writes_once(cfg_dir, monkeypatch): + profile = LocalProfile( + name="local", image="ghcr.io/plastic-labs/honcho@sha256:abc" + ) + monkeypatch.setattr( + "honcho_cli.local.docker._copy_from_image", + lambda image, paths: "[deriver]\nWORKERS = 2\n", + ) + assert seed_config_toml(profile) is True + profile.config_file().write_text( + profile.config_file().read_text() + "# user edit\n" + ) + assert seed_config_toml(profile) is False + assert "# user edit" in profile.config_file().read_text() + + +def test_busy_port_remaps_unless_pinned(monkeypatch): + monkeypatch.setattr( + "honcho_cli.local.docker.port_available", + lambda port, host="127.0.0.1": port != 6379, + ) + profile, remapped = allocate_host_ports(LocalProfile(name="local")) + assert profile.redis_port == 6380 + assert remapped["redis"] == (6379, 6380) + + with pytest.raises(DockerError) as exc: + allocate_host_ports(LocalProfile(name="local"), pinned=frozenset({"redis"})) + assert exc.value.code == "PORT_IN_USE" + assert exc.value.details["flag"] == "--redis-port" diff --git a/honcho-cli/tests/test_setup.py b/honcho-cli/tests/test_setup.py new file mode 100644 index 00000000..5a411946 --- /dev/null +++ b/honcho-cli/tests/test_setup.py @@ -0,0 +1,66 @@ +"""Wizard mapping: ``answers_to_env`` and image-toml defaults.""" + +from __future__ import annotations + +from honcho_cli.local.setup import ( + DIALECTIC_LEVELS, + SetupAnswers, + answers_to_env, + chat_model_default, + load_toml_setup_defaults, +) + + +def test_basic_openai_applies_chat_model_everywhere(): + env = answers_to_env( + SetupAnswers( + mode="basic", + provider="openai", + api_key="sk-test", + chat_model="gpt-test", + ) + ) + assert env["LLM_OPENAI_API_KEY"] == "sk-test" + assert env["DERIVER_MODEL_CONFIG__MODEL"] == "gpt-test" + assert env["SUMMARY_MODEL_CONFIG__MODEL"] == "gpt-test" + for level in DIALECTIC_LEVELS: + assert env[f"DIALECTIC_LEVELS__{level}__MODEL_CONFIG__MODEL"] == "gpt-test" + assert "DREAM_ENABLED" not in env + assert "EMBEDDING_MODEL_CONFIG__MODEL" not in env + + +def test_basic_anthropic_keeps_openai_embeddings_default(): + env = answers_to_env( + SetupAnswers( + mode="basic", + provider="anthropic", + api_key="sk-ant", + chat_model="claude-haiku-4-5", + embedding_api_key="sk-embed", + embedding_key_transport="openai", + ) + ) + assert env["LLM_ANTHROPIC_API_KEY"] == "sk-ant" + assert env["LLM_OPENAI_API_KEY"] == "sk-embed" + assert env["DERIVER_MODEL_CONFIG__TRANSPORT"] == "anthropic" + assert "EMBEDDING_MODEL_CONFIG__TRANSPORT" not in env + + +def test_chat_default_comes_from_image_toml(tmp_path): + path = tmp_path / "config.toml" + path.write_text( + "[deriver.model_config]\n" + 'transport = "openai"\n' + 'model = "gpt-from-image"\n' + ) + defaults = load_toml_setup_defaults(path) + assert defaults.chat_model == "gpt-from-image" + assert chat_model_default("openai", {}, defaults) == "gpt-from-image" + assert chat_model_default("openai-compatible", {}, defaults) == "gpt-from-image" + assert chat_model_default("anthropic", {}, defaults) == "" + assert chat_model_default( + "openai", + {"DERIVER_MODEL_CONFIG__MODEL": "gpt-from-env"}, + defaults, + inferred="openai", + ) == "gpt-from-env" diff --git a/honcho-cli/tests/test_start.py b/honcho-cli/tests/test_start.py new file mode 100644 index 00000000..2b589813 --- /dev/null +++ b/honcho-cli/tests/test_start.py @@ -0,0 +1,159 @@ +"""CLI contracts for `honcho start` / `stop` / `status`.""" + +from __future__ import annotations + +import json +import os + +import pytest +from honcho_cli.local.docker import image_is_digest, image_repository +from honcho_cli.main import app +from typer.testing import CliRunner + + +@pytest.fixture +def cfg(tmp_path, monkeypatch): + f = tmp_path / "config.json" + monkeypatch.setattr("honcho_cli.config.CONFIG_DIR", tmp_path) + monkeypatch.setattr("honcho_cli.config.CONFIG_FILE", f) + monkeypatch.setattr("honcho_cli.commands.setup.CONFIG_FILE", f) + for k in [k for k in os.environ if k.startswith(("HONCHO_", "LLM_"))]: + monkeypatch.delenv(k) + return f + + +@pytest.fixture +def runner(): + return CliRunner() + + +@pytest.fixture(autouse=True) +def _host_ports_free(monkeypatch): + monkeypatch.setattr("honcho_cli.local.docker.port_available", lambda *a, **k: True) + + +@pytest.fixture(autouse=True) +def _stub_image_pin(monkeypatch): + def fake_pin(image: str) -> str: + if image_is_digest(image): + return image + return f"{image_repository(image)}@sha256:cafedeadbeef" + + monkeypatch.setattr("honcho_cli.commands.stack.pin_image", fake_pin) + monkeypatch.setattr("honcho_cli.commands.stack.seed_config_toml", lambda profile: False) + + +_PS = [ + {"Service": "api", "State": "running", "Health": "healthy"}, + {"Service": "deriver", "State": "running"}, + {"Service": "database", "State": "running", "Health": "healthy"}, + {"Service": "redis", "State": "running", "Health": "healthy"}, +] + + +def test_start_does_not_rewrite_environment_url(cfg, runner, monkeypatch): + cfg.write_text( + json.dumps({"apiKey": "k", "environmentUrl": "https://api.honcho.dev"}) + ) + monkeypatch.setattr("honcho_cli.commands.stack.stack_healthy", lambda profile: False) + monkeypatch.setattr("honcho_cli.commands.stack.compose_up", lambda profile, **k: None) + monkeypatch.setattr("honcho_cli.commands.stack.wait_for_health", lambda *a, **k: True) + monkeypatch.setattr("honcho_cli.commands.stack.compose_ps", lambda profile: _PS) + monkeypatch.setenv("LLM_OPENAI_API_KEY", "sk-test") + result = runner.invoke(app, ["start", "--json"]) + assert result.exit_code == 0, result.stderr + payload = json.loads(result.stdout) + assert payload["endpoints"]["api"] == "http://127.0.0.1:8000" + assert payload["image"].endswith("@sha256:cafedeadbeef") + on_disk = json.loads(cfg.read_text()) + assert on_disk["environmentUrl"] == "https://api.honcho.dev" + + +def test_start_requires_llm_key(cfg, runner, monkeypatch): + monkeypatch.setattr("honcho_cli.commands.stack.stack_healthy", lambda profile: False) + result = runner.invoke(app, ["start"]) + assert result.exit_code == 1 + assert json.loads(result.stderr)["error"]["code"] == "MISSING_LLM_KEY" + + +def test_stop_already_stopped_skips_down(cfg, runner, tmp_path, monkeypatch): + compose = tmp_path / "profiles" / "local" / "docker-compose.yml" + compose.parent.mkdir(parents=True) + compose.write_text("services: {}\n") + monkeypatch.setattr("honcho_cli.commands.stack.compose_ps", lambda profile: []) + down = [] + monkeypatch.setattr( + "honcho_cli.commands.stack.compose_down", + lambda profile, wipe=False: down.append(wipe), + ) + result = runner.invoke(app, ["stop"]) + assert result.exit_code == 0, result.stderr + assert down == [] + assert json.loads(result.stdout)["status"] == "stopped" + + +def test_status_lists_profiles_or_one(cfg, runner, tmp_path, monkeypatch): + for name, port in (("demo", 8001), ("local", 8000)): + d = tmp_path / "profiles" / name + d.mkdir(parents=True) + (d / "docker-compose.yml").write_text("services: {}\n") + (d / "profile.json").write_text(json.dumps({"apiPort": port}) + "\n") + + monkeypatch.setattr( + "honcho_cli.commands.stack.compose_ps", + lambda profile: _PS if profile.name == "local" else [], + ) + monkeypatch.setattr( + "honcho_cli.commands.stack.stack_healthy", + lambda profile: profile.name == "local", + ) + listed = runner.invoke(app, ["status"]) + assert listed.exit_code == 0, listed.stderr + rows = json.loads(listed.stdout)["profiles"] + by_name = {row["profile"]: row for row in rows} + assert by_name["local"]["status"] == "running" + assert by_name["demo"]["endpoints"]["api"] == "http://127.0.0.1:8001" + + one = runner.invoke(app, ["status", "--profile", "local"]) + assert one.exit_code == 0, one.stderr + payload = json.loads(one.stdout) + assert payload["profile"] == "local" + assert "profiles" not in payload + + +def test_start_setup_requires_tty(cfg, runner): + result = runner.invoke(app, ["start", "--setup", "basic", "--json"]) + assert result.exit_code == 1 + assert json.loads(result.stderr)["error"]["code"] == "SETUP_REQUIRES_TTY" + + +def test_start_setup_recreates_when_already_running(cfg, runner, monkeypatch): + from honcho_cli.local.setup import SetupAnswers + + ups: list[tuple[str, ...]] = [] + monkeypatch.setattr("honcho_cli.commands.stack.use_json", lambda: False) + monkeypatch.setattr("honcho_cli.commands.stack.stack_healthy", lambda profile: True) + monkeypatch.setattr( + "honcho_cli.commands.stack.compose_up", + lambda profile, **k: ups.append(k.get("recreate", ())), + ) + monkeypatch.setattr("honcho_cli.commands.stack.wait_for_health", lambda *a, **k: True) + monkeypatch.setattr("honcho_cli.commands.stack.compose_ps", lambda profile: _PS) + monkeypatch.setattr( + "honcho_cli.commands.stack.run_setup", + lambda mode, path, config_path=None: SetupAnswers( + mode="basic", + provider="openai", + api_key="sk-wiz", + chat_model="gpt-test", + ), + ) + pins: list[str] = [] + monkeypatch.setattr( + "honcho_cli.commands.stack.pin_image", + lambda image: pins.append(image) or image, + ) + result = runner.invoke(app, ["start", "--setup", "basic"]) + assert result.exit_code == 0, result.stderr + assert pins == [] + assert ups == [("api", "deriver")] diff --git a/skills/honcho-cli/SKILL.md b/skills/honcho-cli/SKILL.md index e773cfa0..4f75f259 100644 --- a/skills/honcho-cli/SKILL.md +++ b/skills/honcho-cli/SKILL.md @@ -18,6 +18,7 @@ allowed-tools: Bash(honcho:*), Bash(jq:*), Read, Grep ## Command groups - `honcho config` — CLI configuration +- `honcho start` / `stop` / `status` — local Docker stack (does not change `environmentUrl`). First start pins the Honcho image digest and writes `config.toml` into the profile. Pass `--setup basic` or `--setup advanced` for an interactive config wizard (TTY only; writes `.env` overrides). `honcho status` lists every profile; pass `--profile` for one. - `honcho workspace` — inspect, delete, search - `honcho peer` — inspect, card, chat, search - `honcho session` — inspect, view (transcript), context, summaries @@ -31,6 +32,7 @@ allowed-tools: Bash(honcho:*), Bash(jq:*), Read, Grep - Use `honcho session context` to see exactly what an agent receives. - Never run `honcho workspace delete` without `honcho workspace inspect` first. - Compare peer card with conclusions to understand memory state. +- `honcho start` does not rewrite `environmentUrl`. Use `HONCHO_BASE_URL=http://127.0.0.1:8000` to talk to local stack. ## Inspection tour