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