File size: 12,164 Bytes
6f9a5fd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
# 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 '<json>' [--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 `<PATH>` (default `~/hearthnet-export-<date>.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 `<DATA>/communities/<id>/` (events, manifests, snapshots)
3. Wipe `<DATA>/blobs/` (unless pinned with `--keep-blobs`)
4. Wipe `<CACHE>/embeddings/`
5. Wipe `<LOG>` (unless `--keep-logs`)
6. Wipe `<DATA>/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.