@sentropic/h2a-cli 0.1.1 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/dist/bin.js +30 -2
  2. package/dist/bin.js.map +1 -1
  3. package/dist/cli-contract.d.ts +62 -0
  4. package/dist/cli-contract.d.ts.map +1 -0
  5. package/dist/cli-contract.js +239 -0
  6. package/dist/cli-contract.js.map +1 -0
  7. package/dist/cli.d.ts +51 -0
  8. package/dist/cli.d.ts.map +1 -1
  9. package/dist/cli.js +711 -2
  10. package/dist/cli.js.map +1 -1
  11. package/dist/hosts/claude.d.ts +11 -6
  12. package/dist/hosts/claude.d.ts.map +1 -1
  13. package/dist/hosts/claude.js +40 -1
  14. package/dist/hosts/claude.js.map +1 -1
  15. package/dist/hosts/codex.d.ts +64 -6
  16. package/dist/hosts/codex.d.ts.map +1 -1
  17. package/dist/hosts/codex.js +39 -1
  18. package/dist/hosts/codex.js.map +1 -1
  19. package/dist/hosts/gemini.d.ts +8 -6
  20. package/dist/hosts/gemini.d.ts.map +1 -1
  21. package/dist/hosts/gemini.js +9 -1
  22. package/dist/hosts/gemini.js.map +1 -1
  23. package/dist/index.d.ts +8 -34
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +5 -2
  26. package/dist/index.js.map +1 -1
  27. package/dist/runtime/local-files/index.d.ts +5 -0
  28. package/dist/runtime/local-files/index.d.ts.map +1 -0
  29. package/dist/runtime/local-files/index.js +5 -0
  30. package/dist/runtime/local-files/index.js.map +1 -0
  31. package/dist/runtime/local-files/locks.d.ts +43 -0
  32. package/dist/runtime/local-files/locks.d.ts.map +1 -0
  33. package/dist/runtime/local-files/locks.js +197 -0
  34. package/dist/runtime/local-files/locks.js.map +1 -0
  35. package/dist/runtime/local-files/paths.d.ts +18 -0
  36. package/dist/runtime/local-files/paths.d.ts.map +1 -0
  37. package/dist/runtime/local-files/paths.js +28 -0
  38. package/dist/runtime/local-files/paths.js.map +1 -0
  39. package/dist/runtime/local-files/schema.d.ts +47 -0
  40. package/dist/runtime/local-files/schema.d.ts.map +1 -0
  41. package/dist/runtime/local-files/schema.js +77 -0
  42. package/dist/runtime/local-files/schema.js.map +1 -0
  43. package/dist/runtime/local-files/store.d.ts +44 -0
  44. package/dist/runtime/local-files/store.d.ts.map +1 -0
  45. package/dist/runtime/local-files/store.js +407 -0
  46. package/dist/runtime/local-files/store.js.map +1 -0
  47. package/dist/runtime/mcp/handlers.d.ts +62 -0
  48. package/dist/runtime/mcp/handlers.d.ts.map +1 -0
  49. package/dist/runtime/mcp/handlers.js +261 -0
  50. package/dist/runtime/mcp/handlers.js.map +1 -0
  51. package/dist/runtime/mcp/index.d.ts +5 -0
  52. package/dist/runtime/mcp/index.d.ts.map +1 -0
  53. package/dist/runtime/mcp/index.js +5 -0
  54. package/dist/runtime/mcp/index.js.map +1 -0
  55. package/dist/runtime/mcp/server.d.ts +26 -0
  56. package/dist/runtime/mcp/server.d.ts.map +1 -0
  57. package/dist/runtime/mcp/server.js +45 -0
  58. package/dist/runtime/mcp/server.js.map +1 -0
  59. package/dist/runtime/mcp/stdio.d.ts +21 -0
  60. package/dist/runtime/mcp/stdio.d.ts.map +1 -0
  61. package/dist/runtime/mcp/stdio.js +103 -0
  62. package/dist/runtime/mcp/stdio.js.map +1 -0
  63. package/dist/runtime/mcp/tools.d.ts +19 -0
  64. package/dist/runtime/mcp/tools.d.ts.map +1 -0
  65. package/dist/runtime/mcp/tools.js +175 -0
  66. package/dist/runtime/mcp/tools.js.map +1 -0
  67. package/package.json +3 -3
