@theagilemonkeys/facility 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.
Files changed (75) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +68 -0
  3. package/bin/facility.mjs +10 -0
  4. package/modules/README.md +35 -0
  5. package/modules/ai-queryability/agents/queryability-reviewer.md +35 -0
  6. package/modules/ai-queryability/module.json +9 -0
  7. package/modules/ai-queryability/standard-section.md +22 -0
  8. package/modules/analytics/agents/analytics-reviewer.md +32 -0
  9. package/modules/analytics/commands/add-telemetry.md +23 -0
  10. package/modules/analytics/module.json +10 -0
  11. package/modules/analytics/standard-section.md +23 -0
  12. package/modules/database/agents/data-security-reviewer.md +38 -0
  13. package/modules/database/commands/new-migration.md +24 -0
  14. package/modules/database/guards/migration-versions.mjs +41 -0
  15. package/modules/database/guards/migrations-immutable.mjs +57 -0
  16. package/modules/database/hooks/protect-migrations.fragment.mjs +10 -0
  17. package/modules/database/module.json +25 -0
  18. package/modules/database/standard-section.md +20 -0
  19. package/modules/design-system/agents/design-reviewer.md +37 -0
  20. package/modules/design-system/module.json +9 -0
  21. package/modules/design-system/standard-section.md +15 -0
  22. package/package.json +42 -0
  23. package/src/add.mjs +77 -0
  24. package/src/cli.mjs +352 -0
  25. package/src/detect.mjs +127 -0
  26. package/src/doctor.mjs +582 -0
  27. package/src/init.mjs +572 -0
  28. package/src/instance.mjs +114 -0
  29. package/src/platform-admin.mjs +1542 -0
  30. package/src/platform-config.mjs +39 -0
  31. package/src/platform.mjs +1759 -0
  32. package/src/prompts.mjs +64 -0
  33. package/src/render.mjs +66 -0
  34. package/src/ui.mjs +30 -0
  35. package/templates/claude/agents/security-reviewer.md +41 -0
  36. package/templates/claude/agents/standards-reviewer.md +31 -0
  37. package/templates/claude/commands/open-pr.md +21 -0
  38. package/templates/claude/commands/verify.md +16 -0
  39. package/templates/claude/hooks/protect-branch.mjs +58 -0
  40. package/templates/claude/hooks/protect-files.mjs +35 -0
  41. package/templates/claude/settings.json +71 -0
  42. package/templates/claude/skills/maintainable-software/SKILL.md +67 -0
  43. package/templates/claude/skills/reviewing-to-standard/SKILL.md +49 -0
  44. package/templates/claude/skills/working-to-standard/SKILL.md +45 -0
  45. package/templates/delivery/verify.mjs +157 -0
  46. package/templates/doctor/resolve.mjs +144 -0
  47. package/templates/guards/README.md +30 -0
  48. package/templates/guards/_kit.mjs +81 -0
  49. package/templates/guards/actions-pinned.mjs +38 -0
  50. package/templates/guards/run.mjs +111 -0
  51. package/templates/guards/watchtower-locked.mjs +66 -0
  52. package/templates/prompts/address-review.md +14 -0
  53. package/templates/prompts/architect.md +62 -0
  54. package/templates/prompts/builder.md +71 -0
  55. package/templates/prompts/doctor.md +64 -0
  56. package/templates/prompts/review.md +14 -0
  57. package/templates/prompts/sweep.md +75 -0
  58. package/templates/receipts/collect.mjs +289 -0
  59. package/templates/review/finalize.mjs +38 -0
  60. package/templates/scripts/move-board-status.sh +155 -0
  61. package/templates/security/sync-findings.mjs +226 -0
  62. package/templates/standard/STANDARD.md +141 -0
  63. package/templates/standard/agents-block.md +25 -0
  64. package/templates/watchtower/budgets.json +12 -0
  65. package/templates/watchtower/canary.mjs +216 -0
  66. package/templates/watchtower/health.mjs +148 -0
  67. package/templates/watchtower/outcomes.mjs +188 -0
  68. package/templates/workflows/facility-address-review.yml +153 -0
  69. package/templates/workflows/facility-canary.yml +61 -0
  70. package/templates/workflows/facility-codex.yml +326 -0
  71. package/templates/workflows/facility-crew.yml +350 -0
  72. package/templates/workflows/facility-doctor.yml +155 -0
  73. package/templates/workflows/facility-review.yml +134 -0
  74. package/templates/workflows/facility-security-sweep.yml +204 -0
  75. package/templates/workflows/facility-watchtower.yml +87 -0
