# M12 — CLI **Spec version:** v1.0 **Depends on:** X04 (config), M01 (identity), M03 (bus, via IPC), X03 (observability for doctor), `click` **Depended on by:** Users; packaging --- ## 1. Responsibility Provide the `hearthnet` command. Each subcommand is small, scriptable, exit-code-correct. The CLI either: - Runs in **standalone mode**: does not need a running node (init, doctor on cold disk, export, erase) - Talks to a **running node** over local HTTP (`status`, `caps`, `log`, `trace`), bypassing the UI The CLI never imports a service module. For node-state queries it uses the bus's HTTP API on `127.0.0.1:7080` like any other client. --- ## 2. File layout ``` hearthnet/ ├── cli.py # Click app, all subcommands ├── __main__.py # `python -m hearthnet` → cli.main() └── doctor.py # re-export from X03 for `hearthnet doctor` ``` Installed as console script in `pyproject.toml`: ```toml [project.scripts] hearthnet = "hearthnet.cli:main" ``` --- ## 3. Subcommands ### 3.1 `hearthnet init` ``` hearthnet init [--name NAME] [--profile PROFILE] [--non-interactive] ``` Bootstraps a new node: 1. Resolves XDG paths, creates dirs 2. Generates keypair if absent (M01) 3. Writes default `config.toml` 4. Interactive prompts (unless `--non-interactive`): - Display name - Profile (auto-detected from hardware) - Create or join community 5. If create: builds genesis community manifest, writes it, prints invite QR to terminal (Unicode block art) and saves PNG 6. If join: prompts for invite text, redeems Exits 0 on success, 2 on user abort, 1 on error. ### 3.2 `hearthnet run` ``` hearthnet run [--config PATH] [--no-ui] [--debug] ``` Starts the node: 1. Loads config (X04) 2. Configures observability (X03) 3. Loads keypair (M01) — refuses if missing 4. Verifies community manifest present — if not, redirects to init 5. Composes the node (see [`node.py` in the package layout](#5-the-orchestrator-nodepy)) 6. Blocks until SIGINT / SIGTERM `--no-ui` skips Gradio (useful for headless anchor / RPi). `--debug` raises log level to debug. ### 3.3 `hearthnet status` ``` hearthnet status [--json] ``` Connects to local node at `127.0.0.1:7080`. Reports: - Our node ID + display name + profile - Community ID + name + member count - Online state (online/degraded/offline) + duration in this state - Peers visible (count + summaries) - Registered local capabilities (count + names) - In-flight calls - Event log head Lamport - Disk usage (blobs + events) Exits 0 if reachable, 3 if not reachable, 1 on bad response. ### 3.4 `hearthnet caps` ``` hearthnet caps [--remote-only | --local-only] [--name PATTERN] ``` Lists capability entries. Columns: `name`, `version`, `stability`, `node`, `model/params`, `health`, `p50ms`, `in_flight`. ### 3.5 `hearthnet call` ``` hearthnet call NAME[@VERSION] --body '' [--stream] ``` Make a one-shot capability call. Useful for scripting and testing. ``` hearthnet call llm.chat@1.0 --stream \ --body '{"params":{"model":"qwen2.5-7b-instruct"},"input":{"messages":[{"role":"user","content":"Hi"}]}}' ``` Streams to stdout. Non-zero exit code reflects wire error code (mapped: see [CONTRACT §9](../CAPABILITY_CONTRACT.md)). ### 3.6 `hearthnet log` ``` hearthnet log [--follow] [--level LEVEL] [--component NAME] ``` Tails the structured log file. With `--follow`, behaves like `tail -F` and filters live. ### 3.7 `hearthnet trace` ``` hearthnet trace recent [N] [--capability NAME] ``` Pulls the trace ring buffer via `/trace/recent`. Pretty-prints last N traces. ### 3.8 `hearthnet doctor` ``` hearthnet doctor [--check NAME] ``` Runs X03's self-diagnostics (`run_all` or `run_one`). Coloured terminal output: ``` ✔ keys_present /home/christof/.local/share/hearthnet/keys/device.ed25519 ✔ keys_loadable Ed25519, 32 bytes ✘ mdns_socket Port 5353 in use by avahi-daemon → fix: sudo systemctl stop avahi-daemon ... ``` Exit code: 0 if all pass, 1 if any fail, 2 if doctor itself crashed. ### 3.9 `hearthnet export` ``` hearthnet export [--out PATH] ``` Exports all local data for this user (GDPR right-to-export): - Public manifest - Our authored events - Our chat history - Our pinned files (CIDs + filenames) - Our marketplace posts - Settings (without secrets) Output: a signed ZIP at `` (default `~/hearthnet-export-.zip`). ### 3.10 `hearthnet erase` ``` hearthnet erase [--keep-keys] [--yes] ``` Erases local state. Prompts thrice. With `--keep-keys`, retains the device key (allowing rejoin later). Order of erase: 1. Stop running node (best-effort over IPC) 2. Wipe `/communities//` (events, manifests, snapshots) 3. Wipe `/blobs/` (unless pinned with `--keep-blobs`) 4. Wipe `/embeddings/` 5. Wipe `` (unless `--keep-logs`) 6. Wipe `/keys/` unless `--keep-keys` 7. Print summary ### 3.11 `hearthnet rag` ``` hearthnet rag list hearthnet rag ingest PATH --corpus NAME hearthnet rag reindex --corpus NAME [--embedding-model MODEL] ``` Local CLI for RAG operations. Calls `rag.list_corpora`, `rag.ingest`, and (for reindex) a privileged local-only flow that re-embeds an existing corpus. ### 3.12 `hearthnet invite` ``` hearthnet invite create --node-id NODEID --level LEVEL --ttl HOURS hearthnet invite redeem TEXT_OR_PATH ``` CLI equivalents of the M13 onboarding flows. Useful for headless anchors. ### 3.13 `hearthnet version` Prints `__version__`, contract version, Python version, OS. One line. --- ## 4. CLI architecture (Click) ```python # hearthnet/cli.py import click @click.group() @click.option("--config", type=click.Path(), help="Path to config.toml") @click.pass_context def main(ctx, config): ctx.obj = load_config(Path(config) if config else None) @main.command() @click.option("--name") ... def init(...): ... # ... etc. Each subcommand is its own function. ``` Each command function is < 40 lines and delegates to module-level helpers in the same file. Tests can call the helpers directly without invoking Click runtime. --- ## 5. The orchestrator (`node.py`) The CLI's `run` subcommand calls into `hearthnet.node.start`. This is not strictly part of M12 but is documented here for completeness because it's the central wiring point. ```python # hearthnet/node.py async def start(config: Config) -> None: # 1. observability observability.logging.configure(config.observability) observability.metrics.configure(config.observability) # 2. identity kp = identity.keys.load_or_generate(config.identity.keys_dir) # 3. community check (M13 redirect if missing) if config.community.community_id is None: await onboarding.run_blocking(config, kp) # writes config; restart cycle return # 4. core state event_log = events.EventLog(config.community.state_dir / "events.sqlite", config.community.community_id) snapshot_store = events.SnapshotStore(config.community.state_dir / "snapshots", config.community.community_id) replay_engine = events.ReplayEngine(event_log) community_manifest = identity.manifest.load_or_regenerate(...) # 5. blobs blob_store = blobs.BlobStore(config.file.blobs_dir, gc_threshold=config.file.gc_threshold) # 6. transport + bus pinned = transport.PinnedCerts(...) http_client = transport.HttpClient(kp, kp.node_id_full, config.community.community_id, pinned) bus = CapabilityBus(kp.node_id_full, config.community.community_id, config.bus, http_client, lambda: community_manifest) # 7. peer registry + discovery peer_registry = discovery.PeerRegistry(kp.node_id_full, config.community.community_id) mdns_announcer = discovery.MdnsAnnouncer(...) mdns_browser = discovery.MdnsBrowser(peer_registry, config.community.community_id) udp_announcer = discovery.UdpAnnouncer(...) udp_listener = discovery.UdpListener(peer_registry, config.community.community_id) # 8. services services_list = [] if config.embedding: services_list.append(EmbeddingService(config.embedding)) if config.llm.backends: services_list.append(LlmService(config.llm)) if config.rag.enabled: services_list.append(RagService(config.rag, bus, blob_store, event_log, lambda: community_manifest)) if config.file: services_list.append(FileService(config.file, blob_store, event_log)) if config.market.enabled: services_list.append(MarketplaceService(config.market, bus, event_log, replay_engine, kp, lambda: community_manifest)) if config.chat.enabled: services_list.append(ChatService(config.chat, bus, event_log, replay_engine, peer_registry, kp, kp.node_id_full)) for s in services_list: bus.register_service(s) await s.start() # 9. emergency detector state_bus = emergency.StateBus() detector = emergency.Detector(config.emergency, bus, state_bus) # 10. transport server http_server = transport.HttpServer(config.transport, kp, bus, event_sync=events.SyncServer(event_log), community_manifest_provider=lambda: community_manifest) # 11. UI ui_app = ui.build_ui(bus, state_bus, config.ui, node_id_short=kp.node_id_short, community_name=community_manifest.name) # 12. wire peer events → bus peer_registry.subscribe(...).on_event(bus.on_peer_added, bus.on_peer_updated, bus.on_peer_removed) # 13. periodic manifest publish publisher = ManifestPublisher(kp, community_manifest_provider=..., bus=bus, peer_registry=peer_registry, interval_seconds=MANIFEST_REPUBLISH_INTERVAL_SECONDS) # 14. periodic sync syncer = events.SyncClient(event_log, http_client) sync_loop = PeriodicTask(lambda: syncer.run_round(peer_registry), interval_seconds=300) # 15. run everything await asyncio.gather( http_server.run(), mdns_announcer.start(), mdns_browser.start(), udp_announcer.run(), udp_listener.run(), detector.run(), publisher.run(), sync_loop.run(), ui_app.launch_async(), ) ``` This is the canonical wiring. Anything that looks different across modules is wrong. --- ## 6. Exit code reference | Code | Meaning | |------|---------| | 0 | Success | | 1 | Generic error (see stderr) | | 2 | User aborted / bad usage | | 3 | No running node (for commands needing IPC) | | 4 | Auth / signature failure | | 5 | Disk full / capacity exceeded | --- ## 7. Configuration The CLI reads the same `config.toml` as the daemon. `--config` overrides the path. --- ## 8. Tests ### Unit (per subcommand handler) - `test_init_writes_config_and_keys` - `test_status_against_mock_node_returns_table` - `test_call_streams_stdout_then_zero` - `test_doctor_exit_code_reflects_failures` - `test_erase_keep_keys` ### Integration - `test_full_init_then_run_then_status` — spawn subprocess, await readiness, query - `test_call_returns_nonzero_on_wire_error` - `test_export_zip_is_signed_and_parseable` --- ## 9. Cross-references | What | Where | |------|-------| | Self-diagnostics | [X03 §6](../cross-cutting/X03-observability.md) | | Onboarding helpers | [M13](M13-onboarding.md) | | Bus introspection endpoints | [M03 §3.7](M03-bus.md), [X01 §3.2](../cross-cutting/X01-transport.md) | | Trace ring buffer endpoint | [X01 §3.2](../cross-cutting/X01-transport.md), [X03 §5](../cross-cutting/X03-observability.md) | | Config | [X04](../cross-cutting/X04-config.md) | --- ## 10. Open questions 1. **Daemon mode on Linux** — `systemd` user unit? Ship one in packaging? Phase 1.5. 2. **Windows service / macOS LaunchAgent** — Phase 2. 3. **Shell completion** — Click supports it; ship completions for bash/zsh/fish. 4. **Progress bars for ingest / fetch** — `rich` progress; nice but optional.