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
This commit is contained in:
ajspig 2026-08-25 12:31:34 -04:00 committed by GitHub
parent ac67017a18
commit 5531ff0fee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 2312 additions and 2 deletions

4
.gitignore vendored
View File

@ -6,8 +6,8 @@ api/docker-compose.yml
*.db
data
redis-data
docker-compose.yml
compose.yml
/docker-compose.yml
/compose.yml

View File

@ -505,6 +505,69 @@ honcho session view [<session_id>]
</Accordion>
</AccordionGroup>
## 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
```
<ParamField path="--profile" type="string" default="local">
Local stack profile name.
</ParamField>
<ParamField path="--api-port" type="string">
Host port for the API.
</ParamField>
<ParamField path="--db-port" type="string">
Host port for Postgres.
</ParamField>
<ParamField path="--redis-port" type="string">
Host port for Redis.
</ParamField>
<ParamField path="--setup" type="string">
Interactive config wizard: basic (provider/model) or advanced (embeddings, deriver, dialectic, dreams, flush).
</ParamField>
<ParamField path="--image" type="string">
Honcho image to pull and pin by digest (default: ghcr.io/plastic-labs/honcho:latest).
</ParamField>
<ParamField path="--timeout" type="string" default="180">
Seconds to wait for /health after compose up.
</ParamField>
## honcho status
Show local stack endpoints and container health.
With no ``--profile``, lists every stack under ``~/.honcho/profiles/``.
```bash
honcho status
```
<ParamField path="--profile" type="string">
Limit to this profile. Omit to show every local stack.
</ParamField>
## honcho stop
Stop the local stack started by `honcho start`. Keeps data unless --wipe.
```bash
honcho stop
```
<ParamField path="--profile" type="string" default="local">
Local stack profile name.
</ParamField>
<ParamField path="--wipe" type="boolean">
Also delete volumes (Postgres data).
</ParamField>
## honcho workspace
List, create, inspect, delete, and search workspaces.

View File

@ -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**.

View File

@ -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

View File

@ -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"]

View File

@ -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 = [

View File

@ -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"],
)

View File

@ -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")

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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")

View File

@ -0,0 +1 @@
"""Package data for the local stack (Compose template + Postgres init)."""

View File

@ -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:

View File

@ -0,0 +1 @@
CREATE EXTENSION IF NOT EXISTS vector;

View File

@ -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)

View File

@ -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 = (

View File

@ -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"

View File

@ -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"

View File

@ -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")]

View File

@ -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