failproofai 1.0.2-beta.6 → 1.0.2-beta.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +3 -3
  3. package/.next/standalone/.next/prerender-manifest.json +3 -3
  4. package/.next/standalone/.next/required-server-files.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  6. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  10. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +6 -6
  11. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  12. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  14. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  15. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  16. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  17. package/.next/standalone/.next/server/app/_not-found.rsc +14 -14
  18. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +14 -14
  19. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +13 -13
  20. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  21. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  22. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  23. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  24. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  25. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  26. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  30. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  32. package/.next/standalone/.next/server/app/index.html +1 -1
  33. package/.next/standalone/.next/server/app/index.rsc +14 -14
  34. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +13 -13
  35. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +14 -14
  36. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  37. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  38. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  39. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  40. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
  41. package/.next/standalone/.next/server/app/policies/page.js +2 -2
  42. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  43. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  44. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  45. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  46. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  47. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  48. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  49. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  50. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  51. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  52. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  53. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  54. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
  55. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  56. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  57. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
  58. package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +1 -1
  59. package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +1 -1
  60. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  61. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__013jr2b._.js +2 -2
  62. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01wy8d-._.js +2 -2
  63. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02npjtd._.js +2 -2
  64. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__07gm3zl._.js +12 -15
  65. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0da85px._.js +2 -2
  66. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ftmoxc._.js +2 -2
  67. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0mgrjnx._.js +10 -0
  68. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0oa1lav._.js +1 -1
  69. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0p-5p8u._.js +2 -2
  70. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s740oi._.js +2 -2
  71. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__128se8m._.js → [root-of-the-server]__1jjjg6g._.js} +2 -2
  72. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1tag8_-._.js → [root-of-the-server]__1opxw-7._.js} +1 -1
  73. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1p2otjt._.js +2 -2
  74. package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
  75. package/.next/standalone/.next/server/chunks/ssr/{_11fc-hb._.js → _0_nsohj._.js} +2 -2
  76. package/.next/standalone/.next/server/chunks/ssr/_1u8-lu2._.js +1 -1
  77. package/.next/standalone/.next/server/chunks/ssr/{_0no96tu._.js → _1uvml5y._.js} +1 -1
  78. package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +1 -1
  79. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  80. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  81. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  82. package/.next/standalone/.next/server/chunks/ssr/app_settings_settings-client_tsx_20lq-mq._.js +1 -1
  83. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  84. package/.next/standalone/.next/server/pages/404.html +1 -1
  85. package/.next/standalone/.next/server/pages/500.html +1 -1
  86. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  87. package/.next/standalone/.next/server/server-reference-manifest.json +19 -19
  88. package/.next/standalone/.next/static/chunks/{276n-edifojbd.js → 0icodhlwr9x_e.js} +2 -2
  89. package/.next/standalone/.next/static/chunks/{0p84npzxdgpc5.js → 0tvmd0p_1i_g6.js} +1 -1
  90. package/.next/standalone/.next/static/chunks/{3vu_7_eor1m-k.js → 1d6c85605lsgp.js} +1 -1
  91. package/.next/standalone/.next/static/chunks/{2jfskwd2u8o5r.js → 1d7o3wnt3h6se.js} +1 -1
  92. package/.next/standalone/.next/static/chunks/{0z7au3njlak0j.js → 1vzyn9h2kvj-i.js} +1 -1
  93. package/.next/standalone/.next/static/chunks/{0sdgnflvw8kta.js → 2-9lvhuto1iwo.js} +1 -1
  94. package/.next/standalone/.next/static/chunks/{0h-3zffoy-_ji.js → 3jt6n16p2qpex.js} +1 -1
  95. package/.next/standalone/.next/static/chunks/{0931avh-06lj5.js → 3swqstog1cd9u.js} +1 -1
  96. package/.next/standalone/.next/static/chunks/{1id7o44jjegf0.js → 3vqddiy9tvlwf.js} +1 -1
  97. package/.next/standalone/.next/static/chunks/{2pxr7nr1nyotn.js → 44oamw33aso19.js} +1 -1
  98. package/.next/standalone/package.json +9 -9
  99. package/.next/standalone/server.js +1 -1
  100. package/bin/failproofai.mjs +796 -679
  101. package/dist/cli.mjs +3747 -3845
  102. package/dist/worker.mjs +1 -1
  103. package/package.json +9 -9
  104. package/src/audit/cli.ts +40 -39
  105. package/src/hooks/backfill-cli.ts +1 -1
  106. package/src/hooks/configure-wizard.ts +24 -10
  107. package/src/hooks/manager.ts +41 -6
  108. package/src/hooks/tui.ts +173 -39
  109. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__15r6r7w._.js +0 -10
  110. /package/.next/standalone/.next/static/{mAYN9BOxWVFREnVp3yCHp → PKwyBPAHjy3DB8qqapaH4}/_buildManifest.js +0 -0
  111. /package/.next/standalone/.next/static/{mAYN9BOxWVFREnVp3yCHp → PKwyBPAHjy3DB8qqapaH4}/_clientMiddlewareManifest.js +0 -0
  112. /package/.next/standalone/.next/static/{mAYN9BOxWVFREnVp3yCHp → PKwyBPAHjy3DB8qqapaH4}/_ssgManifest.js +0 -0
@@ -296,6 +296,76 @@ if (hookIdx >= 0) {
296
296
  * CliError → clean message, no stack trace, exit exitCode (1 or 2)
297
297
  * Error → unexpected; shows message only, exits 2
298
298
  */
299
+ /**
300
+ * Every `--help` in this file, and the index itself, drawn by ONE renderer.
301
+ *
302
+ * They used to be twelve template literals: `USAGE` on one screen and `Usage:`
303
+ * on the next, a description column hand-counted per screen, no version on any
304
+ * of them, and no colour on any of them while the index they were reached from
305
+ * had all three. The words are still each screen's own — this owns the shape,
306
+ * so a screen cannot drift out of the family without editing the family.
307
+ *
308
+ * Capped at 80 columns by `helpOptsFor`, so help reads the same in a maximised
309
+ * window as in a tmux pane, and narrows on a terminal smaller than that.
310
+ */
311
+ /**
312
+ * Lines a module ALREADY laid out with the kit — `harness`, `publish`,
313
+ * `policies add`, the pack lane — printed with the outer margins every other
314
+ * screen gets. They used to go out one `console.log` at a time, which is the
315
+ * one thing `printBlock` exists to own: the block arrived flush against the
316
+ * prompt above it while every neighbouring command was breathing.
317
+ */
318
+ async function printLines(lines, ok = true) {
319
+ const { printBlock } = await import("../src/hooks/tui");
320
+ printBlock(ok ? process.stdout : process.stderr, lines);
321
+ }
322
+
323
+ /**
324
+ * What an action surface prints when it is done: a heading naming the command,
325
+ * then the lines it produced, indented and margined like every other screen.
326
+ *
327
+ * The modules keep returning bare facts — `runFlushCommand` says "2 batches
328
+ * spooled", not how to draw it — so the presentation lives in exactly one
329
+ * place instead of five, and a flush report and a `policies` listing stop
330
+ * looking like output from two different programs.
331
+ *
332
+ * Lines that already carry their own leading space are passed through: those
333
+ * are sub-items a module laid out on purpose, and re-wrapping them would
334
+ * flatten the structure they were expressing.
335
+ */
336
+ async function printReport(command, lines, opts = {}) {
337
+ const { title, wrap, stack, printBlock, optsFor, INDENT, brandAnsi, ANSI_RESET } =
338
+ await import("../src/hooks/tui");
339
+ const ok = opts.ok !== false;
340
+ const stream = ok ? process.stdout : process.stderr;
341
+ const o = optsFor(stream);
342
+ // `\`like this\`` becomes pink, and loses the backticks. These messages name
343
+ // the command to run next more often than not, and pink is what you type
344
+ // everywhere else on the CLI now — the help screens, the bullets, the next
345
+ // steps. Applied AFTER wrapping, because an escape sequence has no width and
346
+ // colouring first would make every wrap measure the wrong length.
347
+ const paint = (line) =>
348
+ o.color && (line.match(/`/g) || []).length % 2 === 0
349
+ ? line.replace(/`([^`]+)`/g, `${brandAnsi("pink")}$1${ANSI_RESET}`)
350
+ : line;
351
+ const body = [];
352
+ for (const line of lines) {
353
+ if (line.trim() === "") body.push("");
354
+ else if (line.startsWith(" ")) body.push(paint(`${INDENT}${line}`));
355
+ else {
356
+ for (const w of wrap(line, Math.max(20, o.cols - INDENT.length * 2))) {
357
+ body.push(paint(`${INDENT}${w}`));
358
+ }
359
+ }
360
+ }
361
+ printBlock(stream, stack(title(`failproofai ${command}`, opts.meta, o), body));
362
+ }
363
+
364
+ async function printHelp(spec) {
365
+ const { helpScreen, helpOptsFor, printBlock } = await import("../src/hooks/tui");
366
+ printBlock(process.stdout, helpScreen({ version, ...spec }, helpOptsFor(process.stdout)));
367
+ }
368
+
299
369
  async function runCli() {
300
370
  // --help / -h (only when not inside a subcommand that handles its own --help)
301
371
  // `update` and `migrate` were missing here, so `failproofai update --help`
@@ -322,25 +392,37 @@ async function runCli() {
322
392
  // string — so it gets a topic here rather than a line on the index, where a
323
393
  // machine-facing flag would only take space from the human-facing commands.
324
394
  if (helpTopic === "hook") {
325
- console.log(`
326
- failproofai --hook <event> [--cli <name>]
327
-
328
- The entry point your agent CLI spawns, once per tool call. You do not run this;
329
- \`failproofai config\` writes it into each CLI's hook configuration for you.
330
-
331
- --hook <event> PreToolUse, PostToolUse, UserPromptSubmit, Stop,
332
- SubagentStop, SessionStart, SessionEnd, PreCompact,
333
- Notification, PermissionRequest
334
- --cli <name> claude, codex, copilot, cursor, opencode, pi, hermes,
335
- openclaw, factory, devin, antigravity, goose.
336
- Defaults to claude. It selects which payload shape to
337
- expect: each CLI names its events and tool arguments
338
- differently, and failproofai canonicalizes them.
339
-
340
- It reads the event as JSON on stdin and answers on stdout, in whatever shape
341
- that CLI honours. Exit codes and response shapes differ per CLI by necessity
342
- see docs.befailproof.ai. Denials are reported to the agent, never to you.
343
- `.trimStart());
395
+ await printHelp({
396
+ command: "--hook",
397
+ tagline: "the entry point your agent CLI spawns, once per tool call",
398
+ sections: [
399
+ {
400
+ label: "usage",
401
+ entries: [["failproofai --hook <event> [--cli <name>]"]],
402
+ after: [
403
+ "You do not run this; `failproofai config` writes it into each CLI's",
404
+ "hook configuration for you.",
405
+ ],
406
+ },
407
+ {
408
+ label: "options",
409
+ entries: [
410
+ ["--hook <event>", "PreToolUse, PostToolUse, UserPromptSubmit, Stop, SubagentStop, SessionStart, SessionEnd, PreCompact, Notification, PermissionRequest"],
411
+ ["--cli <name>", "claude, codex, copilot, cursor, opencode, pi, hermes, openclaw, factory, devin, antigravity, goose. Defaults to claude. It selects which payload shape to expect: each CLI names its events and tool arguments differently, and failproofai canonicalizes them."],
412
+ ],
413
+ },
414
+ {
415
+ label: "how it answers",
416
+ lines: [
417
+ "It reads the event as JSON on stdin and answers on stdout, in whatever",
418
+ "shape that CLI honours. Exit codes and response shapes differ per CLI by",
419
+ "necessity — see docs.befailproof.ai. Denials are reported to the agent,",
420
+ "never to you.",
421
+ ],
422
+ },
423
+ ],
424
+ });
425
+ process.exit(0);
344
426
  process.exit(0);
345
427
  }
346
428
  // Canonicalize the topic the same way a typed command is canonicalized, so
@@ -369,62 +451,53 @@ see docs.befailproof.ai. Denials are reported to the agent, never to you.
369
451
  if (extraArgs.length > 0) {
370
452
  throw new CliError(`Unexpected argument: ${extraArgs[0]}\nRun \`failproofai help\` for usage.`);
371
453
  }
372
- // Built row by row rather than as one literal, so the command column can be
373
- // painted without the escape sequences becoming part of the layout. Colour
374
- // is decoration here and never meaning: the column position already says
375
- // which half is a command, so this reads identically under NO_COLOR, piped
376
- // to a file, or on a terminal that has never heard of 24-bit.
377
- const { brandAnsi, ANSI_RESET, ANSI_BOLD, ANSI_DIM, colorsEnabled } =
378
- await import("../src/hooks/tui");
379
- const tint = colorsEnabled(process.stdout);
380
- const pink = (t) => (tint ? `${brandAnsi("pink")}${t}${ANSI_RESET}` : t);
381
- const mint = (t) => (tint ? `${brandAnsi("guide")}${t}${ANSI_RESET}` : t);
382
- const dim = (t) => (tint ? `${ANSI_DIM}${t}${ANSI_RESET}` : t);
383
- const bold = (t) => (tint ? `${ANSI_BOLD}${t}${ANSI_RESET}` : t);
384
-
385
- // Padded BEFORE painting: an escape sequence has no width, so padding a
386
- // coloured string right-aligns nothing and the description column drifts.
387
- const row = (cmd, desc) => ` ${pink(cmd.padEnd(18))}${desc}`;
388
- // 78 visible columns, matching the body. Computed from the VISIBLE prefix —
389
- // `" \u2501\u2501 "` is 5, the label is `${n} ${title}`, then one space — because the
390
- // painted string's length counts escape bytes and would shorten every rule
391
- // by however many the terminal happened to need.
392
- const section = (n, title) => {
393
- const label = `${n} ${title}`;
394
- const fill = 78 - (5 + label.length + 1);
395
- return dim(" \u2501\u2501 ") + bold(label) + " " + dim("\u2501".repeat(Math.max(0, fill)));
396
- };
397
-
398
- console.log([
399
- `${bold("fa")}${pink("il")}${bold("proofai")} v${version}` +
400
- `${" ".repeat(23)}${dim("-v version -h this screen")}`,
401
- ` ${dim("Usage")} failproofai <command> [options] ${dim("Detail")} failproofai help <command>`,
402
- section(1, "GET IT RUNNING"),
403
- row("config, setup", "Interactive setup: agents, daemon, cloud"),
404
- row("config --connect", "Connect to Cloud with no prompts: --token <key>"),
405
- row("update", "Finish an npm upgrade: migrate home, match daemon"),
406
- section(2, "CHOOSE WHAT IT ENFORCES"),
407
- row("policies", "Every policy on this machine, and whether it is on"),
408
- row("policies add", "Pick from a list, or name one: <policy> or <owner>/<repo>"),
409
- row("policies remove", "Turn one policy off, or uninstall a whole pack"),
410
- row("policies show", "What a pack contains, before you install it"),
411
- row("publish", "Ship your own policies as a pack anyone can install"),
412
- section(3, "SEE WHAT IT CAUGHT"),
413
- row("(no args)", "Open the policy dashboard on localhost:8020"),
414
- row("audit", "Scan your agents' history, then open the audit view"),
415
- row("config --status", "Cloud connection, daemon version, pause state"),
416
- row("harness", "Extra paths to capture agent sessions from"),
417
- section(4, "PUT IT RIGHT"),
418
- row("config --pause", "Pause enforcement for one session (30m; max 8h)"),
419
- row("flush --wait", "Send everything spooled now, and block until it lands"),
420
- row("backfill --since", "Re-send history already read past: 30d, 6m, YYYY-MM-DD"),
421
- row("policies -i / -u", "Wire failproofai into your agent CLIs, or unwire"),
422
- row("migrate", "Run pending ~/.failproofai layout migrations"),
423
- row("uninstall", "Remove hooks and daemon, BEFORE npm rm -g failproofai"),
424
- "",
425
- ` ${dim("Docs")} ${mint("docs.befailproof.ai")} ${dim("Code")} github.com/failproofai/failproofai`,
426
- ` ${dim("Chat")} ${mint("discord.befailproof.ai")} ${dim("Forum")} reddit.com/r/failproofai`,
427
- ].join("\n"));
454
+ // The index is DATA, not a template literal: one renderer draws it and the
455
+ // eleven `<command> --help` screens, so a row added here cannot end up in a
456
+ // different dialect from the screen it points at. Colour is decoration and
457
+ // never meaning the column position already says which half is a command,
458
+ // so this reads identically under NO_COLOR, piped to a file, or on a
459
+ // terminal that has never heard of 24-bit.
460
+ await printHelp({
461
+ tagline: "guardrails for the coding agents on this machine",
462
+ sections: [
463
+ {
464
+ label: "get it running",
465
+ entries: [
466
+ ["config", "Set this machine up: agents, daemon, cloud"],
467
+ ["config --token <key>", "Set up and connect to Cloud, no questions asked"],
468
+ ["update", "Finish an npm upgrade: migrate home, match the daemon"],
469
+ ],
470
+ },
471
+ {
472
+ label: "choose what it enforces",
473
+ entries: [
474
+ ["policies", "Every policy on this machine, and whether it is on"],
475
+ ["policies add", "Turn one on, or install a pack: <owner>/<repo>"],
476
+ ["policies remove", "Turn one off, or uninstall a whole pack"],
477
+ ["publish", "Ship your own policies as a pack anyone can install"],
478
+ ],
479
+ },
480
+ {
481
+ label: "see what it caught",
482
+ entries: [
483
+ ["(no args)", "Open the policy dashboard on localhost:8020"],
484
+ ["audit", "Scan your agents' history, then open the audit view"],
485
+ ["config --status", "Cloud connection, daemon version, pause state"],
486
+ ],
487
+ },
488
+ // Named, not described. Everything here is real and reachable through
489
+ // `help <command>`; none of it is what anyone types on the first day,
490
+ // and a full row each is what made this screen read as a manual.
491
+ {
492
+ label: "less often",
493
+ lines: ["policies show, harness, flush, backfill, migrate, uninstall, config --pause"],
494
+ },
495
+ ],
496
+ footer: [
497
+ "failproofai <command> [options] failproofai help <command> for detail",
498
+ "docs.befailproof.ai discord.befailproof.ai -h this screen -v version",
499
+ ],
500
+ });
428
501
  process.exit(0);
429
502
  }
430
503
 
@@ -574,27 +647,35 @@ see docs.befailproof.ai. Denials are reported to the agent, never to you.
574
647
  if (args[0] === "flush") {
575
648
  const subArgs = args.slice(1);
576
649
  if (subArgs.includes("--help") || subArgs.includes("-h")) {
577
- console.log(`
578
- failproofai flush — deliver what is already spooled, now
579
-
580
- USAGE
581
- failproofai flush [--wait] [--timeout <secs>]
582
-
583
- WHY
584
- The collector is unhurried on purpose: a batch is swept once it is older than
585
- two minutes, at most 64 per pass, on a 60-second cadence. That pacing keeps a
586
- backlog from stampeding the server, and it is exactly wrong when you are
587
- standing at a dashboard waiting to see your own events "not delivered yet"
588
- and "not working" look identical from there.
589
-
590
- This asks the daemon to make a pass right now, with no minimum age and no
591
- per-pass cap. It re-sends nothing: only batches already spooled and not yet
592
- delivered. For history the collector has already read past, use \`backfill\`.
593
-
594
- OPTIONS
595
- --wait Block until the spool drains, or --timeout elapses.
596
- --timeout <secs> How long --wait waits. Default: 60.
597
- `);
650
+ await printHelp({
651
+ command: "flush",
652
+ tagline: "deliver what is already spooled, now",
653
+ sections: [
654
+ { label: "usage", entries: [["failproofai flush [--wait] [--timeout <secs>]"]] },
655
+ {
656
+ label: "why",
657
+ lines: [
658
+ "The collector is unhurried on purpose: a batch is swept once it is older",
659
+ "than two minutes, at most 64 per pass, on a 60-second cadence. That pacing",
660
+ "keeps a backlog from stampeding the server, and it is exactly wrong when",
661
+ "you are standing at a dashboard waiting to see your own events — \"not",
662
+ "delivered yet\" and \"not working\" look identical from there.",
663
+ "",
664
+ "This asks the daemon to make a pass right now, with no minimum age and no",
665
+ "per-pass cap. It re-sends nothing: only batches already spooled and not",
666
+ "yet delivered. For history the collector has already read past, use",
667
+ "`failproofai backfill`.",
668
+ ],
669
+ },
670
+ {
671
+ label: "options",
672
+ entries: [
673
+ ["--wait", "Block until the spool drains, or --timeout elapses."],
674
+ ["--timeout <secs>", "How long --wait waits. Default: 60."],
675
+ ],
676
+ },
677
+ ],
678
+ });
598
679
  process.exit(0);
599
680
  }
600
681
 
@@ -623,10 +704,7 @@ OPTIONS
623
704
  lastSubcommand = "flush";
624
705
  const { runFlushCommand } = await import("../src/hooks/flush-cli");
625
706
  const result = await runFlushCommand({ wait: subArgs.includes("--wait"), timeoutSecs });
626
- for (const line of result.lines) {
627
- if (result.exitCode === 0) console.log(line);
628
- else console.error(line);
629
- }
707
+ await printReport("flush", result.lines, { ok: result.exitCode === 0 });
630
708
  await track("cli_flush", {
631
709
  ok: result.exitCode === 0,
632
710
  waited: subArgs.includes("--wait"),
@@ -640,29 +718,39 @@ OPTIONS
640
718
  if (args[0] === "backfill") {
641
719
  const subArgs = args.slice(1);
642
720
  if (subArgs.includes("--help") || subArgs.includes("-h")) {
643
- console.log(`
644
- failproofai backfill — re-send history the collector has already read past
645
-
646
- USAGE
647
- failproofai backfill [--since <when>] [--dry-run]
648
-
649
- WHY
650
- The collector never re-reads a file it has a cursor for, which is right until
651
- the dashboard's data is cleared, a machine is re-enrolled, or cursors advanced
652
- before there was anywhere to send. Then the history exists on disk and nowhere
653
- else, with no way to ask for it again.
654
-
655
- Re-sending is safe: redaction is deterministic, so a re-sent event hashes
656
- identically to its first send and collapses into the row already there.
657
-
658
- OPTIONS
659
- --since <when> How far back. \`30d\`, \`6m\`, or \`YYYY-MM-DD\`.
660
- Default: 30 days.
661
- --dry-run Report what would be re-read and change nothing.
662
-
663
- Which streams are sent follows [collector] in ~/.failproofai/config.toml —
664
- a backfill never sends something your config says you do not want.
665
- `);
721
+ await printHelp({
722
+ command: "backfill",
723
+ tagline: "re-send history the collector has already read past",
724
+ sections: [
725
+ { label: "usage", entries: [["failproofai backfill [--since <when>] [--dry-run]"]] },
726
+ {
727
+ label: "why",
728
+ lines: [
729
+ "The collector never re-reads a file it has a cursor for, which is right",
730
+ "until the dashboard's data is cleared, a machine is re-enrolled, or",
731
+ "cursors advanced before there was anywhere to send. Then the history",
732
+ "exists on disk and nowhere else, with no way to ask for it again.",
733
+ "",
734
+ "Re-sending is safe: redaction is deterministic, so a re-sent event hashes",
735
+ "identically to its first send and collapses into the row already there.",
736
+ ],
737
+ },
738
+ {
739
+ label: "options",
740
+ entries: [
741
+ ["--since <when>", "How far back: `30d`, `6m`, `YYYY-MM-DD`. Default 30 days."],
742
+ ["--dry-run", "Report what would be re-read and change nothing."],
743
+ ],
744
+ },
745
+ {
746
+ label: "note",
747
+ lines: [
748
+ "Which streams are sent follows [collector] in ~/.failproofai/config.toml —",
749
+ "a backfill never sends something your config says you do not want.",
750
+ ],
751
+ },
752
+ ],
753
+ });
666
754
  process.exit(0);
667
755
  }
668
756
 
@@ -697,10 +785,7 @@ OPTIONS
697
785
  lastSubcommand = "backfill";
698
786
  const { runBackfillCommand } = await import("../src/hooks/backfill-cli");
699
787
  const result = runBackfillCommand({ sinceMs, dryRun: subArgs.includes("--dry-run") });
700
- for (const line of result.lines) {
701
- if (result.exitCode === 0) console.log(line);
702
- else console.error(line);
703
- }
788
+ await printReport("backfill", result.lines, { ok: result.exitCode === 0 });
704
789
  await track("cli_backfill", { ok: result.exitCode === 0, dry_run: subArgs.includes("--dry-run"), explicit_since: sinceIdx >= 0 });
705
790
  lastSubcommand = null;
706
791
  await exitAfterFlush(result.exitCode);
@@ -713,156 +798,102 @@ OPTIONS
713
798
  // nothing else — no root, no daemon call — so it works on a machine whose
714
799
  // daemon is stopped, which is when someone is most likely to be fixing what
715
800
  // it captures.
716
- // pack list | add | remove
717
- //
718
- // Policies that did not ship compiled into this build. Fetches over the
719
- // network, so it is a CLI command only and is never reachable from the hook
720
- // path.
721
- if (args[0] === "pack") {
722
- const subArgs = args.slice(1);
723
- if (subArgs.includes("--help") || subArgs.includes("-h")) {
724
- console.log(`
725
- failproofai pack — install policy packs published as GitHub releases
726
-
727
- Usage:
728
- failproofai policies add FailproofAI/policies the Failproof AI policies
729
- failproofai policies add FailproofAI/policies --policy <a,b> just these
730
- failproofai policies add FailproofAI/policies --category <x,y> whole categories
731
- failproofai policies add FailproofAI/policies --all everything in it
732
- failproofai pack list packs installed here
733
- failproofai pack list <source> what a pack out there contains
734
- failproofai pack add <source> [--policy <a,b>] [--category <x,y>] [--all]
735
- failproofai pack remove <publisher/name>
736
- failproofai pack build <entry.mjs> --id <publisher/name> --version <v>
737
-
738
- A pack is one entry artifact plus a manifest, verified against the release's
739
- SHA256SUMS at install time. The digest is recorded, and re-verified before every
740
- import — so a pack cannot change under this machine after you installed it.
741
-
742
- <source> is any GitHub repo publishing a pack — ours included:
743
- acme/support-agent newest release, pinned to its exact tag
744
- acme/support-agent@v2.1.0 that release
745
- github:acme/support-agent@v2.1.0 same, explicit
746
- https://github.com/acme/support-agent/releases/tag/v2.1.0
747
-
748
- Naming no tag installs the newest release AND pins it, so what gets recorded
749
- always names one version — running the same command later can install something
750
- different, and it will tell you the tag it chose.
751
-
752
- By default you get the pack's OWN defaults — the policies its author marked
753
- safe to switch on unattended — not everything it contains. Take more, or less:
754
-
755
- --category x,y Take whole categories (see: failproofai pack list)
756
- --only a,b Take exactly these policies
757
- --all Take everything the pack contains
758
-
759
- Re-adding at a newer version keeps whatever you chose rather than switching the
760
- rest back on.
761
-
762
- Examples:
763
- failproofai pack add FailproofAI/policies
764
- failproofai policies add FailproofAI/policies --category sanitize,git
765
- failproofai pack add github:acme/support-agent@v2.1.0 --only block-refunds
766
- failproofai pack remove acme/support-agent
767
-
768
- Publishing: pack build writes failproofai-pack.json, failproofai-pack.mjs and
769
- SHA256SUMS. Attach all three to a GitHub release; anyone then runs
770
- failproofai pack add <owner>/<repo>
771
-
772
- Offline: FAILPROOFAI_NO_DOWNLOAD=1 refuses to fetch, while packs already
773
- installed keep enforcing. FAILPROOFAI_PACK_BASE_URL points the whole thing at a
774
- mirror instead of github.com.
775
- `.trimStart());
776
- process.exit(0);
777
- }
778
-
779
- lastSubcommand = "pack";
780
- const { runPackCommand } = await import("../src/hooks/pack-cli");
781
- const result = await runPackCommand(subArgs);
782
- for (const line of result.lines) {
783
- if (result.exitCode === 0) console.log(line);
784
- else console.error(line);
785
- }
786
- await track("cli_pack", {
787
- ok: result.exitCode === 0,
788
- // The subcommand only — never the pack id, source or policy names. A pack
789
- // source is a value the user typed, and a third-party pack name is a
790
- // publisher-controlled string; we send shape, never value.
791
- sub: ["list", "add", "remove"].includes(subArgs[0]) ? subArgs[0] : "unknown",
792
- });
793
- lastSubcommand = null;
794
- await exitAfterFlush(result.exitCode);
795
- return;
796
- }
801
+ // There is no `pack` branch here on purpose. `pack`, `policy` and `p` are
802
+ // rewritten to `policies` at the top of this file, above every dispatch, so
803
+ // `args[0]` can never be "pack" by the time this runs. One lived here anyway
804
+ // for a while, unreachable, carrying a sixty-line help screen that still
805
+ // advertised `pack list`, `pack add` and `pack build` — three spellings this
806
+ // CLI no longer has — in a third heading dialect nothing else used. Nobody
807
+ // could reach it to notice. The pack lane is entered from
808
+ // `policies add|remove|show` below, which is the only door it has.
797
809
 
798
810
  if (args[0] === "harness") {
799
811
  const subArgs = args.slice(1);
800
812
  if (subArgs.length === 0 || subArgs.includes("--help") || subArgs.includes("-h")) {
801
- console.log(`
802
- failproofai harness — capture sessions from more than one location per agent CLI
803
-
804
- USAGE
805
- failproofai harness list [<harness>]
806
- failproofai harness add-path <harness> [<label>=]<path>
807
- failproofai harness remove-path <harness> <path|label>
808
-
809
- WHY
810
- Each agent CLI is watched wherever its own installer put it — ~/.claude/projects,
811
- ~/.hermes/state.db, and so on. That misses every other arrangement: a second
812
- profile, a mounted team share, a container's home beside the host's, an agent
813
- an operator moved. Those hold real sessions and nothing collects them.
814
-
815
- THE LABEL
816
- An entry is \`<path>\` or \`<label>=<path>\`. The label namespaces agent ids as
817
- <label>-<agentId>, and it matters: two locations holding the same project
818
- derive the SAME id (it comes from the cwd inside the transcript, identical in
819
- both copies), so without a label they merge into one agent whose sessions
820
- interleave from two machines' worth of history. Omit it and one is derived
821
- from the folder name.
822
-
823
- HARNESSES
824
- claude, codex, copilot, openclaw, pi, factory, antigravity, cursor,
825
- goose, opencode, devin, hermes
826
-
827
- \`claude\` covers subagent transcripts too they live under the same root.
828
-
829
- NOTES
830
- Session collection must be on for any of this to be read; extra paths live
831
- under "collector" in ~/.failproofai/config.json like the default ones do.
832
- • A path overlapping one already captured is REFUSED by the daemon at startup
833
- rather than collected twice under two ids. It says so in the journal.
834
- • Two entries sharing a LABEL are refused too: they would share one cursor
835
- directory, whose whole map is written at once, so each would clobber the
836
- other's watermark and re-read from zero after every restart.
837
- Takes effect within seconds the daemon re-reads config.json on an interval
838
- and cycles its collector. No restart, no sudo.
839
-
840
- EXAMPLES
841
- failproofai harness add-path claude work=/srv/team/.claude/projects
842
- failproofai harness add-path hermes prod=/srv/hermes-prod/state.db
843
- failproofai harness add-path codex /mnt/other-home/.codex/sessions
844
- failproofai harness list
845
- failproofai harness remove-path claude work
846
-
847
- One home per person label them, or both derive the same folder-name label
848
- and the second is refused:
849
- failproofai harness add-path openclaw user1=/srv/.openclaw-user1
850
- failproofai harness add-path openclaw user2=/srv/.openclaw-user2
851
-
852
- Containers: FAILPROOFAI_<HARNESS>_EXTRA_PATHS replaces the file's entries.
853
- FAILPROOFAI_OPENCLAW_EXTRA_PATHS="user1=/srv/.openclaw-user1,user2=/srv/.openclaw-user2"
854
-
855
- `.trimStart());
813
+ await printHelp({
814
+ command: "harness",
815
+ tagline: "capture sessions from more than one location per agent CLI",
816
+ sections: [
817
+ {
818
+ label: "usage",
819
+ entries: [
820
+ ["failproofai harness list [<harness>]"],
821
+ ["failproofai harness add-path <harness> [<label>=]<path>"],
822
+ ["failproofai harness remove-path <harness> <path|label>"],
823
+ ],
824
+ },
825
+ {
826
+ label: "why",
827
+ lines: [
828
+ "Each agent CLI is watched wherever its own installer put it —",
829
+ "~/.claude/projects, ~/.hermes/state.db, and so on. That misses every",
830
+ "other arrangement: a second profile, a mounted team share, a container's",
831
+ "home beside the host's, an agent an operator moved. Those hold real",
832
+ "sessions and nothing collects them.",
833
+ ],
834
+ },
835
+ {
836
+ label: "the label",
837
+ lines: [
838
+ "An entry is `<path>` or `<label>=<path>`. The label namespaces agent ids",
839
+ "as <label>-<agentId>, and it matters: two locations holding the same",
840
+ "project derive the SAME id (it comes from the cwd inside the transcript,",
841
+ "identical in both copies), so without a label they merge into one agent",
842
+ "whose sessions interleave from two machines' worth of history. Omit it",
843
+ "and one is derived from the folder name.",
844
+ ],
845
+ },
846
+ {
847
+ label: "harnesses",
848
+ lines: [
849
+ "claude, codex, copilot, openclaw, pi, factory, antigravity, cursor,",
850
+ "goose, opencode, devin, hermes",
851
+ "",
852
+ "`claude` covers subagent transcripts too — they live under the same root.",
853
+ ],
854
+ },
855
+ {
856
+ label: "notes",
857
+ lines: [
858
+ "• Session collection must be on for any of this to be read; extra paths",
859
+ " live under \"collector\" in ~/.failproofai/config.json like the defaults.",
860
+ "• A path overlapping one already captured is REFUSED by the daemon at",
861
+ " startup rather than collected twice under two ids. It says so in the",
862
+ " journal.",
863
+ "• Two entries sharing a LABEL are refused too: they would share one",
864
+ " cursor directory, whose whole map is written at once, so each would",
865
+ " clobber the other's watermark and re-read from zero after a restart.",
866
+ "• Takes effect within seconds — the daemon re-reads config.json on an",
867
+ " interval and cycles its collector. No restart, no sudo.",
868
+ ],
869
+ },
870
+ {
871
+ label: "examples",
872
+ lines: [
873
+ "failproofai harness add-path claude work=/srv/team/.claude/projects",
874
+ "failproofai harness add-path hermes prod=/srv/hermes-prod/state.db",
875
+ "failproofai harness add-path codex /mnt/other-home/.codex/sessions",
876
+ "failproofai harness list",
877
+ "failproofai harness remove-path claude work",
878
+ "",
879
+ "One home per person — label them, or both derive the same folder-name",
880
+ "label and the second is refused:",
881
+ "failproofai harness add-path openclaw user1=/srv/.openclaw-user1",
882
+ "failproofai harness add-path openclaw user2=/srv/.openclaw-user2",
883
+ "",
884
+ "Containers: FAILPROOFAI_<HARNESS>_EXTRA_PATHS replaces the file's entries.",
885
+ "FAILPROOFAI_OPENCLAW_EXTRA_PATHS=\"user1=/srv/.a,user2=/srv/.b\"",
886
+ ],
887
+ },
888
+ ],
889
+ });
856
890
  process.exit(0);
857
891
  }
858
892
 
859
893
  lastSubcommand = "harness";
860
894
  const { runHarnessCommand } = await import("../src/hooks/harness-cli");
861
895
  const result = runHarnessCommand(subArgs);
862
- for (const line of result.lines) {
863
- if (result.exitCode === 0) console.log(line);
864
- else console.error(line);
865
- }
896
+ await printLines(result.lines, result.exitCode === 0);
866
897
  await track("cli_harness", {
867
898
  ok: result.exitCode === 0,
868
899
  // The subcommand only — never the harness name, the label or the path.
@@ -884,30 +915,38 @@ EXAMPLES
884
915
  if (args[0] === "migrate") {
885
916
  const subArgs = args.slice(1);
886
917
  if (subArgs.includes("--help") || subArgs.includes("-h")) {
887
- console.log(`
888
- failproofai migrate — bring ~/.failproofai up to the layout this version speaks
889
-
890
- USAGE
891
- failproofai migrate [--dry-run]
892
-
893
- WHY
894
- npm cannot update an installed package on its own, so a machine can sit on an
895
- old version for months and then jump several layouts at once. This runs the
896
- steps for that jump in order, keyed on the LAYOUT recorded in
897
- ~/.failproofai/VERSION rather than on the npm version so skipping thirty
898
- releases with no layout change runs nothing at all.
899
-
900
- It normally happens by itself, on the first command after an upgrade. This is
901
- for running it deliberately, and for seeing what it would do first.
902
-
903
- Your settings, cloud enrolment, policy selection, your own policy files,
904
- decision history and undelivered events are carried across, not removed. The
905
- irreplaceable files are copied to migrations/backup-layout<n>/ before anything
906
- runs, and every step is recorded in migrations/applied.json.
907
-
908
- OPTIONS
909
- --dry-run Print the steps and the files that would be saved. Change nothing.
910
- `);
918
+ await printHelp({
919
+ command: "migrate",
920
+ tagline: "bring ~/.failproofai up to the layout this version speaks",
921
+ sections: [
922
+ { label: "usage", entries: [["failproofai migrate [--dry-run]"]] },
923
+ {
924
+ label: "why",
925
+ lines: [
926
+ "npm cannot update an installed package on its own, so a machine can sit",
927
+ "on an old version for months and then jump several layouts at once. This",
928
+ "runs the steps for that jump in order, keyed on the LAYOUT recorded in",
929
+ "~/.failproofai/VERSION rather than on the npm version so skipping",
930
+ "thirty releases with no layout change runs nothing at all.",
931
+ "",
932
+ "It normally happens by itself, on the first command after an upgrade.",
933
+ "This is for running it deliberately, and for seeing what it would do",
934
+ "first.",
935
+ "",
936
+ "Your settings, cloud enrolment, policy selection, your own policy files,",
937
+ "decision history and undelivered events are carried across, not removed.",
938
+ "The irreplaceable files are copied to migrations/backup-layout<n>/ before",
939
+ "anything runs, and every step is recorded in migrations/applied.json.",
940
+ ],
941
+ },
942
+ {
943
+ label: "options",
944
+ entries: [
945
+ ["--dry-run", "Print the steps and the files it would save. Change nothing."],
946
+ ],
947
+ },
948
+ ],
949
+ });
911
950
  process.exit(0);
912
951
  }
913
952
 
@@ -929,11 +968,12 @@ OPTIONS
929
968
  // data is fine and an upgrade would read it, so migrating "forward" from it
930
969
  // is not a thing that exists.
931
970
  if (state.kind === "future") {
932
- console.error(
933
- `This machine's failproofai directory was written by a newer version (layout ${state.found};`,
934
- );
935
- console.error(`this build speaks ${LAYOUT_VERSION}). Upgrade rather than migrate:`);
936
- console.error(` npm install -g failproofai@latest`);
971
+ await printReport("migrate", [
972
+ `This machine's failproofai directory was written by a newer version ` +
973
+ `(layout ${state.found}; this build speaks ${LAYOUT_VERSION}). Upgrade rather than migrate:`,
974
+ "",
975
+ " npm install -g failproofai@latest",
976
+ ], { ok: false, meta: `layout ${state.found}` });
937
977
  await track("cli_migrate", { ok: false, reason: "future_layout" });
938
978
  lastSubcommand = null;
939
979
  await exitAfterFlush(1);
@@ -942,7 +982,7 @@ OPTIONS
942
982
 
943
983
  const from = state.kind === "stale" ? state.found : LAYOUT_VERSION;
944
984
  if (subArgs.includes("--dry-run")) {
945
- for (const line of describePlan(from)) console.log(line);
985
+ await printReport("migrate", describePlan(from), { meta: "dry run" });
946
986
  await track("cli_migrate", { ok: true, dry_run: true, from });
947
987
  lastSubcommand = null;
948
988
  await exitAfterFlush(0);
@@ -950,7 +990,7 @@ OPTIONS
950
990
  }
951
991
 
952
992
  if (state.kind !== "stale") {
953
- console.log(`Already at layout ${LAYOUT_VERSION}. Nothing to migrate.`);
993
+ await printReport("migrate", [`Already at layout ${LAYOUT_VERSION}. Nothing to migrate.`]);
954
994
  await track("cli_migrate", { ok: true, from, steps: 0 });
955
995
  lastSubcommand = null;
956
996
  await exitAfterFlush(0);
@@ -958,16 +998,21 @@ OPTIONS
958
998
  }
959
999
 
960
1000
  const run = runMigrations(from);
961
- for (const step of run.steps) {
962
- console.log(`${step.ok ? "migrated" : "FAILED "} layout ${step.from} → ${step.to}`);
963
- }
964
- if (run.backedUp.length > 0) {
965
- console.log(`Saved first: ${run.backedUp.join(", ")}`);
966
- }
1001
+ const report = run.steps.map(
1002
+ (step) => ` ${step.ok ? "migrated" : "FAILED "} layout ${step.from} → ${step.to}`,
1003
+ );
1004
+ if (run.backedUp.length > 0) report.push("", `Saved first: ${run.backedUp.join(", ")}`);
967
1005
  if (run.failed) {
968
- console.error(`Step ${run.failed.from} → ${run.failed.to} did not finish: ${run.failed.error}`);
969
- console.error(`The home is still marked layout ${from} and will be retried.`);
1006
+ report.push(
1007
+ "",
1008
+ `Step ${run.failed.from} → ${run.failed.to} did not finish: ${run.failed.error}`,
1009
+ `The home is still marked layout ${from} and will be retried.`,
1010
+ );
970
1011
  }
1012
+ await printReport("migrate", report, {
1013
+ ok: !run.failed,
1014
+ meta: `layout ${from} → ${LAYOUT_VERSION}`,
1015
+ });
971
1016
  await track("cli_migrate", {
972
1017
  ok: !run.failed,
973
1018
  from,
@@ -982,26 +1027,35 @@ OPTIONS
982
1027
  if (args[0] === "update") {
983
1028
  const subArgs = args.slice(1);
984
1029
  if (subArgs.includes("--help") || subArgs.includes("-h")) {
985
- console.log(`
986
- failproofai update — finish an upgrade: migrate the home, match the daemon
987
-
988
- USAGE
989
- npm install -g failproofai@latest && failproofai update [--no-daemon]
990
-
991
- WHY
992
- npm replaces the CLI and nothing else. The daemon binary lives at
993
- ~/.failproofai/bin/failproofaid-<version> and stays exactly where it was, so
994
- after an npm upgrade the two halves are different versions — and failproofaid
995
- refuses to start against a layout it does not speak, which is the loud version
996
- of that problem rather than the silent one.
997
-
998
- This does the rest of the upgrade: runs any pending layout migrations, puts the
999
- matching daemon binary in place, and restarts the service.
1000
-
1001
- OPTIONS
1002
- --no-daemon Migrate the home only. Leaves a version-skewed daemon in place,
1003
- so prefer letting it run.
1004
- `);
1030
+ await printHelp({
1031
+ command: "update",
1032
+ tagline: "finish an upgrade: migrate the home, match the daemon",
1033
+ sections: [
1034
+ {
1035
+ label: "usage",
1036
+ entries: [["npm install -g failproofai@latest && failproofai update [--no-daemon]"]],
1037
+ },
1038
+ {
1039
+ label: "why",
1040
+ lines: [
1041
+ "npm replaces the CLI and nothing else. The daemon binary lives at",
1042
+ "~/.failproofai/bin/failproofaid-<version> and stays exactly where it was,",
1043
+ "so after an npm upgrade the two halves are different versions — and",
1044
+ "failproofaid refuses to start against a layout it does not speak, which",
1045
+ "is the loud version of that problem rather than the silent one.",
1046
+ "",
1047
+ "This does the rest of the upgrade: runs any pending layout migrations,",
1048
+ "puts the matching daemon binary in place, and restarts the service.",
1049
+ ],
1050
+ },
1051
+ {
1052
+ label: "options",
1053
+ entries: [
1054
+ ["--no-daemon", "Migrate the home only. Leaves a version-skewed daemon in place, so prefer letting it run."],
1055
+ ],
1056
+ },
1057
+ ],
1058
+ });
1005
1059
  process.exit(0);
1006
1060
  }
1007
1061
 
@@ -1025,17 +1079,22 @@ OPTIONS
1025
1079
  const state = detectLayout();
1026
1080
 
1027
1081
  if (state.kind === "future") {
1028
- console.error(
1029
- `This machine's failproofai directory was written by a NEWER version (layout ${state.found};`,
1030
- );
1031
- console.error(`this build speaks ${LAYOUT_VERSION}). This CLI is the stale half:`);
1032
- console.error(` npm install -g failproofai@latest`);
1082
+ await printReport("update", [
1083
+ `This machine's failproofai directory was written by a NEWER version ` +
1084
+ `(layout ${state.found}; this build speaks ${LAYOUT_VERSION}). This CLI is the stale half:`,
1085
+ "",
1086
+ " npm install -g failproofai@latest",
1087
+ ], { ok: false, meta: `layout ${state.found}` });
1033
1088
  await track("cli_update", { ok: false, reason: "future_layout" });
1034
1089
  lastSubcommand = null;
1035
1090
  await exitAfterFlush(1);
1036
1091
  return;
1037
1092
  }
1038
1093
 
1094
+ // Collected rather than printed as it goes, so `update` closes with ONE
1095
+ // report — heading, then both halves of the upgrade under it — instead of
1096
+ // a run of bare sentences at column zero.
1097
+ const report = [];
1039
1098
  let migrationsRan = 0;
1040
1099
  let migrationFailed = false;
1041
1100
  if (state.kind === "stale") {
@@ -1043,17 +1102,18 @@ OPTIONS
1043
1102
  migrationsRan = run.steps.length;
1044
1103
  migrationFailed = Boolean(run.failed);
1045
1104
  for (const step of run.steps) {
1046
- console.log(`${step.ok ? "migrated" : "FAILED "} layout ${step.from} → ${step.to}`);
1105
+ report.push(` ${step.ok ? "migrated" : "FAILED "} layout ${step.from} → ${step.to}`);
1047
1106
  }
1048
- if (run.backedUp.length > 0) console.log(`Saved first: ${run.backedUp.join(", ")}`);
1107
+ if (run.backedUp.length > 0) report.push("", `Saved first: ${run.backedUp.join(", ")}`);
1049
1108
  if (run.failed) {
1050
- console.error(
1109
+ report.push(
1110
+ "",
1051
1111
  `Step ${run.failed.from} → ${run.failed.to} did not finish: ${run.failed.error}`,
1112
+ `The home is still marked layout ${state.found} and will be retried.`,
1052
1113
  );
1053
- console.error(`The home is still marked layout ${state.found} and will be retried.`);
1054
1114
  }
1055
1115
  } else {
1056
- console.log(`Home is at layout ${LAYOUT_VERSION}; no migration was needed.`);
1116
+ report.push(`Home is at layout ${LAYOUT_VERSION}; no migration was needed.`);
1057
1117
  }
1058
1118
 
1059
1119
  // The daemon is refreshed even when a step failed, and deliberately: the
@@ -1062,20 +1122,24 @@ OPTIONS
1062
1122
  // the half that can be fixed. The exit code still reports the failure.
1063
1123
  let daemonOk = true;
1064
1124
  if (subArgs.includes("--no-daemon")) {
1065
- console.log("Skipped the daemon (--no-daemon). Its version may not match this CLI.");
1125
+ report.push("", "Skipped the daemon (--no-daemon). Its version may not match this CLI.");
1066
1126
  } else {
1067
1127
  const svc = await import("../src/hooks/daemon-service");
1068
1128
  if (!svc.isDaemonSupportedPlatform()) {
1069
- console.log(`failproofaid does not run on ${process.platform}; nothing to update.`);
1129
+ report.push("", `failproofaid does not run on ${process.platform}; nothing to update.`);
1070
1130
  } else {
1071
1131
  // `refreshDaemonToCliVersion` answers "is there a service at all" itself,
1072
1132
  // so there is no separate installed check to get out of step with it.
1073
1133
  const result = await svc.refreshDaemonToCliVersion();
1074
- for (const line of result.lines) console.log(line);
1134
+ report.push("", ...result.lines);
1075
1135
  daemonOk = result.ok;
1076
1136
  }
1077
1137
  }
1078
1138
 
1139
+ await printReport("update", report, {
1140
+ ok: daemonOk && !migrationFailed,
1141
+ meta: `v${version}`,
1142
+ });
1079
1143
  await track("cli_update", {
1080
1144
  ok: daemonOk && !migrationFailed,
1081
1145
  migrations: migrationsRan,
@@ -1089,32 +1153,41 @@ OPTIONS
1089
1153
  if (args[0] === "uninstall") {
1090
1154
  const subArgs = args.slice(1);
1091
1155
  if (subArgs.includes("--help") || subArgs.includes("-h")) {
1092
- console.log(`
1093
- failproofai uninstall — remove failproofai from this machine
1094
-
1095
- USAGE
1096
- failproofai uninstall [--purge] [--dry-run] [--yes]
1097
-
1098
- WHAT IT REMOVES
1099
- failproofai hook entries from every agent CLI that has them
1100
- the failproofaid daemon service ASKED on a plain uninstall (kept unless
1101
- you say yes); always removed with --purge, which prompts for your password
1102
- rather than printing commands to paste
1103
- the "require the daemon" flag cleared FIRST, so a partial uninstall can
1104
- never leave this machine denying every tool call
1105
-
1106
- OPTIONS
1107
- --purge Also delete ~/.failproofai — settings, credentials, audit
1108
- history and the downloaded daemon binary. Off by default so a
1109
- reinstall keeps your history.
1110
- --dry-run Print what would be removed and change nothing.
1111
- --yes, -y Skip the confirmation prompt. Required when there is no TTY.
1112
-
1113
- WHY THIS EXISTS
1114
- npm runs no uninstall script, so \`npm rm -g failproofai\` removes the package
1115
- and leaves the hook entries and the service behind. Run this first, then:
1116
- npm rm -g failproofai
1117
- `);
1156
+ await printHelp({
1157
+ command: "uninstall",
1158
+ tagline: "remove failproofai from this machine",
1159
+ sections: [
1160
+ { label: "usage", entries: [["failproofai uninstall [--purge] [--dry-run] [--yes]"]] },
1161
+ {
1162
+ label: "what it removes",
1163
+ lines: [
1164
+ "failproofai hook entries from every agent CLI that has them",
1165
+ "• the failproofaid daemon service ASKED on a plain uninstall (kept",
1166
+ " unless you say yes); always removed with --purge, which prompts for",
1167
+ " your password rather than printing commands to paste",
1168
+ "• the \"require the daemon\" flag cleared FIRST, so a partial uninstall",
1169
+ " can never leave this machine denying every tool call",
1170
+ ],
1171
+ },
1172
+ {
1173
+ label: "options",
1174
+ entries: [
1175
+ ["--purge", "Also delete ~/.failproofai — settings, credentials, audit history and the downloaded daemon binary. Off by default so a reinstall keeps your history."],
1176
+ ["--dry-run", "Print what would be removed and change nothing."],
1177
+ ["--yes, -y", "Skip the confirmation prompt. Required when there is no TTY."],
1178
+ ],
1179
+ },
1180
+ {
1181
+ label: "why this exists",
1182
+ lines: [
1183
+ "npm runs no uninstall script, so `npm rm -g failproofai` removes the",
1184
+ "package and leaves the hook entries and the service behind. Run this",
1185
+ "first, then:",
1186
+ " npm rm -g failproofai",
1187
+ ],
1188
+ },
1189
+ ],
1190
+ });
1118
1191
  process.exit(0);
1119
1192
  }
1120
1193
 
@@ -1191,10 +1264,10 @@ WHY THIS EXISTS
1191
1264
  // show the same block twice. `planLines` is reported by the command rather
1192
1265
  // than guessed from the text — see UninstallResult.
1193
1266
  const skip = planWasShown ? result.planLines : 0;
1194
- for (const line of result.lines.slice(skip)) {
1195
- if (result.exitCode === 0) console.log(line);
1196
- else console.error(line);
1197
- }
1267
+ await printReport("uninstall", result.lines.slice(skip), {
1268
+ ok: result.exitCode === 0,
1269
+ meta: subArgs.includes("--purge") ? "purge" : subArgs.includes("--dry-run") ? "dry run" : undefined,
1270
+ });
1198
1271
  // NOT after a purge. `track` resolves the instance id, and `getInstanceId()`
1199
1272
  // lazily WRITES ~/.failproofai/state/telemetry-id — which re-created the
1200
1273
  // whole directory seconds after the purge deleted it, leaving a machine the
@@ -1226,118 +1299,139 @@ WHY THIS EXISTS
1226
1299
  // worked out from the directory and the git remote. Printing help there
1227
1300
  // made the one documented command the only one that did nothing.
1228
1301
  if (subArgs.includes("--help") || subArgs.includes("-h")) {
1229
- console.log(`
1230
- failproofai publish — ship your policies as a pack anyone can install
1231
-
1232
- TWO COMMANDS, FROM NOTHING
1233
-
1234
- failproofai publish --init write a policy to start from
1235
- failproofai publish ship it
1236
-
1237
- Write your policies in a git repo, run publish, answer one question, done.
1238
- It asks WHERE only when nothing tells it — no remote to read — and works
1239
- the rest out: which files hold policies, what version is next, who you are.
1240
-
1241
- Every flag below overrides something it would otherwise decide for you.
1242
- None of them is required, and on a pipe or in CI the flags are all there
1243
- is: nothing prompts where nobody can answer.
1244
-
1245
- WHAT --init DOES
1246
- Asks what the pack is called, writes <name>.mjs, and stops. No network, no
1247
- git, nothing published.
1248
-
1249
- The file is not a template with blanks — it is one policy that already
1250
- blocks git push --force. So the first thing you do is edit something that
1251
- works. Refuses rather than overwriting a file that already exists.
1252
-
1253
- Then try it on THIS machine, before anyone else can see it:
1254
-
1255
- failproofai policies -i -c ./<name>.mjs
1256
-
1257
- That enforces the file right now — any path, any filename. Ask your agent to
1258
- do the thing you blocked and watch it get refused. Iterate here; nothing is
1259
- published and nobody else is affected.
1260
-
1261
- WHAT publish DOES
1262
- In order, and it stops before touching GitHub if anything is wrong:
1263
-
1264
- 1 Finds the policy file here, by CONTENT — one that imports failproofai
1265
- and calls customPolicies.add — not by filename. So it finds guards.mjs
1266
- and ignores an unrelated policies.mjs. Not recursive: publishing a
1267
- fixture is worse than being asked. Two candidates and it lists them.
1268
- 2 Reads the repo from git remote get-url origin, in the FILE's directory
1269
- rather than yours a policy in another checkout is normal.
1270
- 3 Decides the version (see below).
1271
- 4 Finds your credential: GITHUB_TOKEN, GH_TOKEN, or gh auth login. Needs
1272
- release-write and nothing else. Never printed.
1273
- 5 Creates the repository if it does not exist. Public — see below.
1274
- 6 Builds the three assets, validating with the LOADER's own rules: the code
1275
- that decides what may install on a stranger's machine. A pack that could
1276
- never install fails here, where you can fix it.
1277
- 7 Creates or reuses the release, and uploads the assets replacing any of
1278
- the same name, because the install URL is built from fixed names and a
1279
- stale copy is what somebody would fetch.
1280
-
1281
- HOW THE VERSION IS DECIDED
1282
- --version if you pass one. Otherwise a tag on HEAD someone who tagged
1283
- v1.2.0 has SAID what this release is. Otherwise one past the highest version
1284
- the repository has already published: 1.0.0, then 1.0.1, and so on.
1285
-
1286
- Counted from the repository's own releases, not anything local, so a fresh
1287
- clone still gets the right number and two people cannot both mint 1.0.1.
1288
- Releases that are not semver (nightly) are ignored rather than guessed at.
1289
-
1290
- A tag names a COMMIT, so it is refused when the file has uncommitted changes:
1291
- those bytes are not in that commit, and two packs would claim one version. A
1292
- counted version names no commit and has no such problem.
1293
-
1294
- THE REPO MUST BE PUBLIC
1295
- Installs are anonymous HTTPS with no credential to offer, so a private
1296
- repository publishes to nobody. A repo created here is public for that
1297
- reason; an existing private one still publishes, and warns.
1298
-
1299
- Only the release matters. Installs read releases/download/<tag>/<asset> and
1300
- never touch your git tree pushing the source is for humans reading it.
1301
-
1302
- YOUR POLICY FILES
1303
- Write as many as you like — one per category reads well. Every file here
1304
- that registers policies is found and bundled into the single artifact a
1305
- pack has to be, because only the entry is digest-pinned and a pack
1306
- importing siblings could not honestly claim to be verified. Bundling needs
1307
- bun; without it, name one self-contained file.
1308
-
1309
- Each policy may carry category and defaultEnabled alongside the usual
1310
- fields. category is what --category selects on; defaultEnabled is what a
1311
- bare 'policies add' switches on, and it defaults to false.
1312
-
1313
- OPTIONS
1314
- --init [file] Write a starter policy and stop.
1315
- --repo <owner>/<repo> Where to release it, created if missing.
1316
- --version <version> Skip the counting and say which version this is.
1317
- --id <publisher/name> The pack's id. Defaults to --repo.
1318
- --tag <tag> Release tag. Defaults to the version; v prefix ok.
1319
- --notes <text> Release notes.
1320
- --out <dir> Where to write the assets (default: dist-pack)
1321
- --effect enforce|observe
1322
- observe records and blocks nothing (default: enforce)
1323
- --dry-run Build the assets, publish nothing. No credential.
1324
-
1325
- EXAMPLES
1326
- failproofai publish --init
1327
- failproofai publish
1328
- failproofai publish --dry-run
1329
- failproofai publish ./guards.mjs --repo me/guards --version 2.0.0
1330
- `.trimStart());
1302
+ await printHelp({
1303
+ command: "publish",
1304
+ tagline: "ship your policies as a pack anyone can install",
1305
+ sections: [
1306
+ {
1307
+ label: "two commands, from nothing",
1308
+ entries: [
1309
+ ["failproofai publish --init", "write a policy to start from"],
1310
+ ["failproofai publish", "ship it"],
1311
+ ],
1312
+ after: [
1313
+ "Write your policies in a git repo, run publish, answer one question,",
1314
+ "done. It asks WHERE only when nothing tells it no remote to read —",
1315
+ "and works the rest out: which files hold policies, what version is",
1316
+ "next, who you are. Every flag below overrides something it would",
1317
+ "otherwise decide. None is required, and on a pipe or in CI the flags",
1318
+ "are all there is: nothing prompts where nobody can answer.",
1319
+ ],
1320
+ },
1321
+ {
1322
+ label: "what --init does",
1323
+ lines: [
1324
+ "Asks what the pack is called, writes <name>.mjs, and stops. No network,",
1325
+ "no git, nothing published. The file is not a template with blanks — it",
1326
+ "is one policy that already blocks git push --force, so the first thing",
1327
+ "you do is edit something that works. It refuses rather than overwriting",
1328
+ "a file that exists.",
1329
+ "",
1330
+ "Then try it on THIS machine, before anyone else can see it:",
1331
+ "",
1332
+ " failproofai policies -i -c ./<name>.mjs",
1333
+ "",
1334
+ "That enforces the file right now — any path, any filename. Ask your",
1335
+ "agent to do the thing you blocked and watch it get refused. Nothing is",
1336
+ "published and nobody else is affected.",
1337
+ ],
1338
+ },
1339
+ {
1340
+ label: "what publish does",
1341
+ lines: [
1342
+ "In order, stopping before it touches GitHub if anything is wrong:",
1343
+ "",
1344
+ "1 Finds the policy file here by CONTENT one that imports failproofai",
1345
+ " and calls customPolicies.add not by filename, so it finds",
1346
+ " guards.mjs and ignores an unrelated policies.mjs. Not recursive:",
1347
+ " publishing a fixture is worse than being asked. Two candidates and",
1348
+ " it lists them.",
1349
+ "2 Reads the repo from git remote get-url origin, in the FILE's",
1350
+ " directory rather than yours, and decides the version (see below).",
1351
+ "3 Finds your credential: GITHUB_TOKEN, GH_TOKEN, or gh auth login.",
1352
+ " Needs release-write and nothing else. Never printed.",
1353
+ "4 Creates the repository if it does not exist — public, see below.",
1354
+ "5 Builds the three assets, validating with the LOADER's own rules: the",
1355
+ " code that decides what may install on a stranger's machine. A pack",
1356
+ " that could never install fails here, where you can fix it.",
1357
+ "6 Creates or reuses the release and uploads, replacing assets of the",
1358
+ " same name — the install URL is built from fixed names, so a stale",
1359
+ " copy is what somebody would fetch.",
1360
+ ],
1361
+ },
1362
+ {
1363
+ label: "how the version is decided",
1364
+ lines: [
1365
+ "--version if you pass one. Otherwise a tag on HEAD — someone who tagged",
1366
+ "v1.2.0 has SAID what this release is. Otherwise one past the highest",
1367
+ "the repository has published: 1.0.0, then 1.0.1, and so on.",
1368
+ "",
1369
+ "Counted from the repository's own releases, never anything local, so a",
1370
+ "fresh clone still gets the right number and two people cannot both mint",
1371
+ "1.0.1. Non-semver releases (nightly) are ignored rather than guessed at.",
1372
+ "",
1373
+ "A tag names a COMMIT, so it is refused when the file has uncommitted",
1374
+ "changes: those bytes are not in that commit, and two packs would claim",
1375
+ "one version. A counted version names no commit and has no such problem.",
1376
+ ],
1377
+ },
1378
+ {
1379
+ label: "the repo must be public",
1380
+ lines: [
1381
+ "Installs are anonymous HTTPS with no credential to offer, so a private",
1382
+ "repository publishes to nobody. A repo created here is public for that",
1383
+ "reason; an existing private one still publishes, and warns.",
1384
+ "",
1385
+ "Only the release matters. Installs read releases/download/<tag>/<asset>",
1386
+ "and never touch your git tree — pushing the source is for humans.",
1387
+ ],
1388
+ },
1389
+ {
1390
+ label: "your policy files",
1391
+ lines: [
1392
+ "Write as many as you like — one per category reads well. Every file",
1393
+ "here that registers policies is bundled into the single artifact a pack",
1394
+ "has to be: only the entry is digest-pinned, and a pack importing",
1395
+ "siblings could not honestly claim to be verified. Bundling needs bun;",
1396
+ "without it, name one self-contained file.",
1397
+ "",
1398
+ "Each policy may carry category and defaultEnabled alongside the usual",
1399
+ "fields. category is what --category selects on; defaultEnabled is what",
1400
+ "a bare `policies add` switches on, and it defaults to false.",
1401
+ ],
1402
+ },
1403
+ {
1404
+ label: "options",
1405
+ entries: [
1406
+ ["--init [file]", "Write a starter policy and stop."],
1407
+ ["--repo <owner>/<repo>", "Where to release it, created if missing."],
1408
+ ["--version <version>", "Skip the counting and say which version this is."],
1409
+ ["--id <publisher/name>", "The pack's id. Defaults to --repo."],
1410
+ ["--tag <tag>", "Release tag. Defaults to the version; v prefix ok."],
1411
+ ["--notes <text>", "Release notes."],
1412
+ ["--out <dir>", "Where to write the assets. Default: dist-pack."],
1413
+ ["--effect <effect>", "enforce or observe. observe records and blocks nothing. Default: enforce."],
1414
+ ["--dry-run", "Build the assets, publish nothing. No credential."],
1415
+ ],
1416
+ },
1417
+ {
1418
+ label: "examples",
1419
+ lines: [
1420
+ "failproofai publish --init",
1421
+ "failproofai publish",
1422
+ "failproofai publish --dry-run",
1423
+ "failproofai publish ./guards.mjs --repo me/guards --version 2.0.0",
1424
+ ],
1425
+ },
1426
+ ],
1427
+ });
1331
1428
  process.exit(0);
1332
1429
  }
1333
1430
 
1334
1431
  lastSubcommand = "publish";
1335
1432
  const { runPublishCommand } = await import("../src/hooks/pack-cli");
1336
1433
  const result = await runPublishCommand(subArgs);
1337
- for (const line of result.lines) {
1338
- if (result.exitCode === 0) console.log(line);
1339
- else console.error(line);
1340
- }
1434
+ await printLines(result.lines, result.exitCode === 0);
1341
1435
  // Shape, never value: whether it published and whether it worked. Never the
1342
1436
  // repo, the pack id or the entry path — all three are things the user typed.
1343
1437
  await track("cli_publish", { ok: result.exitCode === 0, dry_run: subArgs.includes("--dry-run") });
@@ -1364,55 +1458,78 @@ EXAMPLES
1364
1458
  const subArgs = args.slice(1);
1365
1459
 
1366
1460
  if (subArgs.length === 0 || subArgs.includes("--help") || subArgs.includes("-h")) {
1367
- console.log(`
1368
- failproofai policies add|remove|show — choose what your agents may do
1369
-
1370
- USAGE
1371
- failproofai policies add Pick from what is installed here
1372
- failproofai policies add <name> Turn one policy on
1373
- failproofai policies add <owner>/<repo> Install someone's pack
1374
- failproofai policies add FailproofAI/policies Our pack, fetched like any other
1375
- failproofai policies remove <name> Turn one policy off
1376
- failproofai policies remove <pack-id> Uninstall a pack
1377
- failproofai policies show <owner>/<repo> What a pack contains, before you take it
1378
-
1379
- A NAME OR A SOURCE
1380
- Anything with a slash is a pack source; anything without is a policy name.
1381
- Policy names cannot contain a slash, so there is nothing to guess at.
1382
-
1383
- block-sudo a policy
1384
- acme/deploy-guard a pack, newest release, pinned
1385
- acme/deploy-guard@v2.1.0 that release
1386
- github:acme/deploy-guard@v2.1.0 same, explicit
1387
- https://github.com/acme/x/releases/tag/v2 the URL you copied
1388
-
1389
- CHOOSING PART OF A PACK
1390
- With no flags you get the pack's own defaults and are shown the rest.
1391
- --policy a,b exactly these
1392
- --category x,y whole categories (see: failproofai policies show <source>)
1393
- --all everything it contains
1394
- Re-adding at a newer version keeps what you chose.
1395
-
1396
- OPTIONS (policy names only)
1397
- --cli claude|codex|copilot|cursor|opencode|pi|hermes|openclaw|factory|devin|antigravity|goose
1398
- Agent CLI(s) to apply to; space-separated or repeated.
1399
- Omit to detect installed CLIs and prompt.
1400
- --scope user|project|local Config scope (default: user)
1401
- --beta Allow beta policies
1402
-
1403
- EXAMPLES
1404
- failproofai policies add
1405
- failproofai policies add block-sudo
1406
- failproofai policies add sanitize-api-keys --scope project
1407
- failproofai policies add FailproofAI/policies --category sanitize,git
1408
- failproofai policies add acme/deploy-guard --policy block-prod-deploy
1409
- failproofai policies show acme/deploy-guard
1410
- failproofai policies remove block-sudo
1411
-
1412
- Publishing your own: failproofai publish --help
1413
- Offline: FAILPROOFAI_NO_DOWNLOAD=1 refuses to fetch; packs already installed
1414
- keep enforcing. FAILPROOFAI_PACK_BASE_URL points it all at a mirror.
1415
- `.trimStart());
1461
+ await printHelp({
1462
+ command: "policies add|remove|show",
1463
+ tagline: "choose what your agents may do",
1464
+ sections: [
1465
+ {
1466
+ label: "usage",
1467
+ // Without the `failproofai policies` prefix, which the heading two
1468
+ // lines up already carries: repeating it costs 21 of the 80 columns
1469
+ // on every row and pushes each description into a second line. The
1470
+ // examples below are the copy-pasteable spelling.
1471
+ entries: [
1472
+ ["add", "Pick from what is installed here"],
1473
+ ["add <name>", "Turn one policy on"],
1474
+ ["add <owner>/<repo>", "Install someone's pack"],
1475
+ ["remove <name>", "Turn one policy off"],
1476
+ ["remove <pack-id>", "Uninstall a pack"],
1477
+ ["show <owner>/<repo>", "What a pack contains, before you take it"],
1478
+ ],
1479
+ },
1480
+ {
1481
+ label: "a name or a source",
1482
+ lines: [
1483
+ "Anything with a slash is a pack source; anything without is a policy",
1484
+ "name. Policy names cannot contain a slash, so there is nothing to guess.",
1485
+ "",
1486
+ " block-sudo a policy",
1487
+ " FailproofAI/policies our pack, like any other",
1488
+ " acme/deploy-guard newest release, pinned",
1489
+ " acme/deploy-guard@v2.1.0 that release",
1490
+ " github:acme/deploy-guard@v2.1.0 same, explicit",
1491
+ " https://github.com/acme/x/releases/tag/v2 the URL you copied",
1492
+ ],
1493
+ },
1494
+ {
1495
+ label: "choosing part of a pack",
1496
+ entries: [
1497
+ ["--policy a,b", "exactly these"],
1498
+ ["--category x,y", "whole categories (failproofai policies show <source>)"],
1499
+ ["--all", "everything it contains"],
1500
+ ],
1501
+ },
1502
+ {
1503
+ label: "options (policy names only)",
1504
+ entries: [
1505
+ ["--cli <agent>...", "Agent CLI(s) to apply to; space-separated or repeated. Omit to detect installed CLIs and prompt."],
1506
+ ["--scope user|project|local", "Config scope. Default: user."],
1507
+ ["--beta", "Allow beta policies"],
1508
+ ],
1509
+ },
1510
+ {
1511
+ label: "examples",
1512
+ lines: [
1513
+ "failproofai policies add",
1514
+ "failproofai policies add block-sudo",
1515
+ "failproofai policies add sanitize-api-keys --scope project",
1516
+ "failproofai policies add FailproofAI/policies --category sanitize,git",
1517
+ "failproofai policies add acme/deploy-guard --policy block-prod-deploy",
1518
+ "failproofai policies show acme/deploy-guard",
1519
+ "failproofai policies remove block-sudo",
1520
+ ],
1521
+ },
1522
+ ],
1523
+ footer: [
1524
+ "With no flags you get the pack's own defaults and are shown the rest.",
1525
+ "Re-adding at a newer version keeps what you chose.",
1526
+ "Agents: claude, codex, copilot, cursor, opencode, pi, hermes, openclaw,",
1527
+ "factory, devin, antigravity, goose.",
1528
+ "Publishing your own: failproofai publish --help",
1529
+ "Offline: FAILPROOFAI_NO_DOWNLOAD=1 refuses to fetch; packs already",
1530
+ "installed keep enforcing. FAILPROOFAI_PACK_BASE_URL points it at a mirror.",
1531
+ ],
1532
+ });
1416
1533
  process.exit(0);
1417
1534
  }
1418
1535
 
@@ -1475,10 +1592,7 @@ keep enforcing. FAILPROOFAI_PACK_BASE_URL points it all at a mirror.
1475
1592
  action === "show" ? ["list", ...rest] : [action, ...rest];
1476
1593
  const { runPackCommand } = await import("../src/hooks/pack-cli");
1477
1594
  const result = await runPackCommand(packArgs);
1478
- for (const line of result.lines) {
1479
- if (result.exitCode === 0) console.log(line);
1480
- else console.error(line);
1481
- }
1595
+ await printLines(result.lines, result.exitCode === 0);
1482
1596
  await track("cli_pack", {
1483
1597
  ok: result.exitCode === 0,
1484
1598
  // The subcommand only — never the pack id, source or policy names. A
@@ -1553,10 +1667,7 @@ keep enforcing. FAILPROOFAI_PACK_BASE_URL points it all at a mirror.
1553
1667
  // the answer is to SHOW the list, here, with what is already on ticked.
1554
1668
  const { runPolicyPicker } = await import("../src/hooks/pack-cli");
1555
1669
  const result = await runPolicyPicker(action, { stdin: process.stdin, stdout: process.stdout });
1556
- for (const line of result.lines) {
1557
- if (result.exitCode === 0) console.log(line);
1558
- else console.error(line);
1559
- }
1670
+ await printLines(result.lines, result.exitCode === 0);
1560
1671
  await track("cli_policy_picker", { ok: result.exitCode === 0, action });
1561
1672
  lastSubcommand = null;
1562
1673
  await exitAfterFlush(result.exitCode);
@@ -1630,69 +1741,63 @@ keep enforcing. FAILPROOFAI_PACK_BASE_URL points it all at a mirror.
1630
1741
  const isHelp = subArgs.includes("--help") || subArgs.includes("-h");
1631
1742
 
1632
1743
  if (isHelp) {
1633
- console.log(`
1634
- failproofai policies — manage Failproof AI policies
1635
-
1636
- USAGE
1637
- failproofai policies List all policies and their status
1638
- failproofai policies add [what] Turn one on, take a pack, or pick
1639
- failproofai policies remove <what> Turn one off, or uninstall a pack
1640
- failproofai policies show <owner>/<repo> What a pack holds, before you take it
1641
- failproofai policies --install, -i Wire policies into your agent CLIs
1642
- failproofai policies --uninstall, -u Unwire them, or strip the hooks
1643
-
1644
- policy, pack and p are all spellings of policies.
1645
- Detail on the first three: failproofai policies add --help
1646
-
1647
- OPTIONS (install)
1648
- [names...] Specific policy names to enable (omit for interactive)
1649
- --cli claude|codex|copilot|cursor|opencode|pi|hermes|openclaw|factory|devin|antigravity|goose
1650
- Agent CLI(s) to install for; space-separated
1651
- (e.g. --cli claude codex copilot cursor opencode pi hermes openclaw factory devin antigravity goose) or repeated.
1652
- Omit to detect installed CLIs and prompt (or
1653
- auto-pick if only one is found).
1654
- --scope user|project|local Config scope to write to (default: user)
1655
- (Codex / Copilot / Cursor / OpenCode / Pi support user|project only)
1656
- --beta Include beta policies
1657
- --custom, -c <path> Custom policy file (repeat for multiple files)
1658
- (skips interactive prompt; validates file first)
1659
-
1660
- OPTIONS (uninstall)
1661
- [names...] Specific policy names to disable (omit to remove hooks)
1662
- --cli claude|codex|copilot|cursor|opencode|pi|hermes|openclaw|factory|devin|antigravity|goose
1663
- Agent CLI(s) to uninstall from
1664
- --scope user|project|local|all Config scope to remove from (default: user)
1665
- --beta Remove only beta policies
1666
- --custom, -c Clear all explicit custom policy paths
1667
-
1668
- EXAMPLES
1669
- failproofai policies
1670
- failproofai policies --install
1671
- failproofai policies --install block-sudo sanitize-api-keys
1672
- failproofai policies --install --cli codex --scope project
1673
- failproofai policies --install --cli copilot --scope project
1674
- failproofai policies --install --cli cursor --scope project
1675
- failproofai policies --install --cli opencode --scope project
1676
- failproofai policies --install --cli pi --scope project
1677
- failproofai policies --install --cli factory --scope project
1678
- failproofai policies --install --cli devin --scope project
1679
- failproofai policies --install --cli claude codex copilot cursor opencode pi hermes openclaw factory devin antigravity goose
1680
- failproofai policies --install --custom ./my-policies.js
1681
- failproofai policies --install --custom ./security.js --custom ./workflow.js
1682
- failproofai policies -i -c ./my-policies.js
1683
- failproofai policies --uninstall block-sudo
1684
- failproofai policies --uninstall --cli codex
1685
- failproofai policies --uninstall --cli copilot
1686
- failproofai policies --uninstall --cli cursor
1687
- failproofai policies --uninstall --cli opencode
1688
- failproofai policies --uninstall --cli pi
1689
- failproofai policies -u
1690
- failproofai policies --uninstall --custom
1691
- failproofai policies add FailproofAI/policies
1692
- failproofai policies add FailproofAI/policies --category git,database
1693
- failproofai pack list acme/support-agent
1694
- failproofai pack build ./my-policies.mjs --id acme/support --version 1.0.0
1695
- `.trimStart());
1744
+ await printHelp({
1745
+ command: "policies",
1746
+ tagline: "manage the policies your agents run under",
1747
+ sections: [
1748
+ {
1749
+ label: "usage",
1750
+ entries: [
1751
+ ["(bare)", "List every policy here and whether it is on"],
1752
+ ["add [what]", "Turn one on, take a pack, or pick from a list"],
1753
+ ["remove <what>", "Turn one off, or uninstall a pack"],
1754
+ ["show <owner>/<repo>", "What a pack holds, before you take it"],
1755
+ ["--install, -i", "Wire policies into your agent CLIs"],
1756
+ ["--uninstall, -u", "Unwire them, or strip the hooks"],
1757
+ ],
1758
+ },
1759
+ {
1760
+ label: "options",
1761
+ entries: [
1762
+ ["[names...]", "Policy names. Omit for the interactive picker."],
1763
+ ["--cli <agent>...", "Agent CLI(s); space-separated or repeated. Omit to detect what is installed and prompt."],
1764
+ ["--scope <scope>", "user, project or local. Default: user. --uninstall also takes all."],
1765
+ ["--beta", "Include beta policies. On --uninstall, only those."],
1766
+ ["--custom, -c <path>", "Custom policy file; repeat for several. Bare on --uninstall, clears every explicit path."],
1767
+ ],
1768
+ },
1769
+ {
1770
+ // Enumerated ONCE. It used to be spelled out in full four times on
1771
+ // this screen — twice in the --cli lines, twice more as examples
1772
+ // differing only in which agent they named.
1773
+ label: "agents",
1774
+ lines: [
1775
+ "claude, codex, copilot, cursor, opencode, pi, hermes, openclaw,",
1776
+ "factory, devin, antigravity, goose",
1777
+ "",
1778
+ "Codex, Copilot, Cursor, OpenCode and Pi take user or project scope only.",
1779
+ ],
1780
+ },
1781
+ {
1782
+ label: "examples",
1783
+ lines: [
1784
+ "failproofai policies",
1785
+ "failproofai policies --install",
1786
+ "failproofai policies --install block-sudo sanitize-api-keys",
1787
+ "failproofai policies --install --cli codex --scope project",
1788
+ "failproofai policies --install --cli claude codex copilot cursor",
1789
+ "failproofai policies -i -c ./my-policies.js",
1790
+ "failproofai policies --uninstall block-sudo",
1791
+ "failproofai policies -u",
1792
+ "failproofai policies add FailproofAI/policies --category git,database",
1793
+ ],
1794
+ },
1795
+ ],
1796
+ footer: [
1797
+ "policy, pack and p are all spellings of policies.",
1798
+ "add, remove and show in full: failproofai policies add --help",
1799
+ ],
1800
+ });
1696
1801
  process.exit(0);
1697
1802
  }
1698
1803
 
@@ -1914,82 +2019,100 @@ EXAMPLES
1914
2019
  // onboarding via bare `failproofai`).
1915
2020
  if (args[0] === "config") {
1916
2021
  if (args.includes("--help") || args.includes("-h")) {
1917
- console.log(`
1918
- failproofai config — set this machine up
1919
-
1920
- USAGE
1921
- failproofai config Guided setup
1922
- failproofai configure Alias for config
1923
- failproofai setup Alias for config
1924
-
1925
- WHAT IT DOES
1926
- Installs the failproofaid service (needs root once), wires hooks into every
1927
- agent CLI it supports, and optionally connects this machine to Cloud. It
1928
- chooses NO policies take some with 'failproofai policies add <owner>/<repo>'.
1929
-
1930
- WITH NO TERMINAL (CI, containers, an agent driving it)
1931
- It just runs. There is nothing to confirm when nobody is watching, so it
1932
- applies rather than asking — no flag needed.
1933
-
1934
- failproofai config set up, stay local
1935
- failproofai config --token <key> set up and connect to Cloud
1936
- --url <url> somewhere other than app.befailproof.ai
1937
- --machine-id <id> defaults to a stable per-machine key
1938
- --machine-label <name> defaults to this host's name
1939
- --no-transcripts decisions only, no session transcripts
1940
-
1941
- The key can come from FAILPROOFAI_CLOUD_TOKEN instead of the command line,
1942
- and the url from FAILPROOFAI_CLOUD_URL — the same variable the daemon reads.
1943
- Prefer the environment: an argument is readable from 'ps' by every user on
1944
- the box, and lands in shell history and CI logs.
1945
-
1946
- Exit 1 if anything it was asked to do did not happen including a key the
1947
- server refused, and a machine that could not reach root. 'sudo' is never
1948
- prompted for here; it either works without a password or you are told the
1949
- exact commands to run.
1950
-
1951
- FAILPROOF CLOUD
1952
- failproofai config --connect <url> --token <key> [--machine-id <id>]
1953
- Connect this machine to FailproofAI Cloud
1954
- [--no-transcripts] decisions only, no transcripts
1955
- failproofai config --disconnect Stop pulling policy and sending activity
1956
- failproofai config --status Show connection, daemon and pause state
1957
- failproofai config --pause [--session <id>]
1958
- Pause enforcement, time-boxed
1959
- failproofai config --resume [--all]
1960
- Resume enforcement
1961
-
1962
- --machine-label <name> Human-readable name in the dashboard
1963
- (use alone to rename an already-connected machine)
1964
-
1965
- One connection, two capabilities: this machine PULLS centrally-managed
1966
- policies and SENDS what its hooks decided, so the dashboard shows the fleet
1967
- it is enforcing on. Both are checked against the server before anything is
1968
- written, and reported separately — a key carrying policies:pull but not
1969
- events:add connects for policy and says exactly why the dashboard is empty.
1970
-
1971
- Tokens are stored owner-only in ~/.failproofai/, never in the service unit
1972
- that file is world-readable. Connecting needs no sudo, and the machine id
1973
- defaults to this host's name.
1974
-
1975
- Connecting sends BOTH policy decisions and full session transcripts. A
1976
- transcript carries prompts, file contents and whatever was pasted into a
1977
- terminal that is the point of connecting, and it is stated here rather than
1978
- buried behind a flag nobody finds. Use --no-transcripts for decisions only.
1979
-
1980
- PAUSING ENFORCEMENT (one session, always time-boxed)
1981
- failproofai config --pause Pause this directory's newest agent session (30m)
1982
- failproofai config --pause 10m Pause for a given time (max 8h; s/m/h, bare = minutes)
1983
- failproofai config --resume End the pause early
1984
- failproofai config --status Show what is paused and when it lifts
1985
- --session <id> Target a specific session
1986
- --all With --resume, end every active pause
1987
-
1988
- A pause suspends builtin, custom and convention policies for that session
1989
- only, and always expires on its own. Cloud-managed policies keep enforcing.
1990
-
1991
- Prefer flags? See \`failproofai policies --help\`.
1992
- `.trimStart());
2022
+ await printHelp({
2023
+ command: "config",
2024
+ tagline: "set this machine up",
2025
+ sections: [
2026
+ {
2027
+ label: "usage",
2028
+ entries: [
2029
+ ["(bare)", "Guided setup: agents, daemon, cloud"],
2030
+ ["--token <key>", "Set up and connect to Cloud, asking nothing"],
2031
+ ["--status", "Connection, daemon version and pause state"],
2032
+ ["--pause [<time>]", "Pause enforcement for one session"],
2033
+ ["--resume [--all]", "End a pause early"],
2034
+ ["--disconnect", "Stop pulling policy and sending activity"],
2035
+ ["configure, setup", "Aliases for config"],
2036
+ ],
2037
+ },
2038
+ {
2039
+ label: "what it does",
2040
+ lines: [
2041
+ "Installs the failproofaid service (needs root once), wires hooks into",
2042
+ "every agent CLI it supports, and optionally connects this machine to",
2043
+ "Cloud. It chooses NO policies — take some with:",
2044
+ "",
2045
+ " failproofai policies add <owner>/<repo>",
2046
+ ],
2047
+ },
2048
+ {
2049
+ label: "with no terminal (CI, containers, an agent driving it)",
2050
+ lines: [
2051
+ "It just runs. There is nothing to confirm when nobody is watching, so it",
2052
+ "applies rather than asking no flag needed.",
2053
+ "",
2054
+ "Exit 1 if anything it was asked to do did not happen — including a key",
2055
+ "the server refused, and a machine that could not reach root. sudo is",
2056
+ "never prompted for here; it either works without a password, or you are",
2057
+ "told the exact commands to run.",
2058
+ ],
2059
+ },
2060
+ {
2061
+ label: "failproof cloud",
2062
+ entries: [
2063
+ ["--token <key>", "The API key. Passing one IS the request to connect."],
2064
+ ["--url <url>", "Somewhere other than app.befailproof.ai"],
2065
+ ["--machine-id <id>", "Defaults to a stable per-machine key"],
2066
+ ["--machine-label <n>", "Dashboard name. Alone, renames a connected machine."],
2067
+ ["--no-transcripts", "Decisions only, no session transcripts"],
2068
+ ["--connect <url>", "Enrol only, on a machine already set up"],
2069
+ ["--disconnect", "Stop pulling policy and sending activity"],
2070
+ ],
2071
+ },
2072
+ {
2073
+ label: "what connecting means",
2074
+ lines: [
2075
+ "The key can come from FAILPROOFAI_CLOUD_TOKEN instead of the command",
2076
+ "line, and the url from FAILPROOFAI_CLOUD_URL the same variable the",
2077
+ "daemon reads. Prefer the environment: an argument is readable from `ps`",
2078
+ "by every user on the box, and lands in shell history and CI logs.",
2079
+ "",
2080
+ "One connection, two capabilities: this machine PULLS centrally-managed",
2081
+ "policies and SENDS what its hooks decided. Both are checked against the",
2082
+ "server before anything is written, and reported separately a key",
2083
+ "carrying policies:pull but not events:add connects for policy and says",
2084
+ "exactly why the dashboard is empty.",
2085
+ "",
2086
+ "Connecting sends BOTH policy decisions and full session transcripts. A",
2087
+ "transcript carries prompts, file contents and whatever was pasted into a",
2088
+ "terminal that is the point of connecting, and it is stated here rather",
2089
+ "than buried behind a flag nobody finds. Use --no-transcripts for",
2090
+ "decisions only.",
2091
+ "",
2092
+ "Tokens are stored owner-only in ~/.failproofai/, never in the service",
2093
+ "unit that file is world-readable. Connecting needs no sudo.",
2094
+ ],
2095
+ },
2096
+ {
2097
+ label: "pausing enforcement (one session, always time-boxed)",
2098
+ entries: [
2099
+ ["--pause", "This directory's newest agent session, for 30m"],
2100
+ ["--pause 10m", "A given time (max 8h; s/m/h, bare = minutes)"],
2101
+ ["--resume", "End the pause early"],
2102
+ ["--resume --all", "End every active pause"],
2103
+ ["--session <id>", "Target a specific session"],
2104
+ ["--status", "What is paused, and when it lifts"],
2105
+ ],
2106
+ after: [
2107
+ "A pause suspends builtin, custom and convention policies for that",
2108
+ "session only, and always expires on its own. Cloud-managed policies",
2109
+ "keep enforcing.",
2110
+ ],
2111
+ },
2112
+ ],
2113
+ footer: ["Prefer flags? failproofai policies --help"],
2114
+ });
2115
+ process.exit(0);
1993
2116
  process.exit(0);
1994
2117
  }
1995
2118
  lastSubcommand = "config";
@@ -2047,10 +2170,7 @@ PAUSING ENFORCEMENT (one session, always time-boxed)
2047
2170
  sessions: !args.includes("--no-transcripts"),
2048
2171
  });
2049
2172
  }
2050
- for (const line of result.lines) {
2051
- if (result.exitCode === 0) console.log(line);
2052
- else console.error(line);
2053
- }
2173
+ await printLines(result.lines, result.exitCode === 0);
2054
2174
  await track("cli_cloud_enrollment", {
2055
2175
  action: wantsRename ? "rename" : wantsDisconnect ? "disconnect" : "connect",
2056
2176
  ok: result.exitCode === 0,
@@ -2121,10 +2241,7 @@ PAUSING ENFORCEMENT (one session, always time-boxed)
2121
2241
  ),
2122
2242
  );
2123
2243
  } else {
2124
- for (const line of result.lines) {
2125
- if (result.exitCode === 0) console.log(line);
2126
- else console.error(line);
2127
- }
2244
+ await printLines(result.lines, result.exitCode === 0);
2128
2245
  }
2129
2246
  await track("cli_pause_invoked", {
2130
2247
  action: pauseIdx >= 0 ? "pause" : wantsResume ? "resume" : "status",