@@ -0,0 +1,1759 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { stdin as processStdin, stdout as processStdout } from "node:process";
3
+ import { Writable } from "node:stream";
4
+ import { loadConfig, saveConfig, getProfile } from "./platform-config.mjs";
5
+ import {
6
+ ADMIN_GROUPS,
7
+ runAdminCommand,
8
+ validateAdminFlags,
9
+ validateAdminSubcommandFlags,
10
+ } from "./platform-admin.mjs";
11
+ import { accent, bold, dim, green, yellow } from "./ui.mjs";
12
+
13
+ class CliError extends Error {
14
+ constructor(message, exitCode = 1, options = {}) {
15
+ super(message);
16
+ this.exitCode = exitCode;
17
+ this.code = options.code;
18
+ this.status = options.status;
19
+ this.details = options.details;
20
+ }
21
+ }
22
+
23
+ export async function runPlatformCommand(command, args, options = {}) {
24
+ const stdout = options.stdout || process.stdout;
25
+ const stderr = options.stderr || process.stderr;
26
+ const jsonMode = args.includes("--json") || args.some((arg) => arg.startsWith("--json="));
27
+ try {
28
+ const { flags, positional } = parseArgs(args);
29
+ assertPresenceFlagSyntax(flags);
30
+ const env = options.env || process.env;
31
+ const configPath = options.configPath || env.FACILITY_CONFIG;
32
+ const config = options.config || loadConfig(configPath);
33
+ const ctx = {
34
+ fetch: options.fetch || fetch,
35
+ stdout,
36
+ stderr,
37
+ stdin: options.stdin || process.stdin,
38
+ sleep: options.sleep || delay,
39
+ env,
40
+ timeoutMs: timeoutMilliseconds(flags.timeout),
41
+ config,
42
+ configPath,
43
+ json: Boolean(flags.json),
44
+ profileName: flags.profile,
45
+ };
46
+
47
+ assertCommandFlags(command, flags);
48
+ assertCoreSubcommandFlags(command, positional, flags);
49
+ if (flags.help) return platformHelp(command, stdout);
50
+ if (command === "login") return await login(flags, ctx);
51
+ if (command === "logout") return logout(flags, ctx);
52
+ if (command === "profiles") return profiles(positional, flags, ctx);
53
+ if (ADMIN_GROUPS.has(command)) {
54
+ validateAdminFlags(command, flags);
55
+ validateAdminSubcommandFlags(command, positional, flags);
56
+ }
57
+ const [group, ...rest] = [command, ...positional];
58
+ const authed = clientContext(ctx);
59
+ authed.api = (method, path, requestOptions = {}) =>
60
+ api(
61
+ authed,
62
+ method,
63
+ path,
64
+ method === "POST" && flags["idempotency-key"] !== undefined
65
+ ? {
66
+ ...requestOptions,
67
+ idempotencyKey:
68
+ requestOptions.idempotencyKey ??
69
+ flagString(flags["idempotency-key"], "--idempotency-key"),
70
+ }
71
+ : requestOptions,
72
+ );
73
+ authed.resolveProject = (value) => resolveProject(authed, value);
74
+ authed.writeJson = (value) => writeJson(authed, value);
75
+ authed.table = (headers, rows, tableOptions) => table(authed, headers, rows, tableOptions);
76
+
77
+ if (ADMIN_GROUPS.has(group)) return await runAdminCommand(group, rest, authed, flags);
78
+
79
+ switch (group) {
80
+ case "status":
81
+ return await status(authed, flags);
82
+ case "projects":
83
+ return await projects(rest, authed, flags);
84
+ case "sessions":
85
+ case "runs": // Compatibility alias; API/storage keep /runs and runs.* permissions.
86
+ return await sessions(rest, authed, flags, group);
87
+ case "inbox":
88
+ return await inbox(rest, authed, flags);
89
+ case "issues":
90
+ return await issues(rest, authed, flags);
91
+ case "kickstart":
92
+ return await kickstart(rest, authed, flags, ctx);
93
+ case "upgrade":
94
+ return await upgrade(rest, authed, flags);
95
+ case "keys":
96
+ return await keys(rest, authed, flags);
97
+ case "llm-requests":
98
+ return await llmRequests(rest, authed, flags);
99
+ default:
100
+ throw new CliError(`Unknown platform command: ${group}`, 1);
101
+ }
102
+ } catch (error) {
103
+ const exitCode = error.exitCode || (error.status === 401 ? 2 : 1);
104
+ const message = error.message || "Facility command failed";
105
+ if (jsonMode) {
106
+ stdout.write(
107
+ `${JSON.stringify({
108
+ error: {
109
+ code: error.code || (error.status === 401 ? "unauthorized" : "cli_error"),
110
+ message,
111
+ ...(error.status ? { status: error.status } : {}),
112
+ ...(error.details === undefined ? {} : { details: error.details }),
113
+ },
114
+ })}\n`,
115
+ );
116
+ } else {
117
+ stderr.write(`${message}\n`);
118
+ const details = humanErrorDetails(error.details);
119
+ if (details) stderr.write(`${details}\n`);
120
+ }
121
+ return exitCode;
122
+ }
123
+ }
124
+
125
+ function parseArgs(args) {
126
+ const flags = {};
127
+ const positional = [];
128
+ for (let index = 0; index < args.length; index += 1) {
129
+ const arg = args[index];
130
+ if (arg === "--json") flags.json = true;
131
+ else if (arg === "--yes" || arg === "-y") flags.yes = true;
132
+ else if (arg.startsWith("--") && arg.includes("=")) {
133
+ const [key, ...rest] = arg.slice(2).split("=");
134
+ flags[key] = rest.join("=");
135
+ } else if (arg.startsWith("--")) {
136
+ const key = arg.slice(2);
137
+ const next = args[index + 1];
138
+ if (next && (!next.startsWith("-") || /^-\d/.test(next))) {
139
+ flags[key] = next;
140
+ index += 1;
141
+ } else flags[key] = true;
142
+ } else positional.push(arg);
143
+ }
144
+ return { flags, positional };
145
+ }
146
+
147
+ async function login(flags, ctx) {
148
+ let url = flagString(flags.url, "--url") || ctx.env.FACILITY_URL;
149
+ let key = flagString(flags.key, "--key") || ctx.env.FACILITY_API_KEY;
150
+ const profile = flagString(flags.profile, "--profile") || "default";
151
+ if (!url || !key) {
152
+ if (ctx.json) {
153
+ throw new CliError(
154
+ "facility login --json requires --url and --key (or FACILITY_URL and FACILITY_API_KEY).",
155
+ 1,
156
+ { code: "credentials_required" },
157
+ );
158
+ }
159
+ if (ctx.stdin?.isTTY) {
160
+ if (!url) url = await prompt("API URL", ctx);
161
+ if (!key) key = await promptSecret("API key", ctx);
162
+ } else {
163
+ const answers = await readPipedAnswers(ctx.stdin);
164
+ if (!url) url = answers.shift();
165
+ if (!key) key = answers.shift();
166
+ if (!url || !key) throw promptEof();
167
+ }
168
+ }
169
+ assertSafeApiUrl(url, Boolean(flags["allow-insecure"]));
170
+ const me = await request({ url, key, fetch: ctx.fetch }, "GET", "/v1/me");
171
+ const next = {
172
+ ...ctx.config,
173
+ currentProfile: profile,
174
+ profiles: {
175
+ ...(ctx.config.profiles || {}),
176
+ [profile]: {
177
+ url: stripSlash(url),
178
+ key,
179
+ ...(flags["allow-insecure"] ? { allowInsecure: true } : {}),
180
+ },
181
+ },
182
+ };
183
+ saveConfig(next, ctx.configPath || ctx.config.path);
184
+ if (ctx.json) writeJson(ctx, { profile, org: me.org, principal: me.principal });
185
+ else ctx.stdout.write(` ${bold("login")} ${dim(profile)} verified for ${me.org?.slug || me.org?.name || "Facility"}\n`);
186
+ return 0;
187
+ }
188
+
189
+ function logout(flags, ctx) {
190
+ const profile = flags.profile || ctx.config.currentProfile || "default";
191
+ if (!ctx.config.profiles?.[profile]) {
192
+ throw new CliError(`Profile not found: ${profile}`, 1, { code: "profile_not_found" });
193
+ }
194
+ const remaining = { ...(ctx.config.profiles || {}) };
195
+ delete remaining[profile];
196
+ const nextProfile = Object.keys(remaining)[0] || "default";
197
+ const next = {
198
+ ...ctx.config,
199
+ currentProfile: profile === ctx.config.currentProfile ? nextProfile : ctx.config.currentProfile,
200
+ profiles: remaining,
201
+ };
202
+ saveConfig(next, ctx.configPath || ctx.config.path);
203
+ output(ctx, { profile, loggedOut: true }, () => ` ${bold("logout")} ${dim(profile)} credentials removed\n`);
204
+ return 0;
205
+ }
206
+
207
+ function profiles(args, flags, ctx) {
208
+ const sub = args[0] || "list";
209
+ if (sub === "list") {
210
+ const rows = Object.entries(ctx.config.profiles || {}).map(([name, value]) => ({
211
+ name,
212
+ current: name === ctx.config.currentProfile,
213
+ url: value.url,
214
+ }));
215
+ if (ctx.json) writeJson(ctx, { currentProfile: ctx.config.currentProfile, profiles: rows });
216
+ else table(ctx, ["", "profile", "url"], rows.map((row) => [row.current ? "●" : "", row.name, row.url]));
217
+ return 0;
218
+ }
219
+ if (sub === "use") {
220
+ const name = args[1];
221
+ if (!name) throw usage("facility profiles use <name>");
222
+ if (!ctx.config.profiles?.[name]) {
223
+ throw new CliError(`Profile not found: ${name}`, 1, { code: "profile_not_found" });
224
+ }
225
+ saveConfig({ ...ctx.config, currentProfile: name }, ctx.configPath || ctx.config.path);
226
+ output(ctx, { currentProfile: name }, () => ` ${bold("profile")} ${dim(name)} is now active\n`);
227
+ return 0;
228
+ }
229
+ if (sub === "remove") return logout({ profile: args[1] || flags.profile }, ctx);
230
+ throw usage("facility profiles list|use <name>|remove <name>");
231
+ }
232
+
233
+ async function status(ctx) {
234
+ const projects = await api(ctx, "GET", "/v1/projects");
235
+ const inbox = unwrapInbox(await api(ctx, "GET", "/v1/inbox", { query: { state: "open" } }));
236
+ const issues = await api(ctx, "GET", "/v1/issues", { query: { state: "open" } });
237
+ const spend = await api(ctx, "GET", "/v1/spend", { query: { from: monthStart(), groupBy: "day" } });
238
+ const runs = await allRuns(ctx, "running");
239
+ const payload = {
240
+ projects,
241
+ liveSessions: runs,
242
+ liveSessionsPartial: false,
243
+ // Deprecated JSON aliases for existing automation; human-facing terminology is Sessions.
244
+ liveRuns: runs,
245
+ liveRunsPartial: false,
246
+ inbox,
247
+ issues,
248
+ spend,
249
+ };
250
+ if (ctx.json) writeJson(ctx, payload);
251
+ else {
252
+ ctx.stdout.write(`\n${bold("Facility status")}\n`);
253
+ ctx.stdout.write(row("projects", asArray(projects).length));
254
+ ctx.stdout.write(
255
+ row(
256
+ "live sessions",
257
+ runs.length,
258
+ runs.length > 0,
259
+ ),
260
+ );
261
+ ctx.stdout.write(row("open inbox", asArray(inbox).length));
262
+ ctx.stdout.write(row("open issues", asArray(issues).length));
263
+ ctx.stdout.write(row("spend MTD", cents(sum(asArray(spend), "cost_cents"))));
264
+ }
265
+ return 0;
266
+ }
267
+
268
+ async function projects(args, ctx, flags) {
269
+ const sub = args[0];
270
+ if (sub === "list") {
271
+ const projects = await api(ctx, "GET", "/v1/projects", { query: pageQuery(flags) });
272
+ if (ctx.json) writeJson(ctx, projects);
273
+ else table(ctx, ["slug", "name", "status"], asArray(projects).map((p) => [p.slug, p.name, p.status]));
274
+ return 0;
275
+ }
276
+ if (sub === "get") {
277
+ const project = await resolveProject(ctx, args[1]);
278
+ if (ctx.json) writeJson(ctx, project);
279
+ else table(ctx, ["field", "value"], Object.entries(project).map(([key, value]) => [key, displayValue(value)]));
280
+ return 0;
281
+ }
282
+ if (sub === "create") {
283
+ const name = requiredFlagString(flags.name, "--name");
284
+ const slug = requiredFlagString(flags.slug, "--slug");
285
+ const result = await api(ctx, "POST", "/v1/projects", {
286
+ body: {
287
+ name,
288
+ slug,
289
+ ...(flags.description !== undefined
290
+ ? { description: flagString(flags.description, "--description") }
291
+ : {}),
292
+ ...(flags.settings !== undefined
293
+ ? { settings: parseObject(flags.settings, "--settings") }
294
+ : {}),
295
+ },
296
+ idempotencyKey: flagString(flags["idempotency-key"], "--idempotency-key"),
297
+ });
298
+ output(ctx, result, () => ` ${green("✓")} ${bold("created")} ${result.slug}\n`);
299
+ return 0;
300
+ }
301
+ if (sub === "update") {
302
+ const project = await resolveProject(ctx, args[1]);
303
+ const body = defined({
304
+ name: flagString(flags.name, "--name"),
305
+ description: flagString(flags.description, "--description"),
306
+ status: flagString(flags.status, "--status"),
307
+ settings:
308
+ flags.settings === undefined ? undefined : parseObject(flags.settings, "--settings"),
309
+ });
310
+ if (!Object.keys(body).length) throw usage("facility projects update <project> --name|--description|--status|--settings");
311
+ const result = await api(ctx, "PATCH", `/v1/projects/${project.id}`, { body });
312
+ output(ctx, result, () => ` ${green("✓")} ${bold("updated")} ${result.slug || project.slug}\n`);
313
+ return 0;
314
+ }
315
+ if (sub === "archive" || sub === "delete") {
316
+ const project = await resolveProject(ctx, args[1]);
317
+ requireConfirmation(flags, `Archive project ${project.slug || project.id}`);
318
+ const result = await api(ctx, "DELETE", `/v1/projects/${project.id}`);
319
+ output(ctx, result, () => ` ${yellow("■")} ${bold("archived")} ${project.slug || project.id}\n`);
320
+ return 0;
321
+ }
322
+ throw new CliError("Usage: facility projects list|get|create|update|archive");
323
+ }
324
+
325
+ async function sessions(args, ctx, flags, command = "sessions") {
326
+ const sub = args[0];
327
+ if (sub === "list") {
328
+ const status = runStatus(flags.status);
329
+ let runs;
330
+ let selectedProject;
331
+ const pagination = pageQuery(flags);
332
+ const explicitPage = pagination.limit !== undefined || pagination.offset !== undefined;
333
+ if (flags.project !== undefined) {
334
+ const project = flagString(flags.project, "--project");
335
+ selectedProject = await resolveProject(ctx, project);
336
+ runs = explicitPage
337
+ ? await api(ctx, "GET", `/v1/projects/${selectedProject.id}/runs`, {
338
+ query: { status, ...pagination },
339
+ })
340
+ : await projectRuns(ctx, selectedProject.id, status);
341
+ } else {
342
+ runs = explicitPage
343
+ ? await api(ctx, "GET", "/v1/runs", { query: { status, ...pagination } })
344
+ : await allRuns(ctx, status);
345
+ }
346
+ if (ctx.json) {
347
+ writeJson(
348
+ ctx,
349
+ flags.project
350
+ ? runs
351
+ : { sessions: runs, runs }, // `runs` is a deprecated JSON alias.
352
+ );
353
+ }
354
+ else {
355
+ table(
356
+ ctx,
357
+ ["id", "project", "status", "mode"],
358
+ asArray(runs).map((r) => [
359
+ r.id,
360
+ r.project?.slug ?? selectedProject?.slug ?? r.projectId,
361
+ r.status,
362
+ r.mode,
363
+ ]),
364
+ );
365
+ }
366
+ return 0;
367
+ }
368
+ if (sub === "get") {
369
+ const runId = args[1];
370
+ if (!runId) throw usage(`facility ${command} get <id>`);
371
+ const result = await api(ctx, "GET", `/v1/runs/${runId}`);
372
+ if (ctx.json) writeJson(ctx, result);
373
+ else {
374
+ table(
375
+ ctx,
376
+ ["field", "value"],
377
+ Object.entries(result).map(([key, value]) => [key, displayValue(value)]),
378
+ );
379
+ }
380
+ return 0;
381
+ }
382
+ if (sub === "events") {
383
+ const runId = args[1];
384
+ if (!runId) throw usage(`facility ${command} events <id> [--after-seq <n>] [--tail <n>]`);
385
+ const result = await api(ctx, "GET", `/v1/runs/${runId}/events`, {
386
+ query: {
387
+ afterSeq: numericFlag(flags["after-seq"], "--after-seq"),
388
+ tail: numericFlag(flags.tail, "--tail"),
389
+ limit: pageQuery(flags).limit,
390
+ },
391
+ });
392
+ if (ctx.json) writeJson(ctx, result);
393
+ else {
394
+ table(
395
+ ctx,
396
+ ["seq", "time", "type", "data"],
397
+ asArray(result).map((event) => [
398
+ event.seq,
399
+ event.ts,
400
+ event.type,
401
+ displayValue(event.data),
402
+ ]),
403
+ );
404
+ }
405
+ return 0;
406
+ }
407
+ if (sub === "transcript") {
408
+ const runId = args[1];
409
+ if (!runId) throw usage("facility sessions transcript <id>");
410
+ const transcript = await api(ctx, "GET", `/v1/runs/${runId}/transcript`, {
411
+ responseType: "text",
412
+ });
413
+ if (ctx.json) {
414
+ writeJson(ctx, {
415
+ sessionId: runId,
416
+ events: String(transcript)
417
+ .split("\n")
418
+ .filter(Boolean)
419
+ .map((line) => {
420
+ try {
421
+ return JSON.parse(line);
422
+ } catch {
423
+ return line;
424
+ }
425
+ }),
426
+ });
427
+ } else {
428
+ ctx.stdout.write(String(transcript));
429
+ if (transcript && !String(transcript).endsWith("\n")) ctx.stdout.write("\n");
430
+ }
431
+ return 0;
432
+ }
433
+ if (sub === "trigger") {
434
+ const project = await resolveProject(ctx, args[1]);
435
+ const agent = args[2];
436
+ if (!agent) throw new CliError("Usage: facility sessions trigger <project> <agent> [--input]");
437
+ const input = parseInput(flags.input);
438
+ const result = await api(ctx, "POST", `/v1/projects/${project.id}/runs`, {
439
+ body: { mode: agent, agent, trigger: { source: "cli", agentName: agent, input } },
440
+ idempotencyKey: flagString(flags["idempotency-key"], "--idempotency-key"),
441
+ });
442
+ output(ctx, result, () => ` ${accent("run")} ${result.id || "(queued)"} triggered\n`);
443
+ return 0;
444
+ }
445
+ if (sub === "steer") {
446
+ const runId = args[1];
447
+ const message = args.slice(2).join(" ");
448
+ if (!runId || !message) throw new CliError("Usage: facility sessions steer <id> <message>");
449
+ const result = await api(ctx, "POST", `/v1/runs/${runId}/steer`, {
450
+ body: { body: message },
451
+ idempotencyKey: flagString(flags["idempotency-key"], "--idempotency-key"),
452
+ });
453
+ output(ctx, result, () => ` ${accent("steer")} ${runId}\n`);
454
+ return 0;
455
+ }
456
+ if (sub === "cancel") {
457
+ const runId = args[1];
458
+ if (!runId) throw usage(`facility ${command} cancel <id>`);
459
+ const result = await api(ctx, "POST", `/v1/runs/${runId}/cancel`, {
460
+ idempotencyKey: flagString(flags["idempotency-key"], "--idempotency-key"),
461
+ });
462
+ output(ctx, result, () =>
463
+ result?.status === "canceled"
464
+ ? ` ${yellow("■")} ${bold("canceled")} ${runId}\n`
465
+ : ` ${dim("—")} ${bold("unchanged")} ${runId}${result?.status ? dim(` · already ${result.status}`) : ""}\n`,
466
+ );
467
+ return 0;
468
+ }
469
+ if (sub === "interrupt") {
470
+ const runId = args[1];
471
+ if (!runId) throw usage("facility sessions interrupt <id>");
472
+ const result = await api(ctx, "POST", `/v1/runs/${runId}/interrupt`, {
473
+ idempotencyKey: flagString(flags["idempotency-key"], "--idempotency-key"),
474
+ });
475
+ output(ctx, result, () => ` ${yellow("■")} ${bold("interrupted")} ${runId}\n`);
476
+ return 0;
477
+ }
478
+ if (sub === "resume") {
479
+ const runId = args[1];
480
+ if (!runId) throw usage("facility sessions resume <id> [message]");
481
+ const message = args.slice(2).join(" ") || undefined;
482
+ const result = await api(ctx, "POST", `/v1/runs/${runId}/resume`, {
483
+ body: { message },
484
+ idempotencyKey: flagString(flags["idempotency-key"], "--idempotency-key"),
485
+ });
486
+ output(ctx, result, () => ` ${accent("resume")} ${result.id} from ${runId}\n`);
487
+ return 0;
488
+ }
489
+ if (sub === "watch") return watchRun(ctx, args[1]);
490
+ throw new CliError(
491
+ "Usage: facility sessions list|get|events|transcript|watch|trigger|steer|interrupt|resume|cancel",
492
+ );
493
+ }
494
+
495
+ async function inbox(args, ctx, flags) {
496
+ if (!args.length) {
497
+ const raw = await api(ctx, "GET", "/v1/inbox", {
498
+ query: { state: flags.state || "open", ...pageQuery(flags) },
499
+ });
500
+ const proposals = unwrapInbox(raw);
501
+ const issues = Array.isArray(raw) ? [] : asArray(raw?.issues);
502
+ if (ctx.json) {
503
+ writeJson(ctx, { proposals, issues });
504
+ return 0;
505
+ }
506
+ ctx.stdout.write(` ${bold("gates")}${dim(` · ${asArray(proposals).length}`)}\n`);
507
+ table(ctx, ["id", "state", "action", "project"], asArray(proposals).map((item) => [item.id, item.state, item.actionTypeId, item.projectId]));
508
+ if (issues.length) {
509
+ ctx.stdout.write(`\n ${bold("issues")}${dim(` · ${issues.length} from watchtower`)}\n`);
510
+ table(ctx, ["id", "severity", "kind", "state", "title"], issues.map((item) => [item.id, item.severity, item.kind, item.state, item.title]));
511
+ }
512
+ return 0;
513
+ }
514
+ if (args[0] === "decide") {
515
+ const [id, decision] = [args[1], args[2]];
516
+ if (!id || !["approve", "reject"].includes(decision)) throw new CliError("Usage: facility inbox decide <id> approve|reject [--note]");
517
+ const result = await api(ctx, "POST", `/v1/proposals/${id}/decide`, {
518
+ body: { decision, note: flags.note },
519
+ idempotencyKey: flagString(flags["idempotency-key"], "--idempotency-key"),
520
+ });
521
+ output(ctx, result, () => ` ${bold(decision)} ${id}\n`);
522
+ return 0;
523
+ }
524
+ throw new CliError("Usage: facility inbox [decide <id> approve|reject]");
525
+ }
526
+
527
+ async function issues(args, ctx, flags) {
528
+ const sub = args[0] || "list";
529
+ if (sub === "list") {
530
+ const result = await api(ctx, "GET", "/v1/issues", {
531
+ query: { state: flags.state, kind: flags.kind, ...pageQuery(flags) },
532
+ });
533
+ if (ctx.json) writeJson(ctx, result);
534
+ else table(ctx, ["id", "severity", "kind", "state", "title"], asArray(result).map((i) => [i.id, i.severity, i.kind, i.state, i.title]));
535
+ return 0;
536
+ }
537
+ if (sub === "ack" || sub === "resolve") {
538
+ const id = args[1];
539
+ if (!id) throw new CliError(`Usage: facility issues ${sub} <id>`);
540
+ const result = await api(ctx, "POST", `/v1/issues/${id}/${sub}`, {
541
+ idempotencyKey: flagString(flags["idempotency-key"], "--idempotency-key"),
542
+ });
543
+ output(ctx, result, () => ` ${bold(sub === "ack" ? "acknowledged" : "resolved")} ${id}\n`);
544
+ return 0;
545
+ }
546
+ throw new CliError("Usage: facility issues list|ack <id>|resolve <id>");
547
+ }
548
+
549
+ async function kickstart(args, ctx, flags, rawCtx) {
550
+ assertAllowedFlags(flags, [
551
+ "repo",
552
+ "yes",
553
+ "json",
554
+ "profile",
555
+ "timeout",
556
+ "branch",
557
+ "provision",
558
+ "checks",
559
+ "modules",
560
+ "model",
561
+ "org",
562
+ "board",
563
+ "execution-lane",
564
+ "preview-image",
565
+ "preview-command",
566
+ "preview-port",
567
+ "preview-readiness-path",
568
+ "preview-ttl-hours",
569
+ "idempotency-key",
570
+ ]);
571
+ const project = await resolveProject(ctx, args[0]);
572
+ const repo = requiredFlagString(flags.repo, "--repo");
573
+ const repoId = await resolveRepoId(ctx, project.id, repo);
574
+ const preview = await api(ctx, "GET", `/v1/projects/${project.id}/kickstart/preview`, { query: { repoId } });
575
+ if (ctx.json && !flags.yes) {
576
+ throw new CliError("facility kickstart --json requires --yes to confirm the write", 1, {
577
+ code: "confirmation_required",
578
+ });
579
+ }
580
+ if (!flags.yes && !ctx.json) {
581
+ table(ctx, ["path", "size", "sha256"], asArray(preview.files || preview).map((file) => [file.path, file.size, file.sha256]));
582
+ const answer = await prompt("Open kickstart PR? [y/N]", rawCtx);
583
+ if (!/^y(es)?$/i.test(answer.trim())) throw new CliError("Cancelled", 1);
584
+ }
585
+ const answers = {
586
+ ...(flags.branch !== undefined
587
+ ? { defaultBranch: flagString(flags.branch, "--branch") }
588
+ : {}),
589
+ ...(flags.provision !== undefined
590
+ ? { provisionCmd: flagString(flags.provision, "--provision") }
591
+ : {}),
592
+ ...(flags.checks !== undefined ? { checkCmds: parseList(flags.checks, "--checks") } : {}),
593
+ ...(flags.modules !== undefined ? { modules: parseList(flags.modules, "--modules") } : {}),
594
+ ...(flags.model !== undefined ? { modelTier: flagString(flags.model, "--model") } : {}),
595
+ ...(flags.org !== undefined && flags.board !== undefined
596
+ ? {
597
+ board: {
598
+ org: flagString(flags.org, "--org"),
599
+ project: flagString(flags.board, "--board"),
600
+ },
601
+ }
602
+ : {}),
603
+ ...(flags["execution-lane"]
604
+ ? { execution_lane: parseObject(flags["execution-lane"], "--execution-lane") }
605
+ : {}),
606
+ ...(flags["preview-image"]
607
+ ? {
608
+ preview: {
609
+ enabled: true,
610
+ image: flagString(flags["preview-image"], "--preview-image"),
611
+ ...(flags["preview-command"]
612
+ ? {
613
+ command: [
614
+ "sh",
615
+ "-lc",
616
+ flagString(flags["preview-command"], "--preview-command"),
617
+ ],
618
+ }
619
+ : {}),
620
+ port: Number(flagString(flags["preview-port"] ?? "3000", "--preview-port")),
621
+ ...(flags["preview-readiness-path"]
622
+ ? {
623
+ readinessPath: flagString(
624
+ flags["preview-readiness-path"],
625
+ "--preview-readiness-path",
626
+ ),
627
+ }
628
+ : {}),
629
+ ttlHours: Number(
630
+ flagString(flags["preview-ttl-hours"] ?? "24", "--preview-ttl-hours"),
631
+ ),
632
+ },
633
+ }
634
+ : {}),
635
+ };
636
+ const result = await api(ctx, "POST", `/v1/projects/${project.id}/kickstart`, {
637
+ body: { repoId, answers, mode: "pr" },
638
+ idempotencyKey: flagString(flags["idempotency-key"], "--idempotency-key"),
639
+ });
640
+ output(ctx, result, () => ` ${bold("kickstart")} PR requested for ${repo}\n`);
641
+ return 0;
642
+ }
643
+
644
+ async function upgrade(args, ctx, flags) {
645
+ const project = await resolveProject(ctx, args[0]);
646
+ const repo = requiredFlagString(flags.repo, "--repo");
647
+ const repoId = await resolveRepoId(ctx, project.id, repo);
648
+ const result = await api(ctx, "POST", `/v1/projects/${project.id}/upgrade`, {
649
+ body: { repoId, toVersion: flagString(flags.to, "--to") },
650
+ idempotencyKey: flagString(flags["idempotency-key"], "--idempotency-key"),
651
+ });
652
+ output(ctx, result, () => ` ${bold("upgrade")} requested for ${project.slug || project.id}\n`);
653
+ return 0;
654
+ }
655
+
656
+ async function keys(args, ctx, flags) {
657
+ const sub = args[0];
658
+ if (sub === "list") {
659
+ const result = await api(ctx, "GET", "/v1/keys", { query: pageQuery(flags) });
660
+ if (ctx.json) writeJson(ctx, result);
661
+ else table(ctx, ["id", "name", "last4", "revoked"], asArray(result).map((key) => [key.id, key.name, key.last4, key.revokedAt ? "yes" : "no"]));
662
+ return 0;
663
+ }
664
+ if (sub === "revoke") {
665
+ const id = args[1];
666
+ if (!id) throw new CliError("Usage: facility keys revoke <id> --yes");
667
+ requireConfirmation(flags, `Revoke API key ${id}`);
668
+ const result = await api(ctx, "DELETE", `/v1/keys/${id}`);
669
+ output(ctx, result, () => ` ${bold("revoked")} ${id}\n`);
670
+ return 0;
671
+ }
672
+ if (sub === "issue") {
673
+ const name = flagString(flags.name, "--name") || args[1] || "facility-cli";
674
+ const roleId = flagString(flags.role, "--role") || flagString(flags.roleId, "--roleId");
675
+ if (!roleId) throw new CliError("facility keys issue requires --role <roleId>");
676
+ const result = await api(ctx, "POST", "/v1/keys", {
677
+ body: { name, roleId, projectId: flagString(flags.project, "--project") },
678
+ idempotencyKey: flagString(flags["idempotency-key"], "--idempotency-key"),
679
+ });
680
+ // The plaintext secret is returned exactly once; surface it prominently in
681
+ // human output (JSON mode already includes it) or it is lost for good.
682
+ output(ctx, result, () => {
683
+ const lines = [` ${bold("issued")} ${result.id} ${dim(`· ${result.name} · last4 ${result.last4}`)}`];
684
+ if (result.secret) {
685
+ lines.push(
686
+ ` ${green(result.secret)}`,
687
+ ` ${yellow("!")} ${dim("copy this secret now — it is shown once and cannot be retrieved later")}`,
688
+ );
689
+ }
690
+ return `${lines.join("\n")}\n`;
691
+ });
692
+ return 0;
693
+ }
694
+ throw new CliError("Usage: facility keys issue|revoke|list");
695
+ }
696
+
697
+ async function llmRequests(args, ctx, flags) {
698
+ const sub = args[0] || "list";
699
+ if (sub === "get" || sub === "envelope" || sub === "export") {
700
+ const requestId = args[1];
701
+ if (!requestId) throw new CliError("Usage: facility llm-requests get <id> [--json]");
702
+ const result = await api(ctx, "GET", `/v1/llm-requests/${requestId}/envelope`);
703
+ if (ctx.json) writeJson(ctx, result);
704
+ else writeJson(ctx, result.envelope ?? result);
705
+ return 0;
706
+ }
707
+ if (sub !== "list") {
708
+ throw new CliError(
709
+ "Usage: facility llm-requests list [--project <id>] [--limit <n>] | get <id>",
710
+ );
711
+ }
712
+ const result = await api(ctx, "GET", "/v1/llm-requests", {
713
+ query: {
714
+ projectId: flags.project,
715
+ from: flags.from,
716
+ to: flags.to,
717
+ limit: flags.limit,
718
+ cursor: flags.cursor,
719
+ },
720
+ });
721
+ const rows = asArray(result?.items ?? result);
722
+ if (ctx.json) writeJson(ctx, result);
723
+ else {
724
+ table(
725
+ ctx,
726
+ ["id", "project", "model", "status", "cost", "latency"],
727
+ rows.map((row) => [
728
+ row.id,
729
+ row.projectId,
730
+ row.model,
731
+ row.status,
732
+ row.costCents ?? row.cost_cents,
733
+ row.latencyMs ?? row.latency_ms,
734
+ ]),
735
+ );
736
+ if (result?.nextCursor) ctx.stdout.write(` ${dim(`next cursor: ${result.nextCursor}`)}\n`);
737
+ }
738
+ return 0;
739
+ }
740
+
741
+ function clientContext(ctx) {
742
+ const envUrl = ctx.env.FACILITY_URL;
743
+ const envKey = ctx.env.FACILITY_API_KEY;
744
+ if (envUrl || envKey) {
745
+ if (!envUrl || !envKey) {
746
+ throw new CliError("FACILITY_URL and FACILITY_API_KEY must be set together.", 2, {
747
+ code: "incomplete_environment_auth",
748
+ });
749
+ }
750
+ assertSafeApiUrl(envUrl, ctx.env.FACILITY_ALLOW_INSECURE === "1");
751
+ return { ...ctx, profileName: "environment", url: stripSlash(envUrl), key: envKey };
752
+ }
753
+ const { name, value } = getProfile(ctx.config, ctx.profileName);
754
+ if (!value?.url || !value?.key) throw new CliError(`Not logged in for profile "${name}". Run facility login.`, 2);
755
+ assertSafeApiUrl(value.url, value.allowInsecure === true);
756
+ return { ...ctx, profileName: name, url: value.url, key: value.key };
757
+ }
758
+
759
+ function assertSafeApiUrl(value, allowInsecure) {
760
+ let parsed;
761
+ try {
762
+ parsed = new URL(value);
763
+ } catch {
764
+ throw new CliError("API URL must be a valid absolute URL.", 1, { code: "invalid_api_url" });
765
+ }
766
+ const local = ["localhost", "127.0.0.1", "::1", "[::1]"].includes(parsed.hostname);
767
+ if (parsed.protocol !== "https:" && !local && !allowInsecure) {
768
+ throw new CliError(
769
+ "Refusing to send an API key over plain HTTP. Use HTTPS or explicitly allow insecure transport for a trusted development endpoint.",
770
+ 1,
771
+ { code: "insecure_api_url" },
772
+ );
773
+ }
774
+ }
775
+
776
+ async function api(ctx, method, path, options = {}) {
777
+ return request(ctx, method, path, options);
778
+ }
779
+
780
+ async function request(ctx, method, path, options = {}) {
781
+ if (
782
+ options.idempotencyKey !== undefined &&
783
+ (String(options.idempotencyKey).length < 8 || String(options.idempotencyKey).length > 200)
784
+ ) {
785
+ throw new CliError("--idempotency-key must contain between 8 and 200 characters", 1, {
786
+ code: "invalid_idempotency_key",
787
+ });
788
+ }
789
+ const url = new URL(`${stripSlash(ctx.url)}${path}`);
790
+ for (const [key, value] of Object.entries(options.query || {})) if (value !== undefined) url.searchParams.set(key, String(value));
791
+ const replaySafe = method === "GET" || Boolean(options.idempotencyKey);
792
+ let response;
793
+ for (let attempt = 0; attempt < 3; attempt += 1) {
794
+ try {
795
+ response = await ctx.fetch(url, {
796
+ method,
797
+ headers: {
798
+ ...(options.body === undefined ? {} : { "content-type": "application/json" }),
799
+ authorization: `Bearer ${ctx.key}`,
800
+ ...(options.idempotencyKey
801
+ ? { "idempotency-key": String(options.idempotencyKey) }
802
+ : {}),
803
+ },
804
+ body: options.body === undefined ? undefined : JSON.stringify(options.body),
805
+ signal: AbortSignal.timeout(ctx.timeoutMs || 30_000),
806
+ });
807
+ } catch (error) {
808
+ if (replaySafe && attempt < 2) {
809
+ await ctx.sleep(250 * 2 ** attempt);
810
+ continue;
811
+ }
812
+ const timeout = error?.name === "TimeoutError" || error?.name === "AbortError";
813
+ throw new CliError(
814
+ timeout
815
+ ? `Facility API request timed out after ${ctx.timeoutMs || 30_000}ms`
816
+ : `Could not reach Facility API at ${url.origin}`,
817
+ 1,
818
+ { code: timeout ? "request_timeout" : "network_error" },
819
+ );
820
+ }
821
+ if (
822
+ replaySafe &&
823
+ attempt < 2 &&
824
+ [429, 502, 503, 504].includes(response.status)
825
+ ) {
826
+ await ctx.sleep(retryDelay(response, attempt));
827
+ continue;
828
+ }
829
+ break;
830
+ }
831
+ if (!response) throw new CliError("Facility API request failed", 1, { code: "network_error" });
832
+ const text = await response.text();
833
+ if (response.ok && options.responseType === "text") return text;
834
+ let payload;
835
+ try {
836
+ payload = text ? JSON.parse(text) : undefined;
837
+ } catch {
838
+ if (response.ok) {
839
+ throw new CliError("Facility API returned an invalid JSON response", 1, {
840
+ code: "invalid_response",
841
+ status: response.status,
842
+ });
843
+ }
844
+ }
845
+ if (!response.ok) {
846
+ throw new CliError(
847
+ payload?.error?.message || `Facility API returned ${response.status}`,
848
+ response.status === 401 ? 2 : 1,
849
+ {
850
+ code: payload?.error?.code || `http_${response.status}`,
851
+ status: response.status,
852
+ details: payload?.error?.details,
853
+ },
854
+ );
855
+ }
856
+ return payload;
857
+ }
858
+
859
+ function retryDelay(response, attempt) {
860
+ const retryAfter = response.headers.get("retry-after");
861
+ if (retryAfter && /^\d+$/.test(retryAfter)) return Math.min(Number(retryAfter) * 1_000, 10_000);
862
+ return 250 * 2 ** attempt;
863
+ }
864
+
865
+ async function resolveProject(ctx, slugOrId) {
866
+ if (!slugOrId) throw new CliError("Project is required.");
867
+ if (slugOrId.startsWith("proj_")) return api(ctx, "GET", `/v1/projects/${slugOrId}`);
868
+ const projects = await offsetPages((offset) =>
869
+ api(ctx, "GET", "/v1/projects", { query: { limit: 200, offset } }),
870
+ );
871
+ const found = asArray(projects).find((project) => project.slug === slugOrId || project.id === slugOrId);
872
+ if (!found) throw new CliError(`Project not found: ${slugOrId}`);
873
+ return found;
874
+ }
875
+
876
+ async function allRuns(ctx, status) {
877
+ return offsetPages((offset) =>
878
+ api(ctx, "GET", "/v1/runs", { query: { status, limit: 200, offset } }),
879
+ );
880
+ }
881
+
882
+ async function projectRuns(ctx, projectId, status) {
883
+ return offsetPages((offset) =>
884
+ api(ctx, "GET", `/v1/projects/${projectId}/runs`, {
885
+ query: { status, limit: 200, offset },
886
+ }),
887
+ );
888
+ }
889
+
890
+ async function offsetPages(load) {
891
+ const rows = [];
892
+ for (let offset = 0; ; offset += 200) {
893
+ const page = asArray(await load(offset));
894
+ rows.push(...page);
895
+ if (page.length < 200) return rows;
896
+ }
897
+ }
898
+
899
+ async function watchRun(ctx, runId) {
900
+ if (!runId) throw new CliError("Usage: facility sessions watch <id>");
901
+ let afterSeq = 0;
902
+ let retryMs = 250;
903
+ for (;;) {
904
+ const url = new URL(`${stripSlash(ctx.url)}/v1/runs/${runId}/stream`);
905
+ if (afterSeq) url.searchParams.set("afterSeq", String(afterSeq));
906
+ let response;
907
+ try {
908
+ response = await ctx.fetch(url, {
909
+ headers: {
910
+ authorization: `Bearer ${ctx.key}`,
911
+ ...(afterSeq ? { "last-event-id": String(afterSeq) } : {}),
912
+ },
913
+ });
914
+ } catch {
915
+ await ctx.sleep(retryMs);
916
+ retryMs = Math.min(retryMs * 2, 5_000);
917
+ continue;
918
+ }
919
+ if (!response.ok) {
920
+ const payload = await response.json().catch(() => undefined);
921
+ throw new CliError(
922
+ payload?.error?.message || `Facility API returned ${response.status}`,
923
+ response.status === 401 ? 2 : 1,
924
+ );
925
+ }
926
+ if (!response.body) throw new CliError("Facility stream returned no body");
927
+ retryMs = 250;
928
+ const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
929
+ let buffer = "";
930
+ for (;;) {
931
+ const { value, done } = await reader.read();
932
+ if (done) break;
933
+ buffer += value.replaceAll("\r\n", "\n");
934
+ const chunks = buffer.split("\n\n");
935
+ buffer = chunks.pop() || "";
936
+ for (const chunk of chunks) {
937
+ const parsed = parseSseChunk(chunk);
938
+ if (!parsed || parsed.event === "heartbeat") continue;
939
+ const seq = eventSequence(parsed);
940
+ if (seq > afterSeq) afterSeq = seq;
941
+ renderRunEvent(ctx, parsed.data);
942
+ if (parsed.data?.type === "result") {
943
+ await reader.cancel();
944
+ return parsed.data?.data?.status === "succeeded" ? 0 : 1;
945
+ }
946
+ }
947
+ }
948
+ await ctx.sleep(retryMs);
949
+ retryMs = Math.min(retryMs * 2, 5_000);
950
+ }
951
+ }
952
+
953
+ function parseSseChunk(chunk) {
954
+ let event = "message";
955
+ let id;
956
+ const dataLines = [];
957
+ for (const line of chunk.split("\n")) {
958
+ if (!line || line.startsWith(":")) continue;
959
+ const colon = line.indexOf(":");
960
+ const field = colon === -1 ? line : line.slice(0, colon);
961
+ const value = colon === -1 ? "" : line.slice(colon + 1).replace(/^ /, "");
962
+ if (field === "event") event = value || "message";
963
+ else if (field === "id") id = value;
964
+ else if (field === "data") dataLines.push(value);
965
+ }
966
+ if (!dataLines.length) return undefined;
967
+ const text = dataLines.join("\n");
968
+ try {
969
+ return { event, id, data: JSON.parse(text) };
970
+ } catch {
971
+ return { event, id, data: { type: event, text } };
972
+ }
973
+ }
974
+
975
+ function eventSequence(event) {
976
+ if (event.id && /^\d+$/.test(event.id)) return Number(event.id);
977
+ return Number.isSafeInteger(event.data?.seq) ? event.data.seq : 0;
978
+ }
979
+
980
+ function renderRunEvent(ctx, event) {
981
+ if (ctx.json) {
982
+ writeJson(ctx, event);
983
+ return;
984
+ }
985
+ const type = event?.type || "event";
986
+ const data = event?.data && typeof event.data === "object" ? event.data : {};
987
+ if (type === "assistant") {
988
+ ctx.stdout.write(` ${accent("assistant")} ${data.text || ""}\n`);
989
+ } else if (type === "tool") {
990
+ ctx.stdout.write(` ${dim("tool")} ${data.name || data.tool || compactJson(data)}\n`);
991
+ } else if (type === "check") {
992
+ const passed = data.ok === true || data.code === 0 || data.status === "passed";
993
+ ctx.stdout.write(` ${passed ? green("✓") : yellow("!")} ${data.command || data.name || "check"}\n`);
994
+ } else if (type === "result") {
995
+ const succeeded = data.status === "succeeded";
996
+ ctx.stdout.write(` ${succeeded ? green("✓") : yellow("!")} ${bold(data.status || "finished")}${data.error ? ` · ${data.error}` : ""}\n`);
997
+ } else if (type === "steer") {
998
+ ctx.stdout.write(` ${bold("steer")} ${data.text || compactJson(data)}\n`);
999
+ } else {
1000
+ ctx.stdout.write(` ${dim(type)} ${compactJson(data)}\n`);
1001
+ }
1002
+ }
1003
+
1004
+ function compactJson(value) {
1005
+ return Object.keys(value || {}).length ? JSON.stringify(value) : "";
1006
+ }
1007
+
1008
+ function humanErrorDetails(details) {
1009
+ if (details === undefined || details === null) return "";
1010
+ const issues = Array.isArray(details)
1011
+ ? details
1012
+ : Array.isArray(details.errors)
1013
+ ? [...details.errors, ...(Array.isArray(details.warnings) ? details.warnings : [])]
1014
+ : [];
1015
+ if (issues.length) {
1016
+ return issues
1017
+ .slice(0, 20)
1018
+ .map((issue) => {
1019
+ if (typeof issue === "string") return ` - ${issue}`;
1020
+ const code = issue?.code ? `${issue.code}: ` : "";
1021
+ return ` - ${code}${issue?.message || JSON.stringify(issue)}`;
1022
+ })
1023
+ .join("\n");
1024
+ }
1025
+ if (typeof details === "string") return details ? ` ${details}` : "";
1026
+ if (details && typeof details === "object") {
1027
+ return Object.entries(details)
1028
+ .map(([key, value]) => {
1029
+ const rendered = Array.isArray(value)
1030
+ ? value.map((item) => (typeof item === "string" ? item : JSON.stringify(item))).join(", ")
1031
+ : typeof value === "object" && value !== null
1032
+ ? JSON.stringify(value)
1033
+ : String(value);
1034
+ return ` - ${key}: ${rendered}`;
1035
+ })
1036
+ .join("\n");
1037
+ }
1038
+ return "";
1039
+ }
1040
+
1041
+ async function prompt(label, ctx) {
1042
+ const rl = createInterface({ input: ctx.stdin || processStdin, output: ctx.stdout === process.stdout ? processStdout : undefined });
1043
+ try {
1044
+ return await questionOrEof(rl, `${label}: `);
1045
+ } finally {
1046
+ rl.close();
1047
+ }
1048
+ }
1049
+
1050
+ async function promptSecret(label, ctx) {
1051
+ const input = ctx.stdin || processStdin;
1052
+ if (!input.isTTY) return prompt(label, ctx);
1053
+ ctx.stdout.write(`${label}: `);
1054
+ const muted = new Writable({ write(_chunk, _encoding, callback) { callback(); } });
1055
+ const rl = createInterface({ input, output: muted, terminal: true });
1056
+ try {
1057
+ return await questionOrEof(rl, "");
1058
+ } finally {
1059
+ rl.close();
1060
+ ctx.stdout.write("\n");
1061
+ }
1062
+ }
1063
+
1064
+ async function readPipedAnswers(input) {
1065
+ let text = "";
1066
+ for await (const chunk of input) text += String(chunk);
1067
+ return text.replaceAll("\r\n", "\n").split("\n");
1068
+ }
1069
+
1070
+ function promptEof() {
1071
+ return new CliError("Input ended before the prompt was answered.", 1, {
1072
+ code: "prompt_eof",
1073
+ });
1074
+ }
1075
+
1076
+ function questionOrEof(rl, question) {
1077
+ return new Promise((resolve, reject) => {
1078
+ let settled = false;
1079
+ rl.once("close", () => {
1080
+ if (!settled) {
1081
+ settled = true;
1082
+ reject(promptEof());
1083
+ }
1084
+ });
1085
+ rl.question(question).then(
1086
+ (answer) => {
1087
+ if (settled) return;
1088
+ settled = true;
1089
+ resolve(answer);
1090
+ },
1091
+ (error) => {
1092
+ if (settled) return;
1093
+ settled = true;
1094
+ reject(error);
1095
+ },
1096
+ );
1097
+ });
1098
+ }
1099
+
1100
+ function output(ctx, payload, human) {
1101
+ if (ctx.json) writeJson(ctx, payload);
1102
+ else ctx.stdout.write(human());
1103
+ }
1104
+
1105
+ function writeJson(ctx, payload) {
1106
+ ctx.stdout.write(`${JSON.stringify(payload)}\n`);
1107
+ }
1108
+
1109
+ function table(ctx, headers, rows, options = {}) {
1110
+ const available = terminalWidth(ctx);
1111
+ const normalized = rows.map((values) => values.map((value) => normalizeCell(value)));
1112
+ const widths = fitColumnWidths(headers, normalized, available);
1113
+ if (!widths) return verticalTable(ctx, headers, normalized, options);
1114
+ const clipped = normalized.map((values) =>
1115
+ values.map((value, index) => truncateCell(value, widths[index])),
1116
+ );
1117
+ const header = headers
1118
+ .map((cell, index) => bold(truncateHeaderCell(cell, widths[index]).padEnd(widths[index])))
1119
+ .join(" ")
1120
+ .trimEnd();
1121
+ ctx.stdout.write(`\n ${header}\n`);
1122
+ for (const rowValues of clipped) {
1123
+ const line = rowValues.map((value, index) => String(value ?? "").padEnd(widths[index])).join(" ");
1124
+ ctx.stdout.write(` ${options.live ? accent(line.trimEnd()) : line.trimEnd()}\n`);
1125
+ }
1126
+ if (!rows.length) ctx.stdout.write(` ${dim("No results.")}\n`);
1127
+ }
1128
+
1129
+ function terminalWidth(ctx) {
1130
+ const configured = Number(ctx.env?.COLUMNS);
1131
+ const columns = Number.isFinite(configured) && configured > 0
1132
+ ? configured
1133
+ : Number(ctx.stdout?.columns || processStdout.columns || 120);
1134
+ return Math.max(32, columns - 4);
1135
+ }
1136
+
1137
+ function fitColumnWidths(headers, rows, available) {
1138
+ const natural = headers.map((header, index) =>
1139
+ Math.min(
1140
+ 96,
1141
+ Math.max(
1142
+ String(header).length,
1143
+ ...rows.map((rowValues) => String(rowValues[index] ?? "").length),
1144
+ ),
1145
+ ),
1146
+ );
1147
+ const minimum = headers.map((header) => Math.min(Math.max(String(header).length, 4), 16));
1148
+ const separators = Math.max(0, headers.length - 1) * 2;
1149
+ if (minimum.reduce((sum, width) => sum + width, separators) > available) return undefined;
1150
+ const widths = [...natural];
1151
+ while (widths.reduce((sum, width) => sum + width, separators) > available) {
1152
+ let candidate = -1;
1153
+ for (let index = 0; index < widths.length; index += 1) {
1154
+ if (widths[index] > minimum[index] && (candidate === -1 || widths[index] > widths[candidate])) {
1155
+ candidate = index;
1156
+ }
1157
+ }
1158
+ if (candidate === -1) return undefined;
1159
+ widths[candidate] -= 1;
1160
+ }
1161
+ return widths;
1162
+ }
1163
+
1164
+ function verticalTable(ctx, headers, rows, options) {
1165
+ ctx.stdout.write("\n");
1166
+ if (!rows.length) {
1167
+ ctx.stdout.write(` ${dim("No results.")}\n`);
1168
+ return;
1169
+ }
1170
+ rows.forEach((values, rowIndex) => {
1171
+ if (rowIndex) ctx.stdout.write("\n");
1172
+ for (let index = 0; index < headers.length; index += 1) {
1173
+ const line = `${bold(`${headers[index]}:`)} ${values[index] ?? "—"}`;
1174
+ ctx.stdout.write(` ${options.live ? accent(line) : line}\n`);
1175
+ }
1176
+ });
1177
+ }
1178
+
1179
+ function normalizeCell(value) {
1180
+ if (value === null || value === undefined || value === "") return "—";
1181
+ return String(value).replaceAll(/\s+/g, " ").trim();
1182
+ }
1183
+
1184
+ function truncateCell(value, max = 96) {
1185
+ const text = normalizeCell(value);
1186
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
1187
+ }
1188
+
1189
+ function truncateHeaderCell(value, max = 96) {
1190
+ const text = value === null || value === undefined ? "" : String(value).replaceAll(/\s+/g, " ").trim();
1191
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
1192
+ }
1193
+
1194
+ function row(label, value, live = false) {
1195
+ const line = ` ${String(label).padEnd(12)} ${value}\n`;
1196
+ return live ? accent(line) : line;
1197
+ }
1198
+
1199
+ function asArray(value) {
1200
+ return Array.isArray(value) ? value : [];
1201
+ }
1202
+
1203
+ // GET /v1/inbox returns { items, proposals, issues }; the CLI wants the proposals array.
1204
+ function unwrapInbox(value) {
1205
+ if (Array.isArray(value)) return value;
1206
+ return value?.proposals ?? value?.items ?? [];
1207
+ }
1208
+
1209
+ // The kickstart/upgrade APIs take a connected repo's id, not an owner/name slug.
1210
+ async function resolveRepoId(ctx, projectId, ownerName) {
1211
+ const repos = asArray(await api(ctx, "GET", `/v1/projects/${projectId}/repos`));
1212
+ const match = repos.find(
1213
+ (r) => r.id === ownerName || `${r.owner}/${r.name}` === ownerName || r.fullName === ownerName,
1214
+ );
1215
+ if (!match) {
1216
+ throw new CliError(
1217
+ `Repo "${ownerName}" is not connected to this project. Connect it (GitHub App / web UI) first, then retry.`,
1218
+ 1,
1219
+ );
1220
+ }
1221
+ return match.id;
1222
+ }
1223
+
1224
+ function sum(rows, key) {
1225
+ return rows.reduce((total, row) => total + Number(row?.[key] || 0), 0);
1226
+ }
1227
+
1228
+ function cents(value) {
1229
+ return `$${(Number(value || 0) / 100).toFixed(2)}`;
1230
+ }
1231
+
1232
+ function monthStart() {
1233
+ const now = new Date();
1234
+ return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString();
1235
+ }
1236
+
1237
+ function stripSlash(value) {
1238
+ return String(value).replace(/\/$/, "");
1239
+ }
1240
+
1241
+ function parseInput(value) {
1242
+ if (!value) return undefined;
1243
+ try {
1244
+ return JSON.parse(value);
1245
+ } catch {
1246
+ return value;
1247
+ }
1248
+ }
1249
+
1250
+ function displayValue(value) {
1251
+ if (value === null || value === undefined) return "";
1252
+ if (typeof value === "object") return JSON.stringify(value);
1253
+ return String(value);
1254
+ }
1255
+
1256
+ function flagString(value, name = "Flag") {
1257
+ if (value === undefined) return undefined;
1258
+ if (typeof value === "string" && value.length > 0) return value;
1259
+ throw new CliError(`${name} requires a value`, 1, { code: "invalid_flag" });
1260
+ }
1261
+
1262
+ function requiredFlagString(value, name) {
1263
+ const parsed = flagString(value, name);
1264
+ if (parsed === undefined) throw new CliError(`${name} is required`, 1, { code: "invalid_flag" });
1265
+ return parsed;
1266
+ }
1267
+
1268
+ function runStatus(value) {
1269
+ const status = flagString(value, "--status");
1270
+ if (
1271
+ status !== undefined &&
1272
+ !["queued", "provisioning", "running", "succeeded", "failed", "canceled"].includes(status)
1273
+ ) {
1274
+ throw new CliError(
1275
+ "--status must be queued, provisioning, running, succeeded, failed, or canceled",
1276
+ 1,
1277
+ { code: "invalid_flag" },
1278
+ );
1279
+ }
1280
+ return status;
1281
+ }
1282
+
1283
+ function numericFlag(value, name) {
1284
+ if (value === undefined) return undefined;
1285
+ if (value === true || value === "") {
1286
+ throw new CliError(`${name} requires a number`, 1, { code: "invalid_flag" });
1287
+ }
1288
+ const number = Number(value);
1289
+ if (!Number.isFinite(number) || number < 0) {
1290
+ throw new CliError(`${name} must be a non-negative number`, 1, { code: "invalid_flag" });
1291
+ }
1292
+ return number;
1293
+ }
1294
+
1295
+ function pageQuery(flags) {
1296
+ const limit = flags.limit === undefined ? undefined : Number(flags.limit);
1297
+ const offset = numericFlag(flags.offset, "--offset");
1298
+ if (
1299
+ flags.limit === true ||
1300
+ flags.limit === "" ||
1301
+ (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 200))
1302
+ ) {
1303
+ throw new CliError("--limit must be an integer from 1 to 200", 1, {
1304
+ code: "invalid_flag",
1305
+ });
1306
+ }
1307
+ if (offset !== undefined && !Number.isInteger(offset)) {
1308
+ throw new CliError("--offset must be a non-negative integer", 1, {
1309
+ code: "invalid_flag",
1310
+ });
1311
+ }
1312
+ return { limit, offset };
1313
+ }
1314
+
1315
+ function defined(value) {
1316
+ return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
1317
+ }
1318
+
1319
+ function requireConfirmation(flags, action) {
1320
+ if (!flags.yes) {
1321
+ throw new CliError(`${action} requires --yes`, 1, { code: "confirmation_required" });
1322
+ }
1323
+ }
1324
+
1325
+ function parseList(value, flag) {
1326
+ if (value === true || value === undefined) {
1327
+ throw new CliError(`${flag} requires a value`, 1, { code: "invalid_flag" });
1328
+ }
1329
+ if (Array.isArray(value)) return value.map(String);
1330
+ const text = String(value);
1331
+ if (text.startsWith("[")) {
1332
+ try {
1333
+ const parsed = JSON.parse(text);
1334
+ if (Array.isArray(parsed) && parsed.every((item) => typeof item === "string")) return parsed;
1335
+ } catch {
1336
+ // Fall through to the actionable flag error below.
1337
+ }
1338
+ throw new CliError(`${flag} must be a JSON string array or comma-separated list`, 1, {
1339
+ code: "invalid_flag",
1340
+ });
1341
+ }
1342
+ return text
1343
+ .split(",")
1344
+ .map((item) => item.trim())
1345
+ .filter(Boolean);
1346
+ }
1347
+
1348
+ function parseObject(value, flag) {
1349
+ try {
1350
+ const parsed = JSON.parse(String(value));
1351
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
1352
+ } catch {
1353
+ // Use the shared structured flag error below.
1354
+ }
1355
+ throw new CliError(`${flag} must be a JSON object`, 1, { code: "invalid_flag" });
1356
+ }
1357
+
1358
+ function assertAllowedFlags(flags, allowed) {
1359
+ const permitted = new Set(allowed);
1360
+ const unknown = Object.keys(flags).filter((flag) => !permitted.has(flag));
1361
+ if (unknown.length) {
1362
+ throw new CliError(`Unknown flag${unknown.length === 1 ? "" : "s"}: ${unknown.map((flag) => `--${flag}`).join(", ")}`, 1, {
1363
+ code: "unknown_flag",
1364
+ details: { flags: unknown },
1365
+ });
1366
+ }
1367
+ }
1368
+
1369
+ const PRESENCE_FLAGS = new Set(["json", "yes", "help", "allow-insecure"]);
1370
+
1371
+ function assertPresenceFlagSyntax(flags) {
1372
+ for (const name of PRESENCE_FLAGS) {
1373
+ if (name in flags && flags[name] !== true) {
1374
+ throw new CliError(`--${name} does not take a value`, 1, { code: "invalid_flag" });
1375
+ }
1376
+ }
1377
+ }
1378
+
1379
+ function assertMissingFlagValues(flags, booleanFlags = []) {
1380
+ const booleans = new Set([...PRESENCE_FLAGS, ...booleanFlags]);
1381
+ for (const [name, value] of Object.entries(flags)) {
1382
+ if (value === true && !booleans.has(name)) {
1383
+ throw new CliError(`--${name} requires a value`, 1, { code: "invalid_flag" });
1384
+ }
1385
+ }
1386
+ }
1387
+
1388
+ const COMMAND_FLAGS = {
1389
+ login: ["url", "key", "allow-insecure"],
1390
+ logout: [],
1391
+ profiles: [],
1392
+ status: [],
1393
+ projects: ["name", "slug", "description", "settings", "status", "idempotency-key", "yes", "limit", "offset"],
1394
+ sessions: ["project", "status", "input", "idempotency-key", "after-seq", "tail", "limit", "offset"],
1395
+ runs: ["project", "status", "input", "idempotency-key", "after-seq", "tail", "limit", "offset"],
1396
+ inbox: ["state", "note", "limit", "offset", "idempotency-key"],
1397
+ issues: ["state", "kind", "limit", "offset", "idempotency-key"],
1398
+ kickstart: [
1399
+ "repo",
1400
+ "branch",
1401
+ "provision",
1402
+ "checks",
1403
+ "modules",
1404
+ "model",
1405
+ "org",
1406
+ "board",
1407
+ "execution-lane",
1408
+ "idempotency-key",
1409
+ "yes",
1410
+ ],
1411
+ upgrade: ["repo", "to", "idempotency-key"],
1412
+ keys: ["name", "role", "roleId", "project", "yes", "limit", "offset", "idempotency-key"],
1413
+ "llm-requests": ["project", "from", "to", "limit", "cursor"],
1414
+ };
1415
+
1416
+ const CORE_SUBCOMMAND_FLAGS = {
1417
+ projects: {
1418
+ list: ["limit", "offset"],
1419
+ get: [],
1420
+ create: ["name", "slug", "description", "settings", "idempotency-key"],
1421
+ update: ["name", "description", "status", "settings"],
1422
+ archive: ["yes"],
1423
+ delete: ["yes"],
1424
+ },
1425
+ runs: {
1426
+ list: ["project", "status", "limit", "offset"],
1427
+ get: [],
1428
+ events: ["after-seq", "tail", "limit"],
1429
+ transcript: [],
1430
+ watch: [],
1431
+ trigger: ["input", "idempotency-key"],
1432
+ steer: ["idempotency-key"],
1433
+ interrupt: ["idempotency-key"],
1434
+ resume: ["idempotency-key"],
1435
+ cancel: ["idempotency-key"],
1436
+ },
1437
+ sessions: {
1438
+ list: ["project", "status", "limit", "offset"],
1439
+ get: [],
1440
+ events: ["after-seq", "tail", "limit"],
1441
+ transcript: [],
1442
+ watch: [],
1443
+ trigger: ["input", "idempotency-key"],
1444
+ steer: ["idempotency-key"],
1445
+ interrupt: ["idempotency-key"],
1446
+ resume: ["idempotency-key"],
1447
+ cancel: ["idempotency-key"],
1448
+ },
1449
+ inbox: { __default: ["state", "limit", "offset"], decide: ["note", "idempotency-key"] },
1450
+ issues: { __default: ["state", "kind", "limit", "offset"], list: ["state", "kind", "limit", "offset"], ack: ["idempotency-key"], resolve: ["idempotency-key"] },
1451
+ keys: { list: ["limit", "offset"], issue: ["name", "role", "roleId", "project", "idempotency-key"], revoke: ["yes"] },
1452
+ "llm-requests": {
1453
+ __default: ["project", "from", "to", "limit", "cursor"],
1454
+ list: ["project", "from", "to", "limit", "cursor"],
1455
+ get: [],
1456
+ envelope: [],
1457
+ export: [],
1458
+ },
1459
+ };
1460
+
1461
+ function assertCoreSubcommandFlags(command, positional, flags) {
1462
+ const spec = CORE_SUBCOMMAND_FLAGS[command];
1463
+ if (!spec || flags.help) return;
1464
+ const sub = positional[0] || "__default";
1465
+ const allowed = spec[sub];
1466
+ if (!allowed) throw usage(PLATFORM_USAGE[command]);
1467
+ assertAllowedFlags(flags, ["profile", "json", "timeout", ...allowed]);
1468
+ }
1469
+
1470
+ function assertCommandFlags(command, flags) {
1471
+ if (!(command in COMMAND_FLAGS)) return;
1472
+ assertAllowedFlags(flags, [
1473
+ "profile",
1474
+ "json",
1475
+ "timeout",
1476
+ "help",
1477
+ ...COMMAND_FLAGS[command],
1478
+ ]);
1479
+ assertMissingFlagValues(flags);
1480
+ }
1481
+
1482
+ function delay(ms) {
1483
+ return new Promise((resolve) => setTimeout(resolve, ms));
1484
+ }
1485
+
1486
+ function timeoutMilliseconds(value) {
1487
+ if (value === undefined) return 30_000;
1488
+ if (value === true || value === "") {
1489
+ throw new CliError("--timeout requires a number of seconds", 1, { code: "invalid_flag" });
1490
+ }
1491
+ const seconds = Number(value);
1492
+ if (!Number.isFinite(seconds) || seconds <= 0 || seconds > 300) {
1493
+ throw new CliError("--timeout must be greater than 0 and at most 300 seconds", 1, {
1494
+ code: "invalid_flag",
1495
+ });
1496
+ }
1497
+ return Math.round(seconds * 1_000);
1498
+ }
1499
+
1500
+ function usage(command) {
1501
+ return new CliError(`Usage: ${command}`, 1, { code: "usage" });
1502
+ }
1503
+
1504
+ const PLATFORM_USAGE = {
1505
+ login: "facility login [--url <url>] [--key <key>] [--profile <name>] [--allow-insecure]",
1506
+ logout: "facility logout [--profile <name>]",
1507
+ profiles: "facility profiles list|use <name>|remove <name>",
1508
+ status: "facility status [--json]",
1509
+ projects: "facility projects list|get|create|update|archive",
1510
+ sessions: "facility sessions list|get|events|transcript|watch|trigger|steer|interrupt|resume|cancel",
1511
+ runs: "facility runs list|get|events|transcript|watch|trigger|steer|interrupt|resume|cancel",
1512
+ conversations: "facility conversations list|get|start|send",
1513
+ github: "facility github installations|repos|issues|issue|sync|trigger",
1514
+ inbox: "facility inbox [decide <id> approve|reject]",
1515
+ issues: "facility issues list|ack <id>|resolve <id>",
1516
+ kickstart: "facility kickstart <project> --repo <owner/name> [--yes]",
1517
+ upgrade: "facility upgrade <project> --repo <owner/name> [--to <version>]",
1518
+ keys: "facility keys list|issue|revoke",
1519
+ "llm-requests": "facility llm-requests list|get <id>",
1520
+ org: "facility org get|update",
1521
+ members: "facility members list|add|update|remove",
1522
+ roles: "facility roles list|create|update|delete",
1523
+ repos: "facility repos list|connect|create|disconnect|verify|adopt",
1524
+ agents: "facility agents list|status|create|update|delete <project>",
1525
+ providers: "facility providers list|create|delete",
1526
+ budgets: "facility budgets list|get|set|delete",
1527
+ registry: "facility registry list|get|create|version|publish|deprecate",
1528
+ sandboxes: "facility sandboxes list|create|update|delete",
1529
+ tasks: "facility tasks list|create|update|delete|transition|propose",
1530
+ "virtual-keys": "facility virtual-keys list|issue|revoke <project>",
1531
+ kb: "facility kb space get|set | entries list|get|create|update | validate",
1532
+ analytics: "facility analytics overview|timeseries [--project <id>] [--from <date>] [--to <date>]",
1533
+ audit: "facility audit list|verify",
1534
+ integrations: "facility integrations list|get|create|update|rotate-secret|events|deliveries|retry|delete",
1535
+ spend: "facility spend [--project <id>] [--from <date>] [--to <date>] [--group-by <day|model|agent|task>]",
1536
+ proposals: "facility proposals get|create|execute",
1537
+ "action-types": "facility action-types list|get",
1538
+ health: "facility health <project>",
1539
+ outcomes: "facility outcomes [--project <id>] [--state <open|terminal|all>]",
1540
+ catalog: "facility catalog",
1541
+ };
1542
+
1543
+ const PLATFORM_FORMS = {
1544
+ login: ["login --url <url> --key <key> [--profile <name>] [--allow-insecure]"],
1545
+ logout: ["logout [--profile <name>]"],
1546
+ profiles: ["profiles list", "profiles use <name>", "profiles remove <name>"],
1547
+ status: ["status"],
1548
+ projects: [
1549
+ "projects list",
1550
+ "projects get <project>",
1551
+ "projects create --name <name> --slug <slug> [--description <text>] [--settings <json>] [--idempotency-key <key>]",
1552
+ "projects update <project> --name|--description|--status|--settings <value>",
1553
+ "projects archive <project> --yes",
1554
+ ],
1555
+ sessions: [
1556
+ "sessions list [--project <project>] [--status <status>]",
1557
+ "sessions get <session-id>",
1558
+ "sessions events <session-id> [--after-seq <n> | --tail <n>]",
1559
+ "sessions transcript <session-id>",
1560
+ "sessions watch <session-id> [--json]",
1561
+ "sessions trigger <project> <agent> [--input <json-or-text>] [--idempotency-key <key>]",
1562
+ "sessions steer <session-id> <message>",
1563
+ "sessions interrupt <session-id>",
1564
+ "sessions resume <session-id> [message]",
1565
+ "sessions cancel <session-id>",
1566
+ ],
1567
+ runs: [
1568
+ "runs list [--project <project>] [--status <status>]",
1569
+ "runs get <run-id>",
1570
+ "runs events <run-id> [--after-seq <n> | --tail <n>]",
1571
+ "runs transcript <run-id>",
1572
+ "runs watch <run-id> [--json]",
1573
+ "runs trigger <project> <agent> [--input <json-or-text>] [--idempotency-key <key>]",
1574
+ "runs steer <run-id> <message>",
1575
+ "runs interrupt <run-id>",
1576
+ "runs resume <run-id> [message]",
1577
+ "runs cancel <run-id>",
1578
+ ],
1579
+ conversations: [
1580
+ "conversations list <project>",
1581
+ "conversations get <conversation-id>",
1582
+ "conversations start <project> [--agent <agent-id>] [--title <title>]",
1583
+ "conversations send <conversation-id> <message>",
1584
+ ],
1585
+ github: [
1586
+ "github installations",
1587
+ "github repos <installation-id> [--query <text>]",
1588
+ "github issues <project> [--state <open|closed|all>] [--query <text>] [--cursor <cursor>]",
1589
+ "github issue <project> <number>",
1590
+ "github sync <project>",
1591
+ "github trigger <project> <number> --agent <name>",
1592
+ ],
1593
+ inbox: ["inbox [--state <state>]", "inbox decide <proposal-id> approve|reject [--note <text>]"],
1594
+ issues: ["issues list [--state <state>] [--kind <kind>]", "issues ack <issue-id>", "issues resolve <issue-id>"],
1595
+ kickstart: ["kickstart <project> --repo <owner/name> [configuration flags] [--yes]"],
1596
+ upgrade: ["upgrade <project> --repo <owner/name> [--to <version>]"],
1597
+ keys: ["keys list", "keys issue [name] --role <role-id> [--project <project-id>]", "keys revoke <key-id> --yes"],
1598
+ "llm-requests": ["llm-requests list [--project <id>] [--from <date>] [--to <date>] [--limit <n>] [--cursor <cursor>]", "llm-requests get <request-id>"],
1599
+ org: ["org get", "org update --name <name> | --settings <json>"],
1600
+ members: ["members list", "members add --email <email> --role <role-id>", "members update <user-id> --role <role-id>", "members remove <user-id> --yes"],
1601
+ roles: ["roles list", "roles create --name <name> --permissions <a,b> [--description <text>]", "roles update <role-id> --description|--permissions <value>", "roles delete <role-id> --yes"],
1602
+ repos: ["repos list <project>", "repos connect <project> --repo <owner/name> [--branch <name>]", "repos create <project> --repo <owner/name> [--private <true|false>] [--auto-init <true|false>]", "repos disconnect <project> <repo-id> --yes", "repos verify <repo-id>", "repos adopt <repo-id>"],
1603
+ agents: ["agents list <project>", "agents status <project>", "agents create <project> --name <name> --engine <engine> --contract <item-id> [configuration flags]", "agents update <project> <agent-id> [configuration flags]", "agents delete <project> <agent-id> --yes"],
1604
+ providers: ["providers list", "providers create --provider <provider> --name <name> --secret <secret> [--base-url <url>]", "providers delete <provider-id> --yes"],
1605
+ budgets: ["budgets list", "budgets get <budget-id>", "budgets set [<budget-id>] --scope <scope> --period <period> --limit-cents <n> --mode <mode>", "budgets delete <budget-id> --yes"],
1606
+ registry: ["registry list [--kind <kind>] [--scope <scope>] [--project <id>]", "registry get <item-id>", "registry create --scope <scope> --kind <kind> --name <name> --content|--content-file <value>", "registry version <item-id> --content|--content-file <value> [--changelog <text>]", "registry publish <version-id>", "registry deprecate <version-id>"],
1607
+ sandboxes: ["sandboxes list", "sandboxes create --name <name> --driver <driver> --image <image> [configuration flags]", "sandboxes update <profile-id> [configuration flags]", "sandboxes delete <profile-id> --yes"],
1608
+ tasks: ["tasks list <project>", "tasks create <project> --title <title> --body|--body-file <value>", "tasks update <project> <task-id> [task flags]", "tasks delete <project> <task-id> --yes", "tasks transition <task-id> --status <status>", "tasks propose <task-id>"],
1609
+ "virtual-keys": ["virtual-keys list <project>", "virtual-keys issue <project> --name <name> [--models <a,b>] [--expires <timestamp>]", "virtual-keys revoke <project> <key-id> --yes"],
1610
+ kb: ["kb space get <project>", "kb space set <project> [--charter|--charter-file <value>] [--active|--active-file <value>]", "kb entries list <project> [--type <type>]", "kb entries get <entry-id>", "kb entries create <project> --type <type> --slug <slug> --body|--body-file <value> [--dry]", "kb entries update <project> <entry-id> [entry flags]", "kb validate <project>"],
1611
+ analytics: ["analytics overview [--project <id>] [--from <date>] [--to <date>]", "analytics timeseries [--project <id>] [--from <date>] [--to <date>] [--group-by <day|agent|model>]"],
1612
+ audit: ["audit list [--actor <id>] [--action <name>] [--from <seq>] [--to <seq>] [--cursor <seq>] [--limit <n>]", "audit verify"],
1613
+ integrations: ["integrations list [--project <id>] [--kind <kind>] [--enabled <true|false>]", "integrations get <integration-id>", "integrations create --kind <kind> --name <name> [--project <id>] [--config <json>]", "integrations update <integration-id> [--name <name>] [--config <json>] [--enabled <true|false>]", "integrations rotate-secret <integration-id> [--secret <secret>]", "integrations events <integration-id> [--limit <n>] [--offset <n>]", "integrations deliveries <integration-id> [--status <status>] [--limit <n>] [--offset <n>]", "integrations retry <delivery-id>", "integrations delete <integration-id> --yes"],
1614
+ spend: ["spend [--project <id>] [--from <date>] [--to <date>] [--group-by <day|model|agent|task>]"],
1615
+ proposals: ["proposals get <proposal-id>", "proposals create --action <action-type-id> --context <markdown> [--payload <json>] [--project <id>] [--run <id>] [--expires <timestamp>]", "proposals execute <proposal-id>"],
1616
+ "action-types": ["action-types list", "action-types get <action-type-id>"],
1617
+ health: ["health <project>"],
1618
+ outcomes: ["outcomes [--project <id>] [--state <open|terminal|all>] [--limit <n>]"],
1619
+ catalog: ["catalog"],
1620
+ };
1621
+
1622
+ const PLATFORM_DESCRIPTIONS = {
1623
+ login: "Verify credentials and save a named Facility environment.",
1624
+ logout: "Remove credentials for a saved Facility environment.",
1625
+ profiles: "List, select, or remove saved Facility environments.",
1626
+ status: "See live runs, approval gates, issues, and month-to-date spend.",
1627
+ projects: "Create and govern Facility projects.",
1628
+ sessions: "Trigger, follow, steer, and cancel governed agent sessions.",
1629
+ runs: "Trigger, follow, steer, and cancel agent runs.",
1630
+ conversations: "Continue durable, resumable conversations with project agents.",
1631
+ github: "Discover GitHub App repositories and turn synchronized issues into sessions.",
1632
+ inbox: "Review human approval gates and record decisions.",
1633
+ issues: "Triage operational issues raised by Watchtower.",
1634
+ kickstart: "Preview managed files and open a governed kickstart pull request.",
1635
+ upgrade: "Open a governed Facility system upgrade pull request.",
1636
+ keys: "Issue and revoke control-plane API credentials.",
1637
+ "llm-requests": "Inspect metered model calls and full request envelopes.",
1638
+ org: "Inspect and update organization settings.",
1639
+ members: "Manage organization membership and role assignments.",
1640
+ roles: "Manage permission policy without the web application.",
1641
+ repos: "Connect existing GitHub repositories or create new ones.",
1642
+ agents: "Manage agent definitions, contracts, engines, and schedules.",
1643
+ providers: "Manage model-provider credentials.",
1644
+ budgets: "Create and enforce spend policy at org, project, or agent scope.",
1645
+ registry: "Version and publish contracts, harnesses, skills, guards, and policy.",
1646
+ sandboxes: "Manage isolated execution profiles.",
1647
+ tasks: "Manage the product-owner task queue and proposal flow.",
1648
+ "virtual-keys": "Issue project-scoped model credentials.",
1649
+ kb: "Manage, validate, and trace the project knowledge base.",
1650
+ analytics: "Query reliability, throughput, model, and cost trends.",
1651
+ audit: "Read and verify the tamper-evident audit chain.",
1652
+ integrations: "Manage signed inbound hooks and durable outbound webhooks.",
1653
+ spend: "Inspect attributed model spend.",
1654
+ proposals: "Create, inspect, and retry governed proposals.",
1655
+ "action-types": "Discover proposal payload contracts.",
1656
+ health: "Inspect project readiness and configuration gaps.",
1657
+ outcomes: "Inspect pull-request delivery outcomes and terminal fate.",
1658
+ catalog: "Discover the engines, models, permissions, and trigger types the platform supports.",
1659
+ };
1660
+
1661
+ const PLATFORM_EXAMPLES = {
1662
+ login: "facility login --url https://facility.example --key fak_… --profile production",
1663
+ projects: "facility projects create --name Payments --slug payments --idempotency-key project-payments",
1664
+ sessions: "facility sessions watch run_01H… --json",
1665
+ runs: "facility runs watch run_01H… --json",
1666
+ conversations: "facility conversations send evt_01H… \"Continue with the failing integration tests\"",
1667
+ github: "facility github trigger payments 421 --agent builder",
1668
+ inbox: "facility inbox decide prop_01H… approve --note \"reviewed\"",
1669
+ kickstart: "facility kickstart payments --repo acme/payments --checks \"pnpm test,pnpm typecheck\"",
1670
+ agents: "facility agents create payments --name builder --engine codex --contract item_01H… --schedule \"0 9 * * 1-5\"",
1671
+ budgets: "facility budgets set --scope project --project proj_01H… --period monthly --limit-cents 50000 --mode hard",
1672
+ registry: "facility registry version item_01H… --content-file ./contract.md --changelog \"Tighten acceptance\"",
1673
+ integrations: "facility integrations create --kind webhook --name ops --config '{\"url\":\"https://hooks.example/facility\"}'",
1674
+ audit: "facility audit verify --json",
1675
+ };
1676
+
1677
+ const PAGED_COMMANDS = new Set([
1678
+ "projects",
1679
+ "sessions",
1680
+ "runs",
1681
+ "inbox",
1682
+ "issues",
1683
+ "keys",
1684
+ "members",
1685
+ "roles",
1686
+ "repos",
1687
+ "agents",
1688
+ "providers",
1689
+ "budgets",
1690
+ "registry",
1691
+ "sandboxes",
1692
+ "tasks",
1693
+ "virtual-keys",
1694
+ "kb",
1695
+ "analytics",
1696
+ "integrations",
1697
+ "spend",
1698
+ "action-types",
1699
+ ]);
1700
+ const IDEMPOTENT_WRITE_COMMANDS = new Set([
1701
+ "projects",
1702
+ "sessions",
1703
+ "runs",
1704
+ "inbox",
1705
+ "issues",
1706
+ "kickstart",
1707
+ "upgrade",
1708
+ "keys",
1709
+ "members",
1710
+ "roles",
1711
+ "repos",
1712
+ "agents",
1713
+ "providers",
1714
+ "budgets",
1715
+ "registry",
1716
+ "sandboxes",
1717
+ "tasks",
1718
+ "virtual-keys",
1719
+ "kb",
1720
+ "integrations",
1721
+ "proposals",
1722
+ "conversations",
1723
+ "github",
1724
+ ]);
1725
+
1726
+ function platformHelp(command, stdout) {
1727
+ const line = PLATFORM_USAGE[command];
1728
+ if (!line) {
1729
+ stdout.write(`Unknown Facility command: ${command}\n`);
1730
+ return 1;
1731
+ }
1732
+ stdout.write(`\n ${bold(`facility ${command}`)}\n`);
1733
+ stdout.write(` ${dim(PLATFORM_DESCRIPTIONS[command] ?? "Facility platform command.")}\n\n`);
1734
+ stdout.write(` ${bold("Usage")}\n ${line}\n`);
1735
+ const forms = PLATFORM_FORMS[command] ?? [];
1736
+ if (forms.length) {
1737
+ stdout.write(`\n ${bold(forms.length === 1 ? "Command" : "Commands")}\n`);
1738
+ for (const form of forms) stdout.write(` facility ${form}\n`);
1739
+ }
1740
+ if (PLATFORM_EXAMPLES[command]) {
1741
+ stdout.write(`\n ${bold("Example")}\n ${PLATFORM_EXAMPLES[command]}\n`);
1742
+ }
1743
+ stdout.write(`\n ${bold("Global options")}\n`);
1744
+ stdout.write(" --profile <name> use a saved environment\n");
1745
+ stdout.write(" --json emit stable machine-readable JSON\n");
1746
+ stdout.write(" --timeout <seconds> set the request deadline (max 300)\n");
1747
+ stdout.write(" --help show this help\n");
1748
+ if (PAGED_COMMANDS.has(command)) {
1749
+ stdout.write(`\n ${bold("List options")}\n`);
1750
+ stdout.write(" --limit <1-200> maximum rows to return\n");
1751
+ stdout.write(" --offset <n> rows to skip\n");
1752
+ }
1753
+ if (IDEMPOTENT_WRITE_COMMANDS.has(command)) {
1754
+ stdout.write(`\n ${bold("Write option")}\n`);
1755
+ stdout.write(" --idempotency-key <key> safely replay a POST (8-200 characters)\n");
1756
+ }
1757
+ stdout.write("\n");
1758
+ return 0;
1759
+ }