package/dist/cli.js CHANGED
@@ -1,7 +1,74 @@
1
+ /**
2
+ * `h2a` CLI dispatcher — stable JSON output contract + exit-code table (DEC-034).
3
+ *
4
+ * Output shapes
5
+ * -------------
6
+ * Every JSON-emitting verb writes ONE of three canonical envelopes on stdout:
7
+ *
8
+ * - **resource** — bare JSON of a single entity. Used by verbs that return the
9
+ * persisted/loaded record itself (`negotiate open`, `negotiate status`,
10
+ * `negotiate event`, `negotiate offer`, `negotiate counter`, `negotiate sign`,
11
+ * `inbox pop`, `host setup --print`).
12
+ * - **list** — bare JSON array. Used by `hosts`, `mcp-tools`, `discover`,
13
+ * `inbox read`, `outbox read`, `negotiate journal`.
14
+ * - **action** — `{ ok: true, ...details }` confirmation envelope. Used by
15
+ * verbs that perform side effects without a natural entity to return
16
+ * (`init`, `register`, `inbox put`, `outbox put`, `negotiate stabilize`,
17
+ * `host setup --write`).
18
+ *
19
+ * Stderr lines always follow `h2a <verb> [sub]: <message>` so callers can
20
+ * grep them deterministically. The `mcp-serve` verb is a long-running
21
+ * JSON-RPC 2.0 stdio transport and does not fit the envelope contract.
22
+ *
23
+ * Exit codes
24
+ * ----------
25
+ *
26
+ * - `0` — success.
27
+ * - `1` — user error: missing/bad flag, invalid JSON, validation failure on
28
+ * caller-supplied data, unknown verb/subverb/host.
29
+ * - `2` — runtime/state error: store conflict or business-rule violation
30
+ * (negotiation not found, already open, already stabilized, signature
31
+ * fails verification, quorum incomplete, broken journal, divergent
32
+ * pre-existing config file refusing merge without `--force`).
33
+ * - `3` — I/O / OS error: file unreadable, permission denied, write
34
+ * refused by the filesystem.
35
+ *
36
+ * The full machine-readable manifest lives in `./cli-contract.ts`
37
+ * (`H2A_CLI_VERB_CONTRACTS`). Human-readable reference: `docs/cli-contract.md`.
38
+ */
39
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
40
+ import { dirname, join } from "node:path";
41
+ import { computeHash, signCanonical } from "@sentropic/h2a";
1
42
  import { H2A_CLAUDE_HOST } from "./hosts/claude.js";
2
43
  import { H2A_CODEX_HOST } from "./hosts/codex.js";
3
44
  import { H2A_GEMINI_HOST } from "./hosts/gemini.js";
4
45
  import { H2A_CLI_MCP_TOOL_NAMES } from "./mcp.js";
46
+ import { H2A_STORE_SCHEMA_VERSION, createLocalStore } from "./runtime/local-files/index.js";
47
+ import { runMcpStdio } from "./runtime/mcp/index.js";
48
+ /**
49
+ * Pattern matchers used to map known store-level error messages to exit code
50
+ * 2 (state/runtime conflict) instead of the default 1 (user error). Anything
51
+ * not matched here keeps the conservative 1 — DEC-034 explicitly opts for
52
+ * "don't over-promote".
53
+ */
54
+ const STORE_STATE_ERROR_PATTERNS = [
55
+ /already registered/i,
56
+ /already open/i,
57
+ /already stabilized/i,
58
+ /not found/i,
59
+ /no such envelope/i,
60
+ /fails verification/i,
61
+ /no artifactHash has the full quorum/i,
62
+ /no offer\/counter event matches/i,
63
+ /stabilized artifact already on disk/i
64
+ ];
65
+ function classifyStoreError(message) {
66
+ for (const pattern of STORE_STATE_ERROR_PATTERNS) {
67
+ if (pattern.test(message))
68
+ return 2;
69
+ }
70
+ return 1;
71
+ }
5
72
  const CLI_HOSTS = [
6
73
  H2A_CODEX_HOST,
7
74
  H2A_CLAUDE_HOST,
@@ -11,22 +78,648 @@ export function renderCliHelp() {
11
78
  return [
12
79
  "h2a",
13
80
  "",
14
- "Human-to-agent coordination CLI bootstrap.",
81
+ "Human-to-agent coordination CLI.",
15
82
  "",
16
83
  "Usage:",
17
84
  " h2a --help",
18
85
  " h2a hosts",
19
86
  " h2a mcp-tools",
87
+ " h2a init [--root <path>]",
88
+ " h2a register --json <json> [--root <path>]",
89
+ " h2a discover [--role <role>] [--scope <scope>] [--root <path>]",
90
+ " h2a negotiate open --json <record-json> [--root <path>]",
91
+ " h2a negotiate status --id <id> --status <status> [--root <path>]",
92
+ " h2a negotiate event --id <id> --json <payload-json> [--causation-id <id>] [--correlation-id <id>] [--root <path>]",
93
+ " h2a negotiate offer --id <id> --instance <id> --artifact <json> [--event-id <id>] [--causation-id <id>] [--correlation-id <id>] [--root <path>]",
94
+ " h2a negotiate counter --id <id> --instance <id> --artifact <json> [--event-id <id>] [--causation-id <id>] [--correlation-id <id>] [--root <path>]",
95
+ " h2a negotiate sign --id <id> --instance <id> --artifact <json> --private-key <pem-path> [--event-id <id>] [--causation-id <id>] [--correlation-id <id>] [--root <path>]",
96
+ " h2a negotiate stabilize --id <id> [--event-id <id>] [--root <path>]",
97
+ " h2a negotiate journal --id <id> [--root <path>]",
98
+ "",
99
+ "Auto-propagation (DEC-033):",
100
+ " offer/counter/sign/event inherit causationId from the previous journal",
101
+ " entry's id, and correlationId from the previous entry's correlationId.",
102
+ " Explicit --causation-id / --correlation-id flags always override the",
103
+ " inherited default; pass them on the first offer to start a fresh thread.",
104
+ " h2a inbox put --instance <id> --json <envelope> [--root <path>]",
105
+ " h2a inbox read --instance <id> [--root <path>]",
106
+ " h2a inbox pop --instance <id> --envelope <id> [--root <path>]",
107
+ " h2a outbox put --instance <id> --json <envelope> [--root <path>]",
108
+ " h2a outbox read --instance <id> [--root <path>]",
109
+ " h2a mcp-serve [--root <path>]",
110
+ " h2a host setup --host <codex|claude> [--root <path>] [--print | --write <file>] [--force]",
111
+ " h2a host status [--host <name>]",
112
+ " h2a store migrate [--from <v>] [--to <v>] [--dry-run] [--root <path>]",
20
113
  "",
21
114
  `Hosts: ${CLI_HOSTS.map((host) => host.host).join(", ")}`,
22
115
  `MCP tools: ${H2A_CLI_MCP_TOOL_NAMES.join(", ")}`
23
116
  ].join("\n");
24
117
  }
118
+ function parseFlags(argv) {
119
+ const [command, ...rest] = argv;
120
+ const flags = {};
121
+ for (let i = 0; i < rest.length; i++) {
122
+ const token = rest[i];
123
+ if (token.startsWith("--")) {
124
+ const key = token.slice(2);
125
+ const next = rest[i + 1];
126
+ if (next !== undefined && !next.startsWith("--")) {
127
+ flags[key] = next;
128
+ i++;
129
+ }
130
+ else {
131
+ flags[key] = "true";
132
+ }
133
+ }
134
+ }
135
+ return { command, flags };
136
+ }
137
+ function resolveRoot(flags, cwd) {
138
+ if (flags.root)
139
+ return flags.root;
140
+ return join(cwd(), ".h2a");
141
+ }
142
+ /**
143
+ * Resolve `causationId` / `correlationId` for a new negotiation event.
144
+ *
145
+ * - Explicit `--causation-id` / `--correlation-id` flags always win.
146
+ * - Otherwise, the values are inherited from the **previous journal entry**
147
+ * on the same negotiation (DEC-033): `causationId` defaults to the previous
148
+ * entry's `id`, `correlationId` is propagated as-is so the whole negotiation
149
+ * acts as a single correlation thread by default.
150
+ */
151
+ function resolveCausationCorrelation(flags, previous) {
152
+ const explicitCausation = flags["causation-id"];
153
+ const explicitCorrelation = flags["correlation-id"];
154
+ const out = {};
155
+ if (explicitCausation) {
156
+ out.causationId = explicitCausation;
157
+ }
158
+ else if (previous) {
159
+ out.causationId = previous.id;
160
+ }
161
+ if (explicitCorrelation) {
162
+ out.correlationId = explicitCorrelation;
163
+ }
164
+ else if (previous && previous.correlationId !== undefined) {
165
+ out.correlationId = previous.correlationId;
166
+ }
167
+ return out;
168
+ }
169
+ function cmdInit(flags, streams) {
170
+ const cwd = streams.cwd ?? (() => process.cwd());
171
+ const root = resolveRoot(flags, cwd);
172
+ try {
173
+ const store = createLocalStore({ root });
174
+ streams.stdout.write(`${JSON.stringify({ ok: true, root: store.paths.root }, null, 2)}\n`);
175
+ return 0;
176
+ }
177
+ catch (error) {
178
+ // `createLocalStore` is the one place this verb can fail, and the only
179
+ // failure mode in practice is filesystem-level (cannot mkdir under root,
180
+ // permission denied, …). Surface those as exit code 3.
181
+ streams.stderr.write(`h2a init: ${error.message}\n`);
182
+ return 3;
183
+ }
184
+ }
185
+ function cmdRegister(flags, streams) {
186
+ if (!flags.json) {
187
+ streams.stderr.write("h2a register: --json <registration-json> is required\n");
188
+ return 1;
189
+ }
190
+ let registration;
191
+ try {
192
+ registration = JSON.parse(flags.json);
193
+ }
194
+ catch (error) {
195
+ streams.stderr.write(`h2a register: invalid JSON (${error.message})\n`);
196
+ return 1;
197
+ }
198
+ const cwd = streams.cwd ?? (() => process.cwd());
199
+ const root = resolveRoot(flags, cwd);
200
+ const store = createLocalStore({ root });
201
+ try {
202
+ store.registerInstance(registration);
203
+ }
204
+ catch (error) {
205
+ const message = error.message;
206
+ streams.stderr.write(`h2a register: ${message}\n`);
207
+ return classifyStoreError(message);
208
+ }
209
+ streams.stdout.write(`${JSON.stringify({ ok: true, id: registration.id, root: store.paths.root }, null, 2)}\n`);
210
+ return 0;
211
+ }
212
+ function cmdMailbox(argv, mailbox, streams) {
213
+ const { command: sub, flags } = parseFlags(argv);
214
+ const cwd = streams.cwd ?? (() => process.cwd());
215
+ const root = resolveRoot(flags, cwd);
216
+ const store = createLocalStore({ root });
217
+ if (!flags.instance) {
218
+ streams.stderr.write(`h2a ${mailbox} ${sub ?? ""}: --instance <id> required\n`);
219
+ return 1;
220
+ }
221
+ if (sub === "put") {
222
+ if (!flags.json) {
223
+ streams.stderr.write(`h2a ${mailbox} put: --json <envelope-json> required\n`);
224
+ return 1;
225
+ }
226
+ let envelope;
227
+ try {
228
+ envelope = JSON.parse(flags.json);
229
+ }
230
+ catch (error) {
231
+ streams.stderr.write(`h2a ${mailbox} put: invalid JSON (${error.message})\n`);
232
+ return 1;
233
+ }
234
+ try {
235
+ if (mailbox === "inbox") {
236
+ store.putInboxMessage(flags.instance, envelope);
237
+ }
238
+ else {
239
+ store.putOutboxMessage(flags.instance, envelope);
240
+ }
241
+ streams.stdout.write(`${JSON.stringify({ ok: true, id: envelope.id, mailbox, instance: flags.instance }, null, 2)}\n`);
242
+ return 0;
243
+ }
244
+ catch (error) {
245
+ const message = error.message;
246
+ streams.stderr.write(`h2a ${mailbox} put: ${message}\n`);
247
+ // Envelope-shape failures are user/validation errors (exit 1); only
248
+ // state-level conflicts escalate to exit 2 (none currently emitted here,
249
+ // but the classifier keeps the door open).
250
+ return classifyStoreError(message);
251
+ }
252
+ }
253
+ if (sub === "read") {
254
+ const messages = mailbox === "inbox" ? store.readInbox(flags.instance) : store.readOutbox(flags.instance);
255
+ streams.stdout.write(`${JSON.stringify(messages, null, 2)}\n`);
256
+ return 0;
257
+ }
258
+ if (sub === "pop" && mailbox === "inbox") {
259
+ if (!flags.envelope) {
260
+ streams.stderr.write("h2a inbox pop: --envelope <id> required\n");
261
+ return 1;
262
+ }
263
+ const popped = store.popInboxMessage(flags.instance, flags.envelope);
264
+ if (!popped) {
265
+ streams.stderr.write(`h2a inbox pop: no such envelope ${flags.envelope}\n`);
266
+ // State conflict against the local store (the envelope is not where the
267
+ // caller expected it). Exit code 2 per DEC-034.
268
+ return 2;
269
+ }
270
+ streams.stdout.write(`${JSON.stringify(popped, null, 2)}\n`);
271
+ return 0;
272
+ }
273
+ streams.stderr.write(`Unknown ${mailbox} subcommand: ${sub ?? "<none>"}\n`);
274
+ streams.stderr.write(mailbox === "inbox" ? "Use one of: put, read, pop\n" : "Use one of: put, read\n");
275
+ return 1;
276
+ }
277
+ function cmdNegotiate(argv, streams) {
278
+ const { command: sub, flags } = parseFlags(argv);
279
+ const cwd = streams.cwd ?? (() => process.cwd());
280
+ const root = resolveRoot(flags, cwd);
281
+ const store = createLocalStore({ root });
282
+ if (sub === "open") {
283
+ if (!flags.json) {
284
+ streams.stderr.write("h2a negotiate open: --json <record-json> required\n");
285
+ return 1;
286
+ }
287
+ let record;
288
+ try {
289
+ record = JSON.parse(flags.json);
290
+ }
291
+ catch (error) {
292
+ streams.stderr.write(`h2a negotiate open: invalid JSON (${error.message})\n`);
293
+ return 1;
294
+ }
295
+ try {
296
+ const opened = store.openNegotiation(record);
297
+ streams.stdout.write(`${JSON.stringify(opened, null, 2)}\n`);
298
+ return 0;
299
+ }
300
+ catch (error) {
301
+ const message = error.message;
302
+ streams.stderr.write(`h2a negotiate open: ${message}\n`);
303
+ return classifyStoreError(message);
304
+ }
305
+ }
306
+ if (sub === "status") {
307
+ if (!flags.id || !flags.status) {
308
+ streams.stderr.write("h2a negotiate status: --id <id> and --status <status> required\n");
309
+ return 1;
310
+ }
311
+ try {
312
+ const updated = store.updateNegotiationStatus(flags.id, flags.status);
313
+ streams.stdout.write(`${JSON.stringify(updated, null, 2)}\n`);
314
+ return 0;
315
+ }
316
+ catch (error) {
317
+ const message = error.message;
318
+ streams.stderr.write(`h2a negotiate status: ${message}\n`);
319
+ return classifyStoreError(message);
320
+ }
321
+ }
322
+ if (sub === "offer" || sub === "counter") {
323
+ if (!flags.id || !flags.instance || !flags.artifact) {
324
+ streams.stderr.write(`h2a negotiate ${sub}: --id <id> --instance <id> --artifact <json> required\n`);
325
+ return 1;
326
+ }
327
+ const record = store.readNegotiation(flags.id);
328
+ if (!record) {
329
+ streams.stderr.write(`h2a negotiate ${sub}: negotiation ${flags.id} not found\n`);
330
+ return 2;
331
+ }
332
+ let artifact;
333
+ try {
334
+ artifact = JSON.parse(flags.artifact);
335
+ }
336
+ catch (error) {
337
+ streams.stderr.write(`h2a negotiate ${sub}: invalid --artifact JSON (${error.message})\n`);
338
+ return 1;
339
+ }
340
+ const existing = store.readNegotiationJournal(flags.id);
341
+ const previous = existing[existing.length - 1];
342
+ const chain = resolveCausationCorrelation(flags, previous);
343
+ const payload = {
344
+ id: flags["event-id"] ?? `evt-${Date.now().toString(36)}`,
345
+ type: sub === "offer" ? "propose" : "counter",
346
+ actor: { instance: flags.instance, role: "CONDUCTOR", scope: record.scope },
347
+ body: { artifact },
348
+ createdAt: new Date().toISOString(),
349
+ ...chain
350
+ };
351
+ try {
352
+ const entry = store.appendNegotiationEvent(flags.id, payload);
353
+ streams.stdout.write(`${JSON.stringify(entry, null, 2)}\n`);
354
+ return 0;
355
+ }
356
+ catch (error) {
357
+ const message = error.message;
358
+ streams.stderr.write(`h2a negotiate ${sub}: ${message}\n`);
359
+ return classifyStoreError(message);
360
+ }
361
+ }
362
+ if (sub === "sign") {
363
+ if (!flags.id || !flags.instance || !flags.artifact || !flags["private-key"]) {
364
+ streams.stderr.write("h2a negotiate sign: --id <id> --instance <id> --artifact <json> --private-key <pem-path> required\n");
365
+ return 1;
366
+ }
367
+ const record = store.readNegotiation(flags.id);
368
+ if (!record) {
369
+ streams.stderr.write(`h2a negotiate sign: negotiation ${flags.id} not found\n`);
370
+ return 2;
371
+ }
372
+ let artifact;
373
+ try {
374
+ artifact = JSON.parse(flags.artifact);
375
+ }
376
+ catch (error) {
377
+ streams.stderr.write(`h2a negotiate sign: invalid --artifact JSON (${error.message})\n`);
378
+ return 1;
379
+ }
380
+ let privateKeyPem;
381
+ try {
382
+ privateKeyPem = readFileSync(flags["private-key"], "utf8");
383
+ }
384
+ catch (error) {
385
+ streams.stderr.write(`h2a negotiate sign: cannot read private key at ${flags["private-key"]} (${error.message})\n`);
386
+ // File/OS error — exit code 3 per DEC-034.
387
+ return 3;
388
+ }
389
+ const artifactHash = computeHash(artifact);
390
+ const signature = signCanonical({ artifactHash }, { by: flags.instance, privateKeyPem });
391
+ const existingForSign = store.readNegotiationJournal(flags.id);
392
+ const previousForSign = existingForSign[existingForSign.length - 1];
393
+ const signChain = resolveCausationCorrelation(flags, previousForSign);
394
+ const payload = {
395
+ id: flags["event-id"] ?? `evt-sign-${Date.now().toString(36)}`,
396
+ type: "event",
397
+ actor: { instance: flags.instance, role: "CONDUCTOR", scope: record.scope },
398
+ body: { kind: "signature", artifactHash, signature },
399
+ createdAt: new Date().toISOString(),
400
+ ...signChain
401
+ };
402
+ try {
403
+ const entry = store.appendNegotiationEvent(flags.id, payload);
404
+ streams.stdout.write(`${JSON.stringify(entry, null, 2)}\n`);
405
+ return 0;
406
+ }
407
+ catch (error) {
408
+ const message = error.message;
409
+ streams.stderr.write(`h2a negotiate sign: ${message}\n`);
410
+ return classifyStoreError(message);
411
+ }
412
+ }
413
+ if (sub === "stabilize") {
414
+ if (!flags.id) {
415
+ streams.stderr.write("h2a negotiate stabilize: --id <id> required\n");
416
+ return 1;
417
+ }
418
+ try {
419
+ const result = store.stabilizeNegotiation(flags.id, { eventId: flags["event-id"] });
420
+ streams.stdout.write(`${JSON.stringify({
421
+ ok: true,
422
+ record: result.record,
423
+ artifactHash: result.artifactHash,
424
+ signers: result.signers,
425
+ artifactPath: result.artifactPath,
426
+ finalEvent: { id: result.finalEvent.id, sequence: result.finalEvent.sequence }
427
+ }, null, 2)}\n`);
428
+ return 0;
429
+ }
430
+ catch (error) {
431
+ const message = error.message;
432
+ streams.stderr.write(`h2a negotiate stabilize: ${message}\n`);
433
+ return classifyStoreError(message);
434
+ }
435
+ }
436
+ if (sub === "event") {
437
+ if (!flags.id || !flags.json) {
438
+ streams.stderr.write("h2a negotiate event: --id <id> and --json <payload-json> required\n");
439
+ return 1;
440
+ }
441
+ let payload;
442
+ try {
443
+ payload = JSON.parse(flags.json);
444
+ }
445
+ catch (error) {
446
+ streams.stderr.write(`h2a negotiate event: invalid JSON (${error.message})\n`);
447
+ return 1;
448
+ }
449
+ const existingForEvent = store.readNegotiationJournal(flags.id);
450
+ const previousForEvent = existingForEvent[existingForEvent.length - 1];
451
+ const eventChain = resolveCausationCorrelation(flags, previousForEvent);
452
+ // Explicit fields inside the user-supplied payload always take precedence
453
+ // over the CLI-resolved defaults: this preserves the existing "just append
454
+ // whatever JSON I gave you" contract while still adding the chain when the
455
+ // user did not opt in.
456
+ payload = { ...eventChain, ...payload };
457
+ try {
458
+ const entry = store.appendNegotiationEvent(flags.id, payload);
459
+ streams.stdout.write(`${JSON.stringify(entry, null, 2)}\n`);
460
+ return 0;
461
+ }
462
+ catch (error) {
463
+ const message = error.message;
464
+ streams.stderr.write(`h2a negotiate event: ${message}\n`);
465
+ return classifyStoreError(message);
466
+ }
467
+ }
468
+ if (sub === "journal") {
469
+ if (!flags.id) {
470
+ streams.stderr.write("h2a negotiate journal: --id <id> required\n");
471
+ return 1;
472
+ }
473
+ try {
474
+ const entries = store.readNegotiationJournal(flags.id);
475
+ streams.stdout.write(`${JSON.stringify(entries, null, 2)}\n`);
476
+ return 0;
477
+ }
478
+ catch (error) {
479
+ const message = error.message;
480
+ streams.stderr.write(`h2a negotiate journal: ${message}\n`);
481
+ return classifyStoreError(message);
482
+ }
483
+ }
484
+ streams.stderr.write(`Unknown negotiate subcommand: ${sub ?? "<none>"}\n`);
485
+ streams.stderr.write("Use one of: open, status, event, offer, counter, sign, stabilize, journal\n");
486
+ return 1;
487
+ }
488
+ /**
489
+ * `h2a mcp-serve` binds directly to the real process std streams because it
490
+ * is a long-running JSON-RPC loop. The test-friendly `streams` interface
491
+ * (write-only) cannot express a readable stdin; tests cover `runMcpStdio`
492
+ * with `PassThrough` streams instead of going through this verb.
493
+ */
494
+ export async function runMcpServe(flags, io = {
495
+ stdin: process.stdin,
496
+ stdout: process.stdout,
497
+ stderr: process.stderr
498
+ }) {
499
+ const cwd = io.cwd ?? (() => process.cwd());
500
+ const root = resolveRoot(flags, cwd);
501
+ try {
502
+ await runMcpStdio({
503
+ root,
504
+ stdin: io.stdin,
505
+ stdout: io.stdout,
506
+ stderr: io.stderr
507
+ });
508
+ return 0;
509
+ }
510
+ catch (err) {
511
+ io.stderr.write(`h2a mcp-serve: ${err.message}\n`);
512
+ return 1;
513
+ }
514
+ }
515
+ function isPlainObject(value) {
516
+ return (typeof value === "object" &&
517
+ value !== null &&
518
+ !Array.isArray(value));
519
+ }
520
+ function configsEqual(a, b) {
521
+ return JSON.stringify(a) === JSON.stringify(b);
522
+ }
523
+ function cmdHostSetup(flags, streams) {
524
+ const host = flags.host;
525
+ if (!host) {
526
+ streams.stderr.write("h2a host setup: --host <codex|claude> is required\n");
527
+ return 1;
528
+ }
529
+ if (host === "gemini") {
530
+ streams.stderr.write("h2a host setup: Gemini is deferred to wave 2 (DEC-028). Use --host codex or claude.\n");
531
+ return 1;
532
+ }
533
+ let snippet;
534
+ if (host === "codex") {
535
+ snippet = H2A_CODEX_HOST.renderMcpConfig({ root: flags.root });
536
+ }
537
+ else if (host === "claude") {
538
+ snippet = H2A_CLAUDE_HOST.renderMcpConfig({ root: flags.root });
539
+ }
540
+ else {
541
+ streams.stderr.write(`h2a host setup: unknown --host "${host}". Supported: codex, claude.\n`);
542
+ return 1;
543
+ }
544
+ const targetPath = flags.write;
545
+ const printMode = flags.print === "true" || !targetPath;
546
+ if (printMode && !targetPath) {
547
+ streams.stdout.write(`${JSON.stringify(snippet.config, null, 2)}\n`);
548
+ streams.stderr.write(`# ${host} — paste this snippet under \`mcpServers\` in:\n# ${snippet.path.hint}\n# example path: ${snippet.path.example}\n`);
549
+ return 0;
550
+ }
551
+ // --write path: merge into the target file.
552
+ let existing = {};
553
+ if (existsSync(targetPath)) {
554
+ let raw;
555
+ try {
556
+ raw = readFileSync(targetPath, "utf8");
557
+ }
558
+ catch (error) {
559
+ streams.stderr.write(`h2a host setup: cannot read ${targetPath} (${error.message})\n`);
560
+ // File/OS error — exit code 3 per DEC-034.
561
+ return 3;
562
+ }
563
+ if (raw.trim().length > 0) {
564
+ try {
565
+ const parsed = JSON.parse(raw);
566
+ if (!isPlainObject(parsed)) {
567
+ streams.stderr.write(`h2a host setup: ${targetPath} is valid JSON but not a JSON object; refusing to merge.\n`);
568
+ // Pre-existing on-disk state we refuse to overwrite — state conflict.
569
+ return 2;
570
+ }
571
+ existing = parsed;
572
+ }
573
+ catch (error) {
574
+ streams.stderr.write(`h2a host setup: ${targetPath} is not valid JSON (${error.message}). Use --force to overwrite intentionally.\n`);
575
+ if (flags.force !== "true") {
576
+ // Pre-existing malformed file refused without --force — state conflict.
577
+ return 2;
578
+ }
579
+ existing = {};
580
+ }
581
+ }
582
+ }
583
+ const existingMcpServers = isPlainObject(existing.mcpServers)
584
+ ? existing.mcpServers
585
+ : {};
586
+ const previous = existingMcpServers.h2a;
587
+ const incoming = snippet.config.mcpServers.h2a;
588
+ if (previous !== undefined &&
589
+ !configsEqual(previous, incoming) &&
590
+ flags.force !== "true") {
591
+ streams.stderr.write(`h2a host setup: ${targetPath} already has a different mcpServers.h2a entry. Re-run with --force to overwrite.\n`);
592
+ // Divergent pre-existing entry — state conflict (exit 2).
593
+ return 2;
594
+ }
595
+ const merged = {
596
+ ...existing,
597
+ mcpServers: {
598
+ ...existingMcpServers,
599
+ h2a: incoming
600
+ }
601
+ };
602
+ try {
603
+ const dir = dirname(targetPath);
604
+ if (dir && !existsSync(dir)) {
605
+ mkdirSync(dir, { recursive: true });
606
+ }
607
+ writeFileSync(targetPath, `${JSON.stringify(merged, null, 2)}\n`);
608
+ }
609
+ catch (error) {
610
+ streams.stderr.write(`h2a host setup: cannot write ${targetPath} (${error.message})\n`);
611
+ // File/OS error — exit code 3 per DEC-034.
612
+ return 3;
613
+ }
614
+ streams.stdout.write(`${JSON.stringify({ ok: true, host, path: targetPath, merged: true }, null, 2)}\n`);
615
+ streams.stderr.write(`# wrote mcpServers.h2a for host=${host} to ${targetPath}\n# ${snippet.path.hint}\n`);
616
+ return 0;
617
+ }
618
+ function cmdHostStatus(flags, streams) {
619
+ const filter = flags.host;
620
+ let selection = CLI_HOSTS;
621
+ if (filter) {
622
+ const match = CLI_HOSTS.find((h) => h.host === filter);
623
+ if (!match) {
624
+ streams.stderr.write(`h2a host status: unknown --host "${filter}". Supported: ${CLI_HOSTS.map((h) => h.host).join(", ")}.\n`);
625
+ return 1;
626
+ }
627
+ selection = [match];
628
+ }
629
+ const hosts = selection.map((descriptor) => {
630
+ const mcpAdapterShipped = true; // both stdio + in-process MCP back every host
631
+ const hostSetupShipped = typeof descriptor.renderMcpConfig === "function";
632
+ const hostScenarioShipped = descriptor.hostScenarioShipped === true;
633
+ const summary = descriptor.wave === 1 && hostSetupShipped && hostScenarioShipped
634
+ ? `wave 1 — host setup + MCP scenario shipped; MCP adapter (stdio + local) wired`
635
+ : descriptor.wave === 1 && hostSetupShipped
636
+ ? `wave 1 — host setup snippet shipped; MCP adapter (stdio + local) wired`
637
+ : descriptor.wave === 2
638
+ ? `wave 2 — descriptor only (DEC-028 defers full enablement)`
639
+ : `wave 1 — MCP adapter wired but no setup snippet`;
640
+ return {
641
+ host: descriptor.host,
642
+ wave: descriptor.wave,
643
+ mcpAdapterShipped,
644
+ hostSetupShipped,
645
+ hostScenarioShipped,
646
+ summary
647
+ };
648
+ });
649
+ streams.stdout.write(`${JSON.stringify({ ok: true, hosts }, null, 2)}\n`);
650
+ return 0;
651
+ }
652
+ function cmdHost(argv, streams) {
653
+ const { command: sub, flags } = parseFlags(argv);
654
+ if (sub === "setup")
655
+ return cmdHostSetup(flags, streams);
656
+ if (sub === "status")
657
+ return cmdHostStatus(flags, streams);
658
+ streams.stderr.write(`Unknown host subcommand: ${sub ?? "<none>"}\n`);
659
+ streams.stderr.write("Use: h2a host setup --host <codex|claude> ...\n" +
660
+ " h2a host status [--host <name>]\n");
661
+ return 1;
662
+ }
663
+ function cmdStoreMigrate(flags, streams) {
664
+ const cwd = streams.cwd ?? (() => process.cwd());
665
+ const root = resolveRoot(flags, cwd);
666
+ const from = flags.from ?? H2A_STORE_SCHEMA_VERSION;
667
+ const to = flags.to ?? H2A_STORE_SCHEMA_VERSION;
668
+ const dryRun = flags["dry-run"] === "true";
669
+ const KNOWN_VERSIONS = [H2A_STORE_SCHEMA_VERSION];
670
+ if (!KNOWN_VERSIONS.includes(from)) {
671
+ streams.stderr.write(`h2a store migrate: unknown --from version "${from}". Known versions: ${KNOWN_VERSIONS.join(",")}\n`);
672
+ return 1;
673
+ }
674
+ if (!KNOWN_VERSIONS.includes(to)) {
675
+ streams.stderr.write(`h2a store migrate: unknown --to version "${to}". Known versions: ${KNOWN_VERSIONS.join(",")}\n`);
676
+ return 1;
677
+ }
678
+ // V1 → V1: no-op. Future bumps will branch here.
679
+ if (from === H2A_STORE_SCHEMA_VERSION && to === H2A_STORE_SCHEMA_VERSION) {
680
+ streams.stdout.write(`${JSON.stringify({
681
+ ok: true,
682
+ fromVersion: from,
683
+ toVersion: to,
684
+ changed: false,
685
+ dryRun,
686
+ root
687
+ }, null, 2)}\n`);
688
+ return 0;
689
+ }
690
+ // Unreachable today (only one known version) — kept for future ramps.
691
+ streams.stderr.write(`h2a store migrate: no migration registered for ${from} → ${to}\n`);
692
+ return 1;
693
+ }
694
+ function cmdStore(argv, streams) {
695
+ const { command: sub, flags } = parseFlags(argv);
696
+ if (sub === "migrate")
697
+ return cmdStoreMigrate(flags, streams);
698
+ streams.stderr.write(`Unknown store subcommand: ${sub ?? "<none>"}\n`);
699
+ streams.stderr.write("Use: h2a store migrate [--from <v>] [--to <v>] [--dry-run] [--root <path>]\n");
700
+ return 1;
701
+ }
702
+ function cmdDiscover(flags, streams) {
703
+ const cwd = streams.cwd ?? (() => process.cwd());
704
+ const root = resolveRoot(flags, cwd);
705
+ const store = createLocalStore({ root });
706
+ let entries = store.listInstances();
707
+ if (flags.role) {
708
+ const role = flags.role;
709
+ entries = entries.filter((entry) => entry.roles.includes(role));
710
+ }
711
+ if (flags.scope) {
712
+ const scope = flags.scope;
713
+ entries = entries.filter((entry) => entry.scopes.includes(scope));
714
+ }
715
+ streams.stdout.write(`${JSON.stringify(entries, null, 2)}\n`);
716
+ return 0;
717
+ }
25
718
  export function runCli(argv = process.argv.slice(2), streams = {
26
719
  stdout: process.stdout,
27
720
  stderr: process.stderr
28
721
  }) {
29
- const [command] = argv;
722
+ const { command, flags } = parseFlags(argv);
30
723
  if (!command || command === "--help" || command === "-h" || command === "help") {
31
724
  streams.stdout.write(`${renderCliHelp()}\n`);
32
725
  return 0;
@@ -39,6 +732,22 @@ export function runCli(argv = process.argv.slice(2), streams = {
39
732
  streams.stdout.write(`${JSON.stringify(H2A_CLI_MCP_TOOL_NAMES, null, 2)}\n`);
40
733
  return 0;
41
734
  }
735
+ if (command === "init")
736
+ return cmdInit(flags, streams);
737
+ if (command === "register")
738
+ return cmdRegister(flags, streams);
739
+ if (command === "discover")
740
+ return cmdDiscover(flags, streams);
741
+ if (command === "negotiate")
742
+ return cmdNegotiate(argv.slice(1), streams);
743
+ if (command === "inbox")
744
+ return cmdMailbox(argv.slice(1), "inbox", streams);
745
+ if (command === "outbox")
746
+ return cmdMailbox(argv.slice(1), "outbox", streams);
747
+ if (command === "host")
748
+ return cmdHost(argv.slice(1), streams);
749
+ if (command === "store")
750
+ return cmdStore(argv.slice(1), streams);
42
751
  streams.stderr.write(`Unknown command: ${command}\n`);
43
752
  streams.stderr.write("Run `h2a --help`.\n");
44
753
  return 1;