@nodii/dev 0.0.0-stage → 0.3.1

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.
@@ -0,0 +1,586 @@
1
+ #!/usr/bin/env node
2
+ import { readFile } from "node:fs/promises";
3
+ import { stdin, stdout } from "node:process";
4
+ import {
5
+ CliError,
6
+ DEFAULT_HUB_URL,
7
+ OPERATOR_AGENT_ID,
8
+ VERSION,
9
+ deleteCredentials,
10
+ encode,
11
+ hubRawRequest,
12
+ hubRequest,
13
+ parseArgs,
14
+ query,
15
+ readConfig,
16
+ requestHash,
17
+ requireFlag,
18
+ secretFromAws,
19
+ storeSecret,
20
+ writeConfig,
21
+ } from "./core.js";
22
+ import {
23
+ DEFAULT_AUTH_URL,
24
+ DEFAULT_CLIENT_ID,
25
+ DEFAULT_EDGE_URL,
26
+ PLATFORM_TENANT,
27
+ deviceLogin,
28
+ revokeOAuth,
29
+ } from "./oauth.js";
30
+ import { postHook, preHook, settingsSnippet } from "./hooks.js";
31
+ import {
32
+ ALIASES,
33
+ GROUPS,
34
+ buildOperationRequest,
35
+ callOperation,
36
+ findFirstClass,
37
+ flagsToOperation,
38
+ loadOpenApi,
39
+ operationsFrom,
40
+ parseParams,
41
+ readBody,
42
+ } from "./api.js";
43
+
44
+ const HELP = `nodii-dev ${VERSION}
45
+
46
+ Usage: nodii-dev <command> [options] [--json]
47
+
48
+ login | logout | whoami | config show
49
+ agent register --name NAME [--service ID] | agent heartbeat
50
+ issues list|show|create|update|claim|release
51
+ inbox list|show
52
+ drift file | request file
53
+ decisions list|get|search
54
+ design entries|entry|guide
55
+ figma lock|check|log-write|submit|apply|published|abandon|release
56
+ hook claude-figma-pre|claude-figma-post|print-settings
57
+
58
+ api list [--tag TAG]
59
+ api <operationId> [--param k=v] [--body @file|-|JSON]
60
+ <resource> <action> [ID ...] [--field VALUE]
61
+
62
+ Resource commands cover every OpenAPI tag, including services, feature-docs,
63
+ contracts, sagas, outbox-topics, tables, libraries, workers, projects, issues,
64
+ backlogs, builds, changes, deploy-reports, runtime, people, agents, inbox,
65
+ gate, design, runbooks, doctrine, changelog, health-verdicts, approval-kinds,
66
+ notification-registry, skills, open-questions, decisions, drift, and requests.
67
+
68
+ Run a command with --help or see the package README for all flags.`;
69
+
70
+ async function stdinText() {
71
+ let value = "";
72
+ for await (const chunk of stdin) value += chunk;
73
+ return value;
74
+ }
75
+ const bodyFlag = async (f, name = "body") =>
76
+ f[`${name}-file`] ? readFile(f[`${name}-file`], "utf8") : f[name];
77
+ function emit(payload, json, human) {
78
+ if (json) console.log(JSON.stringify(payload));
79
+ else
80
+ console.log(
81
+ typeof human === "function"
82
+ ? human(payload)
83
+ : (human ?? JSON.stringify(payload.data ?? payload, null, 2)),
84
+ );
85
+ }
86
+ const qsFlags = (f, mapping) =>
87
+ Object.fromEntries(
88
+ Object.entries(mapping).map(([to, from]) => [to, f[from]]),
89
+ );
90
+
91
+ async function login(flags, json) {
92
+ const previous = await readConfig(false);
93
+ const configuredClientId = flags["client-id"] ?? process.env.NODII_DEV_OAUTH_CLIENT_ID ?? previous?.clientId ?? DEFAULT_CLIENT_ID;
94
+ if (configuredClientId && !flags.hmac) {
95
+ const result = await deviceLogin({
96
+ clientId: configuredClientId,
97
+ authUrl: flags["auth-url"] ?? process.env.NODII_DEV_AUTH_URL ?? DEFAULT_AUTH_URL,
98
+ edgeUrl: flags["edge-url"] ?? process.env.NODII_DEV_EDGE_URL ?? DEFAULT_EDGE_URL,
99
+ tenantHint:
100
+ flags["tenant-hint"] ??
101
+ process.env.NODII_DEV_PLATFORM_TENANT_ID ??
102
+ PLATFORM_TENANT,
103
+ noBrowser: Boolean(flags["no-browser"]),
104
+ scope: flags.scope,
105
+ output: json ? console.error : console.log,
106
+ });
107
+ return emit({ loggedIn: true, provider: "oauth", credentialStorage: result.storage }, json,
108
+ `OAuth login complete; refresh credential stored in ${result.storage}. Run \`nodii-dev agent register --name NAME\` next.`);
109
+ }
110
+ const hubUrl = flags["hub-url"] ?? DEFAULT_HUB_URL;
111
+ const agentId = flags["agent-id"] ?? OPERATOR_AGENT_ID;
112
+ const person = requireFlag(flags, "person");
113
+ if (flags["secret-stdin"] && flags["from-aws"])
114
+ throw new CliError(
115
+ "USAGE",
116
+ "Choose either --secret-stdin or --from-aws, not both.",
117
+ 2,
118
+ );
119
+ if (
120
+ !flags["secret-stdin"] &&
121
+ !flags["from-aws"] &&
122
+ !process.env.PLANNING_HUB_HMAC_SECRET
123
+ )
124
+ throw new CliError(
125
+ "USAGE",
126
+ "Provide --secret-stdin, --from-aws, or PLANNING_HUB_HMAC_SECRET.",
127
+ 2,
128
+ );
129
+ const secret = flags["secret-stdin"]
130
+ ? (await stdinText()).trim()
131
+ : flags["from-aws"]
132
+ ? await secretFromAws()
133
+ : process.env.PLANNING_HUB_HMAC_SECRET;
134
+ if (!secret)
135
+ throw new CliError("EMPTY_SECRET", "The supplied secret is empty.", 2);
136
+ const storage = await storeSecret(secret);
137
+ await writeConfig({ hubUrl, agentId, person });
138
+ emit(
139
+ { hubUrl, agentId, person, secretStorage: storage },
140
+ json,
141
+ `Logged in as ${person} (${agentId}); secret stored in ${storage}.`,
142
+ );
143
+ }
144
+
145
+ async function issues(action, args, f, json) {
146
+ if (action === "list") {
147
+ const payload = await hubRequest(
148
+ "GET",
149
+ `/api/v1/issues${query(qsFlags(f, { serviceId: "service", project: "project", status: "status", kind: "kind", priority: "priority", claimed: "claimed", limit: "limit", cursor: "cursor" }))}`,
150
+ );
151
+ return emit(
152
+ payload,
153
+ json,
154
+ (p) =>
155
+ p.data
156
+ .map((x) => `${x.key}\t${x.status}\t${x.priority}\t${x.title}`)
157
+ .join("\n") || "No issues.",
158
+ );
159
+ }
160
+ const ref = args[0];
161
+ if (!ref && action !== "create")
162
+ throw new CliError("USAGE", `${action} needs an issue key or id`, 2);
163
+ if (action === "show")
164
+ return emit(await hubRequest("GET", `/api/v1/issues/${encode(ref)}`), json);
165
+ const config = await readConfig();
166
+ if (action === "create" || action === "update") {
167
+ const bodyMd = await bodyFlag(f);
168
+ const payload = {
169
+ ...(action === "create"
170
+ ? {
171
+ serviceId: requireFlag(f, "service"),
172
+ title: requireFlag(f, "title"),
173
+ }
174
+ : {}),
175
+ ...Object.fromEntries(
176
+ [
177
+ ["title", f.title],
178
+ ["kind", f.kind],
179
+ ["priority", f.priority],
180
+ ["project", f.project],
181
+ ["bodyMd", bodyMd],
182
+ ["onBehalfOf", config.person],
183
+ ].filter(([, v]) => v !== undefined),
184
+ ),
185
+ };
186
+ const result = await hubRequest(
187
+ action === "create" ? "POST" : "PATCH",
188
+ action === "create" ? "/api/v1/issues" : `/api/v1/issues/${encode(ref)}`,
189
+ payload,
190
+ );
191
+ return emit(
192
+ result,
193
+ json,
194
+ (p) => `${p.data.key} ${p.data.status} — ${p.data.title}`,
195
+ );
196
+ }
197
+ if (action === "claim")
198
+ return emit(
199
+ await hubRequest("POST", `/api/v1/issues/${encode(ref)}/claim`, {
200
+ onBehalfOf: config.person,
201
+ ...(f.ttl ? { ttlMinutes: Number(f.ttl) } : {}),
202
+ }),
203
+ json,
204
+ );
205
+ if (action === "release")
206
+ return emit(
207
+ await hubRequest("POST", `/api/v1/issues/${encode(ref)}/release`, {
208
+ onBehalfOf: config.person,
209
+ ...(f.note ? { note: f.note } : {}),
210
+ }),
211
+ json,
212
+ );
213
+ throw new CliError("USAGE", `Unknown issues action: ${action}`, 2);
214
+ }
215
+
216
+ async function main(argv = process.argv.slice(2)) {
217
+ if (argv.length === 1 && ["--version", "-v"].includes(argv[0]))
218
+ return console.log(VERSION);
219
+ if (argv.length === 1 && ["--help", "-h"].includes(argv[0]))
220
+ return console.log(HELP);
221
+ const { args, flags: f } = parseArgs(argv);
222
+ const json = Boolean(f.json);
223
+ const [command, action, ...rest] = args;
224
+ if (!command || f.help || command === "help") return console.log(HELP);
225
+ if (command === "api") {
226
+ const operations = operationsFrom(await loadOpenApi());
227
+ if (action === "list") {
228
+ const selected = f.tag === undefined || f.tag === true
229
+ ? operations
230
+ : operations.filter((x) => x.tags.some((t) => t.toLowerCase() === String(f.tag).toLowerCase()));
231
+ return emit(selected.map(({ operationId, method, path, tags }) => ({ operationId, method, path, tags })), json,
232
+ (rows) => rows.map((x) => `${x.operationId}\t${x.method}\t${x.path}\t${x.tags.join(",")}`).join("\n"));
233
+ }
234
+ if (!action) throw new CliError("USAGE", "api needs an operationId or list", 2);
235
+ const entry = operations.find((x) => x.operationId === action);
236
+ if (!entry) throw new CliError("UNKNOWN_OPERATION", `No OpenAPI operationId: ${action}`, 2);
237
+ const parsed = await readBody(f.body);
238
+ const request = buildOperationRequest(entry, parseParams(f.param), parsed?.value);
239
+ return emit(await hubRawRequest(request.method, request.path, parsed?.text), json);
240
+ }
241
+ if (command === "login") return login(f, json);
242
+ if (command === "logout") {
243
+ const c = await readConfig(false);
244
+ if (c?.authProvider === "oauth") {
245
+ try { await revokeOAuth(c); } finally { await deleteCredentials(); }
246
+ } else await deleteCredentials();
247
+ return emit({ loggedOut: true }, json, "Logged out.");
248
+ }
249
+ if (command === "config" && action === "show") {
250
+ const c = await readConfig();
251
+ if (c.authProvider === "oauth")
252
+ return emit({ provider: "oauth", authUrl: c.authUrl, edgeUrl: c.edgeUrl, clientId: c.clientId, tenantHint: c.tenantHint, agentId: c.agentId }, json,
253
+ `${c.edgeUrl}\nprovider: oauth\napp: ${c.clientId}\nagent: ${c.agentId ?? "not registered"}`);
254
+ return emit(
255
+ c,
256
+ json,
257
+ `${c.hubUrl}\nagent: ${c.agentId}\nperson: ${c.person}`,
258
+ );
259
+ }
260
+ if (command === "whoami") {
261
+ const c = await readConfig();
262
+ if (c.authProvider === "oauth") {
263
+ try { return emit(await hubRequest("GET", "/api/v1/whoami"), json); }
264
+ catch (error) {
265
+ if (!["HTTP_404", "NOT_FOUND"].includes(error.code)) throw error;
266
+ return emit({ data: { person: c.person, app: c.clientId, installation: c.installationId, agent: c.agentId } }, json);
267
+ }
268
+ }
269
+ const remote = await hubRequest(
270
+ "GET",
271
+ `/api/v1/agents/${encode(c.agentId)}`,
272
+ );
273
+ return emit({ data: { ...c, agent: remote.data } }, json);
274
+ }
275
+ if (command === "state" && action === "diff") {
276
+ const [health, services, decisions, docs, requests, drift] = await Promise.all([
277
+ hubRequest("GET", "/api/v1/health"), hubRequest("GET", "/api/v1/services"),
278
+ hubRequest("GET", "/api/v1/decisions"), hubRequest("GET", "/api/v1/feature-docs"),
279
+ hubRequest("GET", "/api/v1/requests"), hubRequest("GET", "/api/v1/drift"),
280
+ ]);
281
+ const count = (x) => Array.isArray(x.data) ? x.data.length : undefined;
282
+ return emit({ data: { health: health.data ?? health, counts: { services: count(services), decisions: count(decisions), featureDocs: count(docs), requests: count(requests), drift: count(drift) } } }, json);
283
+ }
284
+ if (command === "heartbeat" && action === "audit") {
285
+ const ids = rest[0] ? [rest[0]] : (await hubRequest("GET", "/api/v1/services?limit=1000")).data.map((x) => x.id);
286
+ const rows = await Promise.all(ids.map(async (id) => ({ serviceId: id, ...(await hubRequest("GET", `/api/v1/services/${encode(id)}/unblock-surface`)).data })));
287
+ const failed = rows.some((x) => (x.openDriftCount ?? 0) > 0 || (x.openInboxRequestCount ?? 0) > 0);
288
+ emit({ data: rows, clean: !failed }, json, (p) => p.data.map((x) => `${x.serviceId}\t${(x.openDriftCount ?? 0) || (x.openInboxRequestCount ?? 0) ? "ATTENTION" : "PASS"}\tdrift=${x.openDriftCount ?? 0}\tinbox=${x.openInboxRequestCount ?? 0}`).join("\n"));
289
+ if (failed) process.exitCode = 1;
290
+ return;
291
+ }
292
+ if (command === "agents" && action === "spawn") {
293
+ const serviceId = rest[0] ?? requireFlag(f, "service");
294
+ const docs = await hubRequest("GET", `/api/v1/feature-docs${query({ serviceId, status: "approved", limit: 200 })}`);
295
+ if (!docs.data?.length) throw new CliError("NO_APPROVED_WORK", `No approved feature docs for ${serviceId}`, 1);
296
+ const repo = `${f["repo-root"] ?? `${process.env.HOME}/Projects`}/${serviceId}`;
297
+ return emit({ data: { serviceId, repo, approvedFeatureDocs: docs.data.length, next: `cd ${repo} && nodii-dev agent register --name <name> --service ${serviceId}` } }, json);
298
+ }
299
+ if (command === "agents" && action === "cowork-tick") {
300
+ if (f.plist) {
301
+ const interval = Number(f.interval ?? 1800);
302
+ return console.log(`<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict><key>Label</key><string>com.nodii.cowork</string><key>ProgramArguments</key><array><string>nodii-dev</string><string>agents</string><string>cowork-tick</string></array><key>StartInterval</key><integer>${interval}</integer><key>RunAtLoad</key><true/></dict></plist>`);
303
+ }
304
+ const docs = await hubRequest("GET", "/api/v1/feature-docs?status=implementing&limit=1000");
305
+ const claims = docs.data.filter((x) => x.claimedByAgentId && (!x.claimExpiresAt || Date.parse(x.claimExpiresAt) > Date.now()));
306
+ return emit({ data: claims, dryRun: true, note: "Wakeup execution is workstation-local and has no hub endpoint; this tick reports eligible claims without invoking an external agent runtime." }, json,
307
+ (p) => p.data.map((x) => `${x.serviceId}/${x.docKey}\t${x.claimedByAgentId}`).join("\n") || "No active claims.");
308
+ }
309
+ if (command === "agent") {
310
+ const c = await readConfig();
311
+ if (action === "register") {
312
+ const result = await hubRequest("POST", c.authProvider === "oauth" ? "/api/v1/agents/register" : "/api/v1/agents", {
313
+ sessionId: f.session ?? crypto.randomUUID(),
314
+ serviceId: f.service ?? "nodii-planning-hub",
315
+ name: requireFlag(f, "name"),
316
+ ...(c.authProvider === "oauth" ? {} : { personId: c.person }),
317
+ });
318
+ await writeConfig({ ...c, agentId: result.data.id });
319
+ await hubRequest(
320
+ "POST",
321
+ `/api/v1/agents/${encode(result.data.id)}/heartbeat`,
322
+ {},
323
+ );
324
+ return emit(
325
+ result,
326
+ json,
327
+ `Registered ${result.data.name}: ${result.data.id}`,
328
+ );
329
+ }
330
+ if (action === "heartbeat")
331
+ return emit(
332
+ await hubRequest(
333
+ "POST",
334
+ `/api/v1/agents/${encode(c.agentId)}/heartbeat`,
335
+ {},
336
+ ),
337
+ json,
338
+ "Heartbeat recorded.",
339
+ );
340
+ }
341
+ if (command === "issues") return issues(action, rest, f, json);
342
+ if (command === "inbox") {
343
+ if (action === "list")
344
+ return emit(
345
+ await hubRequest(
346
+ "GET",
347
+ `/api/v1/inbox${query({ serviceId: f.service, kind: f.kind, limit: f.limit, cursor: f.cursor })}`,
348
+ ),
349
+ json,
350
+ );
351
+ if (action === "show") {
352
+ const [kind, id] = rest;
353
+ if (!kind || !id)
354
+ throw new CliError(
355
+ "USAGE",
356
+ "inbox show needs <drift|request|question> <id>",
357
+ 2,
358
+ );
359
+ return emit(
360
+ await hubRequest(
361
+ "GET",
362
+ `/api/v1/${kind === "question" ? "open-questions" : `${kind}s`}/${encode(id)}`,
363
+ ),
364
+ json,
365
+ );
366
+ }
367
+ }
368
+ if (command === "drift" && action === "file") {
369
+ const service = requireFlag(f, "service");
370
+ const bodyMd = await bodyFlag(f);
371
+ return emit(
372
+ await hubRequest("POST", `/api/v1/services/${encode(service)}/drift`, {
373
+ severity: requireFlag(f, "severity"),
374
+ summary: requireFlag(f, "summary"),
375
+ bodyMd: bodyMd ?? "",
376
+ relatedArtifactType: f["related-artifact-type"] ?? null,
377
+ relatedArtifactId: f["related-artifact-id"] ?? null,
378
+ }),
379
+ json,
380
+ );
381
+ }
382
+ if (command === "request" && action === "file") {
383
+ const specMd = f["spec-file"]
384
+ ? await readFile(f["spec-file"], "utf8")
385
+ : requireFlag(f, "spec");
386
+ const body = {
387
+ fromService: requireFlag(f, "from"),
388
+ toService: requireFlag(f, "to"),
389
+ kind: requireFlag(f, "kind"),
390
+ specMd,
391
+ requestHash: requestHash({ toService: f.to, kind: f.kind, specMd }),
392
+ ...(f["required-capability"]
393
+ ? { requiredCapability: f["required-capability"] }
394
+ : {}),
395
+ targetArtifactType: f["target-artifact-type"] ?? null,
396
+ targetArtifactId: f["target-artifact-id"] ?? null,
397
+ };
398
+ return emit(await hubRequest("POST", "/api/v1/requests", body), json);
399
+ }
400
+ if (command === "requests" && action === "file") {
401
+ const specMd = f["spec-file"] ? await readFile(f["spec-file"], "utf8") : requireFlag(f, "spec");
402
+ f.fromService = requireFlag(f, "from"); f.toService = requireFlag(f, "to"); f.specMd = specMd;
403
+ f.requestHash = requestHash({ toService: f.toService, kind: requireFlag(f, "kind"), specMd });
404
+ }
405
+ if (command === "decisions") {
406
+ if (action === "get")
407
+ return emit(
408
+ await hubRequest("GET", `/api/v1/decisions/${encode(rest[0])}`),
409
+ json,
410
+ );
411
+ const q = action === "search" ? rest.join(" ") || f.q : undefined;
412
+ const payload = await hubRequest(
413
+ "GET",
414
+ `/api/v1/decisions${query({ status: f.status, module: f.module, tag: f.tag, limit: f.limit ?? 200 })}`,
415
+ );
416
+ if (q)
417
+ payload.data = payload.data.filter((x) =>
418
+ `${x.id} ${x.title} ${(x.topicTags ?? []).join(" ")}`
419
+ .toLowerCase()
420
+ .includes(q.toLowerCase()),
421
+ );
422
+ return emit(
423
+ payload,
424
+ json,
425
+ (p) =>
426
+ p.data.map((x) => `${x.id}\t${x.status}\t${x.title}`).join("\n") ||
427
+ "No decisions.",
428
+ );
429
+ }
430
+ if (command === "design") {
431
+ if (action === "entries")
432
+ return emit(
433
+ await hubRequest(
434
+ "GET",
435
+ `/api/v1/design/entries${query({ kind: f.kind, group: f.group, q: f.q, limit: f.limit })}`,
436
+ ),
437
+ json,
438
+ );
439
+ if (action === "entry")
440
+ return emit(
441
+ await hubRequest("GET", `/api/v1/design/entries/${encode(rest[0])}`),
442
+ json,
443
+ );
444
+ if (action === "guide")
445
+ return emit(
446
+ await hubRequest("GET", `/api/v1/design/guides/${encode(rest[0])}`),
447
+ json,
448
+ );
449
+ }
450
+ if (command === "figma") {
451
+ if (action === "lock")
452
+ return emit(
453
+ await hubRequest(
454
+ "POST",
455
+ `/api/v1/design/files/${encode(rest[0])}/lock`,
456
+ { changeSetTitle: requireFlag(f, "title") },
457
+ ),
458
+ json,
459
+ );
460
+ if (action === "check") {
461
+ const c = await readConfig();
462
+ const result = await hubRequest(
463
+ "GET",
464
+ `/api/v1/design/files/${encode(rest[0])}/lock/check?agentId=${encode(c.agentId)}`,
465
+ );
466
+ emit(
467
+ result,
468
+ json,
469
+ (p) =>
470
+ `${p.data.allowed ? "allowed" : "denied"}: ${p.data.reason ?? "lock held"}`,
471
+ );
472
+ if (!result.data.allowed) process.exitCode = 3;
473
+ return;
474
+ }
475
+ if (action === "log-write")
476
+ return emit(
477
+ await hubRequest(
478
+ "POST",
479
+ `/api/v1/design/change-sets/${encode(rest[0])}/writes`,
480
+ {
481
+ tool: requireFlag(f, "tool"),
482
+ nodeIds: requireFlag(f, "nodes").split(",").filter(Boolean),
483
+ },
484
+ ),
485
+ json,
486
+ );
487
+ if (["submit", "apply", "published", "abandon"].includes(action)) {
488
+ const payload = f.file
489
+ ? JSON.parse(await readFile(f.file, "utf8"))
490
+ : action === "abandon"
491
+ ? {}
492
+ : { figmaVersion: requireFlag(f, "figma-version") };
493
+ return emit(
494
+ await hubRequest(
495
+ "POST",
496
+ `/api/v1/design/change-sets/${encode(rest[0])}/${action}`,
497
+ payload,
498
+ ),
499
+ json,
500
+ );
501
+ }
502
+ if (action === "release")
503
+ return emit(
504
+ await hubRequest(
505
+ "POST",
506
+ `/api/v1/design/files/${encode(rest[0])}/lock/release`,
507
+ {},
508
+ ),
509
+ json,
510
+ );
511
+ }
512
+ if (command === "hook") {
513
+ if (action === "print-settings")
514
+ return console.log(JSON.stringify(settingsSnippet(), null, 2));
515
+ if (action === "claude-figma-pre") {
516
+ // Fail closed: Claude Code only blocks a tool call on exit code 2, and
517
+ // treats any other non-zero exit as a non-blocking error that lets the
518
+ // write through. Every failure here (bad stdin, missing config, hub
519
+ // error, lock denied) must therefore exit 2.
520
+ try {
521
+ const payload = JSON.parse(await stdinText());
522
+ return emit(await preHook(payload), json, "allowed");
523
+ } catch (error) {
524
+ throw new CliError(
525
+ error?.code ?? "FIGMA_HOOK_FAILED",
526
+ `Blocked Figma write: ${error?.message ?? String(error)}`,
527
+ 2,
528
+ error?.details,
529
+ );
530
+ }
531
+ }
532
+ const payload = JSON.parse(await stdinText());
533
+ if (action === "claude-figma-post") {
534
+ try {
535
+ return emit(await postHook(payload), json, "logged");
536
+ } catch (error) {
537
+ console.error(
538
+ `nodii-dev post hook: ${error.code ?? "ERROR"}: ${error.message}`,
539
+ );
540
+ return;
541
+ }
542
+ }
543
+ }
544
+ if (Object.hasOwn(GROUPS, command)) {
545
+ const operations = operationsFrom(await loadOpenApi());
546
+ let entry = findFirstClass(operations, command, action);
547
+ if (command === "drift" && action === "resolve" && f.status === "accepted-as-canonical")
548
+ entry = operations.find((x) => x.operationId === "acceptDrift");
549
+ if (command === "contracts") {
550
+ const [kind, verb, ...ids] = [action, ...rest];
551
+ const type = { grpc: "GrpcInterface", rest: "RestInterface", outbox: "OutboxTopic", saga: "Saga", table: "Table" }[kind];
552
+ const op = verb === "publish" ? `create${type}` : verb === "status" && f.status === "implemented" ? `implement${type}` : null;
553
+ if (!op) throw new CliError("USAGE", "contracts: use <grpc|rest|outbox|saga|table> publish|status; status currently supports --status implemented", 2);
554
+ entry = operations.find((x) => x.operationId === op);
555
+ rest.splice(0, rest.length, ...ids);
556
+ }
557
+ if (!entry) {
558
+ const tags = Array.isArray(GROUPS[command]) ? GROUPS[command] : [GROUPS[command]];
559
+ const available = operations.filter((x) => x.tags.some((t) => tags.includes(t))).map((x) => x.operationId).join(", ");
560
+ throw new CliError("USAGE", `Unknown ${command} action: ${action}. OpenAPI operations: ${available}`, 2);
561
+ }
562
+ const built = flagsToOperation(entry, rest, f);
563
+ if (command === "requests" && action === "answer")
564
+ Object.assign(built.params, { kind: "request", id: rest[0] });
565
+ if (command === "drift" && entry.operationId === "acceptDrift" && !built.body?.relatedDecisionId && !built.body?.newDecision)
566
+ throw new CliError("USAGE", "accepted-as-canonical drift requires --related-decision-id or --new-decision JSON", 2);
567
+ const explicit = await readBody(f.body);
568
+ return emit(await callOperation(entry, built.params, explicit?.value ?? built.body), json);
569
+ }
570
+ throw new CliError(
571
+ "USAGE",
572
+ `Unknown command: ${[command, action].filter(Boolean).join(" ")}`,
573
+ 2,
574
+ );
575
+ }
576
+
577
+ main().catch((error) => {
578
+ const e =
579
+ error instanceof CliError
580
+ ? error
581
+ : new CliError("UNEXPECTED", error?.message ?? String(error));
582
+ console.error(
583
+ `${e.code}: ${e.message}${e.details ? `\n${JSON.stringify(e.details)}` : ""}`,
584
+ );
585
+ process.exitCode = e.exitCode;
586
+ });