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:
parent
ac67017a18
commit
5531ff0fee
|
|
@ -6,8 +6,8 @@ api/docker-compose.yml
|
|||
*.db
|
||||
data
|
||||
redis-data
|
||||
docker-compose.yml
|
||||
compose.yml
|
||||
/docker-compose.yml
|
||||
/compose.yml
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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**.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
)
|
||||
|
|
@ -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")
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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")
|
||||
|
|
@ -0,0 +1 @@
|
|||
"""Package data for the local stack (Compose template + Postgres init)."""
|
||||
|
|
@ -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:
|
||||
|
|
@ -0,0 +1 @@
|
|||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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")]
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue