failproofai 0.0.9 → 0.0.10-beta.0

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 (197) hide show
  1. package/.next/standalone/.cursor/hooks.json +47 -0
  2. package/.next/standalone/.gemini/settings.json +147 -0
  3. package/.next/standalone/.next/BUILD_ID +1 -1
  4. package/.next/standalone/.next/build-manifest.json +3 -3
  5. package/.next/standalone/.next/prerender-manifest.json +3 -3
  6. package/.next/standalone/.next/required-server-files.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  8. package/.next/standalone/.next/server/app/_global-error/page.js +1 -1
  9. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  10. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  11. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  12. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  13. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  14. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  15. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
  16. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
  17. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  18. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  19. package/.next/standalone/.next/server/app/_not-found/page.js +1 -1
  20. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  21. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  22. package/.next/standalone/.next/server/app/_not-found.html +2 -2
  23. package/.next/standalone/.next/server/app/_not-found.rsc +17 -17
  24. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +17 -17
  25. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  26. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +11 -11
  27. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  28. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  29. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  30. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js +2 -1
  31. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/index.html +1 -1
  33. package/.next/standalone/.next/server/app/index.rsc +16 -16
  34. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  35. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +16 -16
  36. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  37. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +11 -11
  38. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  39. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  40. package/.next/standalone/.next/server/app/page.js +1 -1
  41. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  42. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  43. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +8 -8
  44. package/.next/standalone/.next/server/app/policies/page.js +1 -1
  45. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  46. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  47. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  48. package/.next/standalone/.next/server/app/project/[name]/page.js +2 -2
  49. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  50. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  51. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  52. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  53. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +5 -5
  54. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  55. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  56. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  57. package/.next/standalone/.next/server/app/projects/page.js +2 -2
  58. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  59. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  60. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0.~nmr9._.js +3 -0
  61. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0yspgjy._.js → [root-of-the-server]__010i6f5._.js} +2 -2
  62. package/.next/standalone/.next/server/chunks/[root-of-the-server]__08px0ym._.js +3 -0
  63. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0b57.gk._.js +3 -0
  64. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0dtn9lr._.js +3 -0
  65. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0kjo7d_._.js +1 -1
  66. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0vlhtkc._.js +3 -0
  67. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0wu7fr7._.js +3 -0
  68. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0yfq1yr._.js +3 -0
  69. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0z4c5dj._.js +3 -0
  70. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0zso~62._.js +3 -0
  71. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_0z7w.hh._.js +1 -1
  72. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0-2wr.c._.js +4 -0
  73. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0.~m-w2._.js +4 -0
  74. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__09icjsf._.js → [root-of-the-server]__0709m8.._.js} +3 -3
  75. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0bz245.._.js +4 -0
  76. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0dl0kgt._.js +4 -0
  77. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0gmhxyo._.js +4 -0
  78. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0mup1hi._.js +3 -0
  79. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ohb3gc._.js +4 -0
  80. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qbpe_v._.js +3 -0
  81. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s~gy6y._.js +3 -0
  82. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0t5l7a5._.js +3 -0
  83. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ymlddl._.js +152 -6
  84. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0_b7pgn._.js → [root-of-the-server]__0ymn496._.js} +2 -2
  85. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__01g_w_e._.js → [root-of-the-server]__10h.ggz._.js} +2 -2
  86. package/.next/standalone/.next/server/chunks/ssr/_03d7qyt._.js +3 -0
  87. package/.next/standalone/.next/server/chunks/ssr/{_07a1g.3._.js → _0zx~s__._.js} +2 -2
  88. package/.next/standalone/.next/server/chunks/ssr/_10lm7or._.js +2 -2
  89. package/.next/standalone/.next/server/chunks/ssr/app_0cdqd9w._.js +1 -1
  90. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_0xerkr6._.js +1 -1
  91. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_0q-m0y-._.js +2 -2
  92. package/.next/standalone/.next/server/chunks/ssr/lib_codex-projects_ts_0eosib~._.js +1 -1
  93. package/.next/standalone/.next/server/chunks/ssr/lib_copilot-projects_ts_0r8xkn8._.js +3 -0
  94. package/.next/standalone/.next/server/chunks/ssr/lib_cursor-projects_ts_0qt1scg._.js +3 -0
  95. package/.next/standalone/.next/server/chunks/ssr/lib_gemini-projects_ts_0sl~yqr._.js +3 -0
  96. package/.next/standalone/.next/server/chunks/ssr/lib_opencode-projects_ts_0op9gyp._.js +3 -0
  97. package/.next/standalone/.next/server/chunks/ssr/lib_pi-projects_ts_103tsh1._.js +3 -0
  98. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  99. package/.next/standalone/.next/server/pages/404.html +2 -2
  100. package/.next/standalone/.next/server/pages/500.html +1 -1
  101. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  102. package/.next/standalone/.next/server/server-reference-manifest.json +9 -9
  103. package/.next/standalone/.next/static/chunks/{0n-_j_6fo6jex.js → 00ay03h8bq4b~.js} +2 -2
  104. package/.next/standalone/.next/static/chunks/{11kt_9zaooda3.js → 0agmlhk5ml7x5.js} +1 -1
  105. package/.next/standalone/.next/static/chunks/0bi2r.m~yokoo.js +1 -0
  106. package/.next/standalone/.next/static/chunks/{095l4hc7-h.~~.js → 0en4v5k2nnxks.js} +1 -1
  107. package/.next/standalone/.next/static/chunks/0q5bmqop--9yk.js +1 -0
  108. package/.next/standalone/.next/static/chunks/{0756i.7omnnl6.js → 0s6nux54y~l~r.js} +1 -1
  109. package/.next/standalone/.next/static/chunks/{0t~iusm_fxoao.js → 0tpse0wu2wwo0.js} +1 -1
  110. package/.next/standalone/.next/static/chunks/12po2vpc-4_c1.css +1 -0
  111. package/.next/standalone/.next/static/chunks/{0u-ys71jc4y68.js → 1400rtd5ywbt..js} +2 -2
  112. package/.next/standalone/.next/static/chunks/{09ose_165ra4d.js → 14lmf8boay-zu.js} +1 -1
  113. package/.next/standalone/.next/static/chunks/{0pr7k36o_.du1.js → 17htukxga7bil.js} +1 -1
  114. package/.next/standalone/.opencode/opencode.json +4 -0
  115. package/.next/standalone/.opencode/plugins/failproofai.mjs +131 -0
  116. package/.next/standalone/.pi/settings.json +5 -0
  117. package/.next/standalone/app/components/cli-badge.tsx +7 -11
  118. package/.next/standalone/app/components/project-list.tsx +32 -4
  119. package/.next/standalone/app/policies/hooks-client.tsx +31 -15
  120. package/.next/standalone/app/project/[name]/page.tsx +52 -16
  121. package/.next/standalone/app/project/[name]/session/[sessionId]/page.tsx +92 -15
  122. package/.next/standalone/assets/logos/copilot-dark.svg +1 -0
  123. package/.next/standalone/assets/logos/copilot-light.svg +1 -0
  124. package/.next/standalone/assets/logos/cursor-dark.svg +1 -0
  125. package/.next/standalone/assets/logos/cursor-light.svg +1 -0
  126. package/.next/standalone/assets/logos/gemini-dark.svg +13 -0
  127. package/.next/standalone/assets/logos/gemini-light.svg +13 -0
  128. package/.next/standalone/assets/logos/opencode-dark.svg +1 -0
  129. package/.next/standalone/assets/logos/opencode-light.svg +1 -0
  130. package/.next/standalone/assets/logos/pi-dark.svg +7 -0
  131. package/.next/standalone/assets/logos/pi-light.svg +7 -0
  132. package/.next/standalone/lib/cli-registry.ts +107 -0
  133. package/.next/standalone/lib/codex-projects.ts +3 -3
  134. package/.next/standalone/lib/copilot-projects.ts +224 -0
  135. package/.next/standalone/lib/copilot-sessions.ts +395 -0
  136. package/.next/standalone/lib/cursor-projects.ts +312 -0
  137. package/.next/standalone/lib/cursor-sessions.ts +467 -0
  138. package/.next/standalone/lib/gemini-projects.ts +203 -0
  139. package/.next/standalone/lib/gemini-sessions.ts +365 -0
  140. package/.next/standalone/lib/opencode-projects.ts +232 -0
  141. package/.next/standalone/lib/opencode-sessions.ts +237 -0
  142. package/.next/standalone/lib/pi-projects.ts +230 -0
  143. package/.next/standalone/lib/pi-sessions.ts +325 -0
  144. package/.next/standalone/lib/projects.ts +67 -31
  145. package/.next/standalone/next.config.ts +5 -4
  146. package/.next/standalone/package.json +2 -1
  147. package/.next/standalone/pi-extension/index.ts +373 -0
  148. package/.next/standalone/pi-extension/package.json +12 -0
  149. package/.next/standalone/server.js +1 -1
  150. package/README.md +37 -3
  151. package/bin/failproofai.mjs +61 -21
  152. package/dist/cli.mjs +2248 -246
  153. package/lib/cli-registry.ts +107 -0
  154. package/lib/codex-projects.ts +3 -3
  155. package/lib/copilot-projects.ts +224 -0
  156. package/lib/copilot-sessions.ts +395 -0
  157. package/lib/cursor-projects.ts +312 -0
  158. package/lib/cursor-sessions.ts +467 -0
  159. package/lib/gemini-projects.ts +203 -0
  160. package/lib/gemini-sessions.ts +365 -0
  161. package/lib/opencode-projects.ts +232 -0
  162. package/lib/opencode-sessions.ts +237 -0
  163. package/lib/pi-projects.ts +230 -0
  164. package/lib/pi-sessions.ts +325 -0
  165. package/lib/projects.ts +67 -31
  166. package/package.json +2 -1
  167. package/pi-extension/index.ts +373 -0
  168. package/pi-extension/package.json +12 -0
  169. package/scripts/translate-docs/mdx-translator.ts +56 -2
  170. package/scripts/translate-docs/translator.ts +1 -1
  171. package/src/hooks/builtin-policies.ts +84 -14
  172. package/src/hooks/handler.ts +67 -5
  173. package/src/hooks/install-prompt.ts +33 -10
  174. package/src/hooks/integrations.ts +1007 -6
  175. package/src/hooks/policy-evaluator.ts +299 -3
  176. package/src/hooks/resolve-permission-mode.ts +23 -0
  177. package/src/hooks/types.ts +307 -3
  178. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0g72weg._.js +0 -3
  179. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0su~k6f._.js +0 -3
  180. package/.next/standalone/.next/server/chunks/lib_codex-projects_ts_07qqk1g._.js +0 -3
  181. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01743wx._.js +0 -3
  182. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__092s1ta._.js +0 -4
  183. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0g.lg8b._.js +0 -4
  184. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0gs6wz4._.js +0 -3
  185. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0h..k-e._.js +0 -4
  186. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0it81ys._.js +0 -3
  187. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u4a9jq._.js +0 -4
  188. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__11pa2ra._.js +0 -4
  189. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__12.h2mg._.js +0 -3
  190. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__12t-wym._.js +0 -4
  191. package/.next/standalone/.next/server/chunks/ssr/_04w00cm._.js +0 -3
  192. package/.next/standalone/.next/static/chunks/0.rk1iwdt1d7c.css +0 -1
  193. package/.next/standalone/.next/static/chunks/06x4-d1~o-opr.js +0 -1
  194. package/.next/standalone/.next/static/chunks/0n~s0gafwnp2y.js +0 -1
  195. /package/.next/standalone/.next/static/{A_Ax17P33facL0OmIwFXj → 68TLSFdjAQYIulNHfP0QY}/_buildManifest.js +0 -0
  196. /package/.next/standalone/.next/static/{A_Ax17P33facL0OmIwFXj → 68TLSFdjAQYIulNHfP0QY}/_clientMiddlewareManifest.js +0 -0
  197. /package/.next/standalone/.next/static/{A_Ax17P33facL0OmIwFXj → 68TLSFdjAQYIulNHfP0QY}/_ssgManifest.js +0 -0
@@ -7,8 +7,9 @@
7
7
  * is agent-agnostic — only install/uninstall plumbing varies.
8
8
  */
9
9
  import { execSync } from "node:child_process";
10
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
10
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from "node:fs";
11
11
  import { resolve, dirname } from "node:path";
12
+ import { fileURLToPath } from "node:url";
12
13
  import { homedir } from "node:os";
13
14
  import {
14
15
  HOOK_EVENT_TYPES,
@@ -16,6 +17,16 @@ import {
16
17
  CODEX_HOOK_EVENT_TYPES,
17
18
  CODEX_HOOK_SCOPES,
18
19
  CODEX_EVENT_MAP,
20
+ COPILOT_HOOK_EVENT_TYPES,
21
+ COPILOT_HOOK_SCOPES,
22
+ CURSOR_HOOK_EVENT_TYPES,
23
+ CURSOR_HOOK_SCOPES,
24
+ OPENCODE_HOOK_EVENT_TYPES,
25
+ OPENCODE_HOOK_SCOPES,
26
+ PI_HOOK_EVENT_TYPES,
27
+ PI_HOOK_SCOPES,
28
+ GEMINI_HOOK_EVENT_TYPES,
29
+ GEMINI_HOOK_SCOPES,
19
30
  FAILPROOFAI_HOOK_MARKER,
20
31
  INTEGRATION_TYPES,
21
32
  type IntegrationType,
@@ -39,10 +50,12 @@ function writeJsonFile(path: string, data: Record<string, unknown>): void {
39
50
  writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf8");
40
51
  }
41
52
 
42
- function isMarkedHook(hook: Record<string, unknown>): boolean {
43
- if (hook[FAILPROOFAI_HOOK_MARKER] === true) return true;
53
+ function isMarkedHook(hook: unknown): boolean {
54
+ if (!hook || typeof hook !== "object") return false;
55
+ const h = hook as Record<string, unknown>;
56
+ if (h[FAILPROOFAI_HOOK_MARKER] === true) return true;
44
57
  // Fallback for legacy installs predating the marker
45
- const cmd = typeof hook.command === "string" ? hook.command : "";
58
+ const cmd = typeof h.command === "string" ? h.command : "";
46
59
  return cmd.includes("failproofai") && cmd.includes("--hook");
47
60
  }
48
61
 
@@ -78,8 +91,8 @@ export interface Integration {
78
91
  /** Build a single hook entry for a given event. */
79
92
  buildHookEntry(binaryPath: string, eventType: string, scope?: HookScope): Record<string, unknown>;
80
93
 
81
- /** Whether a hook entry is owned by failproofai. */
82
- isFailproofaiHook(hook: Record<string, unknown>): boolean;
94
+ /** Whether a hook entry is owned by failproofai. Entry shape varies per CLI (object for Claude/Codex/Copilot/Cursor; string or tuple for OpenCode). */
95
+ isFailproofaiHook(hook: unknown): boolean;
83
96
 
84
97
  /** Mutate `settings` in place, registering failproofai across all event types. Idempotent. */
85
98
  writeHookEntries(settings: Record<string, unknown>, binaryPath: string, scope?: HookScope): void;
@@ -348,11 +361,999 @@ export const codex: Integration = {
348
361
  },
349
362
  };
350
363
 
364
+ // ── GitHub Copilot CLI integration ──────────────────────────────────────────
365
+ //
366
+ // Copilot CLI accepts two hook payload formats: a camelCase native form and a
367
+ // "VS Code compatible" PascalCase form. We install with PascalCase keys, which
368
+ // gets us:
369
+ // • PascalCase `hook_event_name` on stdin (matches Claude — no canonicalization)
370
+ // • snake_case fields like `tool_name`/`tool_input` (matches Claude payload parser)
371
+ // • `hookSpecificOutput.permissionDecision` honored on stdout (matches Claude
372
+ // output shape — policy-evaluator works unchanged)
373
+ //
374
+ // Hook entries differ from Claude/Codex: each entry uses OS-keyed `bash` and
375
+ // `powershell` command fields and a `timeoutSec` (seconds) instead of Claude's
376
+ // single `command` field with `timeout` (milliseconds). Top-level wrapper is
377
+ // `{ "version": 1, "hooks": {...} }`, mirroring Codex.
378
+
379
+ interface CopilotHookEntry {
380
+ type: "command";
381
+ bash: string;
382
+ powershell: string;
383
+ timeoutSec: number;
384
+ [FAILPROOFAI_HOOK_MARKER]: true;
385
+ }
386
+
387
+ interface CopilotSettingsFile {
388
+ version?: number;
389
+ hooks?: Record<string, ClaudeHookMatcher[]>;
390
+ [key: string]: unknown;
391
+ }
392
+
393
+ function isMarkedCopilotHook(hook: Record<string, unknown>): boolean {
394
+ if (hook[FAILPROOFAI_HOOK_MARKER] === true) return true;
395
+ // Fallback for legacy installs predating the marker — Copilot entries store
396
+ // commands under `bash`/`powershell` rather than `command`, so check both.
397
+ const bash = typeof hook.bash === "string" ? hook.bash : "";
398
+ const ps = typeof hook.powershell === "string" ? hook.powershell : "";
399
+ for (const cmd of [bash, ps]) {
400
+ if (cmd.includes("failproofai") && cmd.includes("--hook")) return true;
401
+ }
402
+ return false;
403
+ }
404
+
405
+ export const copilot: Integration = {
406
+ id: "copilot",
407
+ displayName: "GitHub Copilot",
408
+ scopes: COPILOT_HOOK_SCOPES,
409
+ eventTypes: COPILOT_HOOK_EVENT_TYPES,
410
+
411
+ getSettingsPath(scope, cwd) {
412
+ const base = cwd ? resolve(cwd) : process.cwd();
413
+ switch (scope) {
414
+ case "user":
415
+ return resolve(homedir(), ".copilot", "hooks", "failproofai.json");
416
+ case "project":
417
+ return resolve(base, ".github", "hooks", "failproofai.json");
418
+ case "local":
419
+ // Copilot has no "local" scope; CLI rejects --cli copilot --scope local
420
+ // before reaching here, but fall back to project so callers don't crash.
421
+ return resolve(base, ".github", "hooks", "failproofai.json");
422
+ }
423
+ },
424
+
425
+ readSettings(settingsPath) {
426
+ const raw = readJsonFile(settingsPath);
427
+ if (raw.version === undefined) raw.version = 1;
428
+ return raw;
429
+ },
430
+
431
+ writeSettings(settingsPath, settings) {
432
+ writeJsonFile(settingsPath, settings);
433
+ },
434
+
435
+ buildHookEntry(binaryPath, eventType, scope) {
436
+ const cmd =
437
+ scope === "project"
438
+ ? `npx -y failproofai --hook ${eventType} --cli copilot`
439
+ : `"${binaryPath}" --hook ${eventType} --cli copilot`;
440
+ return {
441
+ type: "command",
442
+ bash: cmd,
443
+ powershell: cmd,
444
+ timeoutSec: 60,
445
+ [FAILPROOFAI_HOOK_MARKER]: true,
446
+ };
447
+ },
448
+
449
+ isFailproofaiHook: isMarkedCopilotHook,
450
+
451
+ writeHookEntries(settings, binaryPath, scope) {
452
+ const s = settings as CopilotSettingsFile;
453
+ if (s.version === undefined) s.version = 1;
454
+ if (!s.hooks) s.hooks = {};
455
+
456
+ for (const eventType of COPILOT_HOOK_EVENT_TYPES) {
457
+ const hookEntry = this.buildHookEntry(binaryPath, eventType, scope) as unknown as CopilotHookEntry;
458
+ if (!s.hooks[eventType]) s.hooks[eventType] = [];
459
+ const matchers: ClaudeHookMatcher[] = s.hooks[eventType];
460
+
461
+ let found = false;
462
+ for (const matcher of matchers) {
463
+ if (!matcher.hooks) continue;
464
+ const idx = matcher.hooks.findIndex((h) => isMarkedCopilotHook(h as Record<string, unknown>));
465
+ if (idx >= 0) {
466
+ matcher.hooks[idx] = hookEntry as unknown as ClaudeHookEntry;
467
+ found = true;
468
+ break;
469
+ }
470
+ }
471
+ if (!found) matchers.push({ hooks: [hookEntry as unknown as ClaudeHookEntry] });
472
+ }
473
+ },
474
+
475
+ removeHooksFromFile(settingsPath) {
476
+ const settings = this.readSettings(settingsPath) as CopilotSettingsFile;
477
+ if (!settings.hooks) return 0;
478
+
479
+ let removed = 0;
480
+ for (const eventType of Object.keys(settings.hooks)) {
481
+ const matchers = settings.hooks[eventType];
482
+ if (!Array.isArray(matchers)) continue;
483
+ for (let i = matchers.length - 1; i >= 0; i--) {
484
+ const matcher = matchers[i];
485
+ if (!matcher.hooks) continue;
486
+ const before = matcher.hooks.length;
487
+ matcher.hooks = matcher.hooks.filter((h) => !isMarkedCopilotHook(h as Record<string, unknown>));
488
+ removed += before - matcher.hooks.length;
489
+ if (matcher.hooks.length === 0) matchers.splice(i, 1);
490
+ }
491
+ if (matchers.length === 0) delete settings.hooks[eventType];
492
+ }
493
+ if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
494
+
495
+ this.writeSettings(settingsPath, settings as Record<string, unknown>);
496
+ return removed;
497
+ },
498
+
499
+ hooksInstalledInSettings(scope, cwd) {
500
+ const settingsPath = this.getSettingsPath(scope, cwd);
501
+ if (!existsSync(settingsPath)) return false;
502
+ try {
503
+ const settings = this.readSettings(settingsPath) as CopilotSettingsFile;
504
+ if (!settings.hooks) return false;
505
+ for (const matchers of Object.values(settings.hooks)) {
506
+ if (!Array.isArray(matchers)) continue;
507
+ for (const matcher of matchers) {
508
+ if (!matcher.hooks) continue;
509
+ if (matcher.hooks.some((h) => isMarkedCopilotHook(h as Record<string, unknown>))) return true;
510
+ }
511
+ }
512
+ } catch {
513
+ // Corrupt settings — treat as not installed
514
+ }
515
+ return false;
516
+ },
517
+
518
+ detectInstalled() {
519
+ return binaryExists("copilot");
520
+ },
521
+ };
522
+
523
+ // ── Cursor Agent CLI integration ───────────────────────────────────────────
524
+ //
525
+ // Cursor's hooks.json schema is a FLAT array of hook entries per event —
526
+ // `{ hooks: { preToolUse: [{ command, type, timeout, ... }] } }` — without
527
+ // the Claude-style `{ hooks: [...] }` matcher wrapper. The settings file
528
+ // carries `version: 1` like Codex/Copilot. Differences from Claude:
529
+ // • Settings paths: ~/.cursor/hooks.json (user) and <cwd>/.cursor/hooks.json (project)
530
+ // • Event keys are camelCase (`preToolUse`, `beforeSubmitPrompt`, …); we
531
+ // canonicalize to PascalCase in handler.ts before policy lookup
532
+ // • Stdout decision shape differs (`{permission, user_message, agent_message,
533
+ // additional_context}`); the Cursor branch in policy-evaluator.ts emits it
534
+ // • No "local" scope
535
+ // • Detected via the `cursor-agent` binary (preferred) or `agent` (legacy alias)
536
+ //
537
+ // Ref: https://cursor.com/docs/hooks (Schema section).
538
+
539
+ interface CursorSettingsFile {
540
+ version?: number;
541
+ /** Flat array of hook entries per event — NOT wrapped in `{ hooks: [...] }`. */
542
+ hooks?: Record<string, Array<ClaudeHookEntry | Record<string, unknown>>>;
543
+ [key: string]: unknown;
544
+ }
545
+
546
+ export const cursor: Integration = {
547
+ id: "cursor",
548
+ displayName: "Cursor Agent",
549
+ scopes: CURSOR_HOOK_SCOPES,
550
+ eventTypes: CURSOR_HOOK_EVENT_TYPES,
551
+
552
+ getSettingsPath(scope, cwd) {
553
+ const base = cwd ? resolve(cwd) : process.cwd();
554
+ switch (scope) {
555
+ case "user":
556
+ return resolve(homedir(), ".cursor", "hooks.json");
557
+ case "project":
558
+ return resolve(base, ".cursor", "hooks.json");
559
+ case "local":
560
+ // Cursor has no "local" scope; CLI rejects --cli cursor --scope local
561
+ // before reaching here, but fall back to project so callers don't crash.
562
+ return resolve(base, ".cursor", "hooks.json");
563
+ }
564
+ },
565
+
566
+ readSettings(settingsPath) {
567
+ const raw = readJsonFile(settingsPath);
568
+ if (raw.version === undefined) raw.version = 1;
569
+ return raw;
570
+ },
571
+
572
+ writeSettings(settingsPath, settings) {
573
+ writeJsonFile(settingsPath, settings);
574
+ },
575
+
576
+ buildHookEntry(binaryPath, eventType, scope) {
577
+ const command =
578
+ scope === "project"
579
+ ? `npx -y failproofai --hook ${eventType} --cli cursor`
580
+ : `"${binaryPath}" --hook ${eventType} --cli cursor`;
581
+ // `timeout` is documented as ms in Cursor's schema (matches Claude).
582
+ return {
583
+ type: "command",
584
+ command,
585
+ timeout: 60_000,
586
+ [FAILPROOFAI_HOOK_MARKER]: true,
587
+ };
588
+ },
589
+
590
+ isFailproofaiHook: isMarkedHook,
591
+
592
+ writeHookEntries(settings, binaryPath, scope) {
593
+ const s = settings as CursorSettingsFile;
594
+ if (s.version === undefined) s.version = 1;
595
+ if (!s.hooks) s.hooks = {};
596
+
597
+ for (const eventType of CURSOR_HOOK_EVENT_TYPES) {
598
+ const hookEntry = this.buildHookEntry(binaryPath, eventType, scope) as unknown as ClaudeHookEntry;
599
+ const existing = s.hooks[eventType];
600
+ const entries: Array<ClaudeHookEntry | Record<string, unknown>> = existing ?? [];
601
+ if (!existing) s.hooks[eventType] = entries;
602
+
603
+ // Idempotent: replace an existing failproofai-marked entry; otherwise append.
604
+ const idx = entries.findIndex((h) => isMarkedHook(h as Record<string, unknown>));
605
+ if (idx >= 0) {
606
+ entries[idx] = hookEntry;
607
+ } else {
608
+ entries.push(hookEntry);
609
+ }
610
+ }
611
+ },
612
+
613
+ removeHooksFromFile(settingsPath) {
614
+ const settings = this.readSettings(settingsPath) as CursorSettingsFile;
615
+ if (!settings.hooks) return 0;
616
+
617
+ let removed = 0;
618
+ for (const eventType of Object.keys(settings.hooks)) {
619
+ const entries = settings.hooks[eventType];
620
+ if (!Array.isArray(entries)) continue;
621
+ const before = entries.length;
622
+ const filtered = entries.filter((h) => !isMarkedHook(h as Record<string, unknown>));
623
+ removed += before - filtered.length;
624
+ if (filtered.length === 0) {
625
+ delete settings.hooks[eventType];
626
+ } else {
627
+ settings.hooks[eventType] = filtered;
628
+ }
629
+ }
630
+ if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
631
+
632
+ this.writeSettings(settingsPath, settings as Record<string, unknown>);
633
+ return removed;
634
+ },
635
+
636
+ hooksInstalledInSettings(scope, cwd) {
637
+ const settingsPath = this.getSettingsPath(scope, cwd);
638
+ if (!existsSync(settingsPath)) return false;
639
+ try {
640
+ const settings = this.readSettings(settingsPath) as CursorSettingsFile;
641
+ if (!settings.hooks) return false;
642
+ for (const entries of Object.values(settings.hooks)) {
643
+ if (!Array.isArray(entries)) continue;
644
+ if (entries.some((h) => isMarkedHook(h as Record<string, unknown>))) return true;
645
+ }
646
+ } catch {
647
+ // Corrupt settings — treat as not installed
648
+ }
649
+ return false;
650
+ },
651
+
652
+ detectInstalled() {
653
+ return binaryExists("cursor-agent") || binaryExists("agent");
654
+ },
655
+ };
656
+
657
+ // ── OpenCode (sst/opencode) integration ────────────────────────────────────
658
+ //
659
+ // OpenCode does not have an external-command hook system. Plugins are
660
+ // in-process JS/TS modules registered via the `plugin: []` array in
661
+ // `opencode.json`. To reuse the existing failproofai evaluator without
662
+ // forking the codebase, this integration drops a generated plugin shim
663
+ // at `.opencode/plugins/failproofai.mjs` (project) or
664
+ // `~/.config/opencode/plugins/failproofai.mjs` (user) AND edits the
665
+ // adjacent `opencode.json` to register it. The shim subprocess-calls the
666
+ // failproofai binary with `--cli opencode` and translates the binary's
667
+ // Claude-shape JSON response back into plugin semantics:
668
+ // • exit 2 OR `permissionDecision: "deny"` → `throw new Error(reason)`
669
+ // (which OpenCode surfaces as a tool-call failure to the agent)
670
+ // • `additionalContext` → `client.session.prompt(...)` (fire-and-forget)
671
+ // • everything else → no-op (allow)
672
+ //
673
+ // Settings paths:
674
+ // user → ~/.config/opencode/opencode.json (+ plugins/failproofai.mjs)
675
+ // project → <cwd>/.opencode/opencode.json (+ plugins/failproofai.mjs)
676
+ // OpenCode has no `local` scope.
677
+ //
678
+ // Verified live against opencode v1.14.31 — see the Live findings section
679
+ // of the implementation plan for the full event surface and SDK shape.
680
+ //
681
+ // Ref: https://opencode.ai/docs/plugins/
682
+
683
+ interface OpenCodeSettingsFile {
684
+ /** OpenCode plugin registration array — npm spec OR file:// URL OR relative path OR [spec, options] tuple. */
685
+ plugin?: Array<string | [string, Record<string, unknown>]>;
686
+ [key: string]: unknown;
687
+ }
688
+
689
+ /** Path of the generated plugin shim file relative to opencode.json. */
690
+ const OPENCODE_PLUGIN_REL_PATH = "./plugins/failproofai.mjs";
691
+
692
+ /** Returns the absolute path of the plugin shim, given the opencode.json settings path. */
693
+ function opencodePluginFilePath(settingsPath: string): string {
694
+ return resolve(dirname(settingsPath), "plugins", "failproofai.mjs");
695
+ }
696
+
697
+ /**
698
+ * Generate the plugin shim source. Embeds a binary command so the shim is
699
+ * self-contained — it doesn't need to resolve `failproofai` at runtime.
700
+ * • project scope: spawn `npx -y failproofai` (portable across machines)
701
+ * • user scope: spawn the absolute binary path (avoids npm round-trip on
702
+ * every tool call — failproofai's hooks are hot-path)
703
+ */
704
+ function buildOpenCodePluginShim(binaryPath: string, scope: HookScope): string {
705
+ const useNpx = scope === "project";
706
+ // For project scope, do NOT embed the installer's absolute binary path —
707
+ // it's machine-specific (changes between dev boxes / CI / production
708
+ // installs). The shim only uses FAILPROOFAI_BIN when USE_NPX is false,
709
+ // so an empty string is safe.
710
+ const escapedBin = useNpx ? '""' : JSON.stringify(binaryPath);
711
+ return `// AUTO-GENERATED by failproofai. ${FAILPROOFAI_HOOK_MARKER}
712
+ // Re-generate via: failproofai policies --install --cli opencode
713
+ // Plugin shim that bridges OpenCode's plugin API to the failproofai binary.
714
+ // See: https://opencode.ai/docs/plugins/
715
+ import { spawnSync } from "node:child_process";
716
+
717
+ // Map opencode bus-event types → canonical failproofai event names.
718
+ // (The binary sees PascalCase — the binary's --cli=opencode flag is for
719
+ // telemetry / activity tagging only; no opencode branch in handler.ts.)
720
+ const BUS_EVENT_MAP = {
721
+ "session.created": "SessionStart",
722
+ "session.deleted": "SessionEnd",
723
+ "session.idle": "Stop",
724
+ // message.updated is handled separately (filter to role:user); see below.
725
+ };
726
+
727
+ const FAILPROOFAI_BIN = ${escapedBin};
728
+ const USE_NPX = ${useNpx};
729
+
730
+ function runFailproofai(eventName, payload, directory) {
731
+ const cmd = USE_NPX ? "npx" : FAILPROOFAI_BIN;
732
+ const args = USE_NPX
733
+ ? ["-y", "failproofai", "--hook", eventName, "--cli", "opencode"]
734
+ : ["--hook", eventName, "--cli", "opencode"];
735
+ const r = spawnSync(cmd, args, {
736
+ input: JSON.stringify(payload),
737
+ encoding: "utf8",
738
+ timeout: 60_000,
739
+ cwd: directory,
740
+ });
741
+ return { exitCode: r.status ?? 0, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
742
+ }
743
+
744
+ function applyDecision(result, ctx) {
745
+ // Deny path 1: exit 2 (Claude Stop-style or any non-Pre/Post deny).
746
+ if (result.exitCode === 2) {
747
+ throw new Error((result.stderr || "").trim() || "Blocked by failproofai");
748
+ }
749
+ // Deny path 2: stdout JSON with hookSpecificOutput.permissionDecision === "deny".
750
+ let parsed = null;
751
+ try { parsed = JSON.parse(result.stdout); } catch { /* fail-open allow */ }
752
+ if (!parsed) return;
753
+ const out = parsed.hookSpecificOutput;
754
+ if (out && out.permissionDecision === "deny") {
755
+ throw new Error(out.permissionDecisionReason || "Blocked by failproofai");
756
+ }
757
+ // Codex-shape PermissionRequest deny: hookSpecificOutput.decision.behavior.
758
+ if (out && out.decision && out.decision.behavior === "deny") {
759
+ throw new Error((out.decision.message) || "Blocked by failproofai");
760
+ }
761
+ // Instruct: forward the additional context as a prompt to the session.
762
+ const ctxText = out && out.additionalContext;
763
+ if (ctxText && ctx && ctx.client && ctx.sessionID) {
764
+ // Fire-and-forget: don't block the tool call on the SDK round-trip.
765
+ Promise.resolve(ctx.client.session.prompt({
766
+ path: { id: ctx.sessionID },
767
+ body: { parts: [{ type: "text", text: ctxText }] },
768
+ })).catch(() => {});
769
+ }
770
+ }
771
+
772
+ export default async function failproofaiPlugin({ client, directory }) {
773
+ return {
774
+ // Generic bus events: session lifecycle + user-prompt detection.
775
+ event: async ({ event }) => {
776
+ if (!event || !event.type) return;
777
+
778
+ // UserPromptSubmit — filter message.updated to user role only so we
779
+ // don't fire on every assistant token. Forward the prompt text so
780
+ // prompt-based policies (sanitize-* on input, content checks) see it.
781
+ if (event.type === "message.updated") {
782
+ const props = event.properties || {};
783
+ const info = props.info || props.message || {};
784
+ const role = info.role || props.role;
785
+ if (role !== "user") return;
786
+ const sessionID = info.sessionID || info.sessionId || info.session_id || props.sessionID;
787
+ // OpenCode's message shape: parts is an array of {type, text, ...}.
788
+ // Concatenate text parts to reconstruct the user-facing prompt.
789
+ // Fall back to direct text/content fields if a future shape differs.
790
+ let prompt = "";
791
+ const parts = info.parts || props.parts || [];
792
+ if (Array.isArray(parts)) {
793
+ for (const p of parts) {
794
+ if (p && typeof p === "object" && typeof p.text === "string") prompt += p.text;
795
+ }
796
+ }
797
+ if (!prompt) prompt = (info.text || info.content || props.text || "").toString();
798
+ const r = runFailproofai("UserPromptSubmit", {
799
+ session_id: sessionID, cwd: directory, hook_event_name: "UserPromptSubmit", prompt,
800
+ }, directory);
801
+ applyDecision(r, { client, sessionID });
802
+ return;
803
+ }
804
+
805
+ const claudeEvent = BUS_EVENT_MAP[event.type];
806
+ if (!claudeEvent) return;
807
+ const props = event.properties || {};
808
+ const sessionID = props.sessionID || (props.session && props.session.id) || props.id;
809
+ const r = runFailproofai(claudeEvent, {
810
+ session_id: sessionID, cwd: directory, hook_event_name: claudeEvent,
811
+ }, directory);
812
+ applyDecision(r, { client, sessionID });
813
+ },
814
+
815
+ // First-class PreToolUse hook. Note: tool args live on output.args (mutable).
816
+ "tool.execute.before": async (input, output) => {
817
+ const r = runFailproofai("PreToolUse", {
818
+ session_id: input.sessionID,
819
+ cwd: directory,
820
+ tool_name: input.tool,
821
+ tool_input: output.args,
822
+ hook_event_name: "PreToolUse",
823
+ }, directory);
824
+ applyDecision(r, { client, sessionID: input.sessionID });
825
+ },
826
+
827
+ // First-class PostToolUse hook. Note: tool args live on input.args here.
828
+ "tool.execute.after": async (input, output) => {
829
+ const r = runFailproofai("PostToolUse", {
830
+ session_id: input.sessionID,
831
+ cwd: directory,
832
+ tool_name: input.tool,
833
+ tool_input: input.args,
834
+ tool_response: { title: output.title, output: output.output, metadata: output.metadata },
835
+ hook_event_name: "PostToolUse",
836
+ }, directory);
837
+ applyDecision(r, { client, sessionID: input.sessionID });
838
+ },
839
+
840
+ // Cleaner deny UX for prompted tools — mutate output.status instead of throwing.
841
+ "permission.ask": async (input, output) => {
842
+ const r = runFailproofai("PermissionRequest", {
843
+ session_id: input.sessionID,
844
+ cwd: directory,
845
+ tool_name: input.tool || input.command || "permission",
846
+ tool_input: input,
847
+ hook_event_name: "PermissionRequest",
848
+ }, directory);
849
+ try {
850
+ applyDecision(r, { client, sessionID: input.sessionID });
851
+ } catch {
852
+ output.status = "deny";
853
+ }
854
+ },
855
+ };
856
+ }
857
+ `;
858
+ }
859
+
860
+ export const opencode: Integration = {
861
+ id: "opencode",
862
+ displayName: "OpenCode",
863
+ scopes: OPENCODE_HOOK_SCOPES,
864
+ eventTypes: OPENCODE_HOOK_EVENT_TYPES,
865
+
866
+ getSettingsPath(scope, cwd) {
867
+ const base = cwd ? resolve(cwd) : process.cwd();
868
+ switch (scope) {
869
+ case "user":
870
+ return resolve(homedir(), ".config", "opencode", "opencode.json");
871
+ case "project":
872
+ return resolve(base, ".opencode", "opencode.json");
873
+ case "local":
874
+ // OpenCode has no "local" scope — fall back to project so callers don't crash.
875
+ return resolve(base, ".opencode", "opencode.json");
876
+ }
877
+ },
878
+
879
+ readSettings(settingsPath) {
880
+ return readJsonFile(settingsPath);
881
+ },
882
+
883
+ writeSettings(settingsPath, settings) {
884
+ writeJsonFile(settingsPath, settings);
885
+ },
886
+
887
+ /**
888
+ * Returns the plugin entry that gets pushed into opencode.json's `plugin`
889
+ * array. Project scope uses a relative path (resolved against the config
890
+ * file's directory by opencode); user scope uses a `file://` URL with the
891
+ * absolute path so it works regardless of the user's cwd at startup.
892
+ */
893
+ buildHookEntry(_binaryPath, _eventType, scope) {
894
+ if (scope === "user") {
895
+ const abs = resolve(homedir(), ".config", "opencode", "plugins", "failproofai.mjs");
896
+ return { spec: `file://${abs}`, [FAILPROOFAI_HOOK_MARKER]: true };
897
+ }
898
+ return { spec: OPENCODE_PLUGIN_REL_PATH, [FAILPROOFAI_HOOK_MARKER]: true };
899
+ },
900
+
901
+ /** True if the array entry references our plugin filename. */
902
+ isFailproofaiHook(hook) {
903
+ if (typeof hook === "string") return hook.includes("failproofai.mjs");
904
+ if (Array.isArray(hook)) return typeof hook[0] === "string" && hook[0].includes("failproofai.mjs");
905
+ return false;
906
+ },
907
+
908
+ /**
909
+ * Atomically install: (a) write the plugin shim file (overwrite is OK —
910
+ * marker keeps user files safe in removeHooksFromFile); (b) merge our
911
+ * plugin entry into opencode.json's `plugin` array.
912
+ */
913
+ writeHookEntries(settings, binaryPath, scope) {
914
+ const s = settings as OpenCodeSettingsFile;
915
+ const effectiveScope: HookScope = scope ?? "project";
916
+
917
+ // Compute the settings path so we know where to drop the shim.
918
+ // We can't introspect cwd from `settings` alone, so use the convention
919
+ // that callers always pass settings read from the path they're about
920
+ // to write back to. For user scope the homedir resolves; for project
921
+ // scope we infer from process.cwd() — which matches the codepath in
922
+ // hooksInstalledInSettings/getSettingsPath without a cwd arg.
923
+ const settingsPath = effectiveScope === "user"
924
+ ? resolve(homedir(), ".config", "opencode", "opencode.json")
925
+ : resolve(process.cwd(), ".opencode", "opencode.json");
926
+ const pluginPath = opencodePluginFilePath(settingsPath);
927
+
928
+ // (a) Write the shim file. mkdirSync is recursive so the plugins/ dir
929
+ // is created on first install.
930
+ mkdirSync(dirname(pluginPath), { recursive: true });
931
+ writeFileSync(pluginPath, buildOpenCodePluginShim(binaryPath, effectiveScope), "utf8");
932
+
933
+ // (b) Merge our entry into the plugin array idempotently. Replace any
934
+ // existing failproofai-marked entry; otherwise append.
935
+ if (!Array.isArray(s.plugin)) s.plugin = [];
936
+ const desired: string = effectiveScope === "user" ? `file://${pluginPath}` : OPENCODE_PLUGIN_REL_PATH;
937
+ const idx = s.plugin.findIndex((entry) => this.isFailproofaiHook(entry));
938
+ if (idx >= 0) {
939
+ s.plugin[idx] = desired;
940
+ } else {
941
+ s.plugin.push(desired);
942
+ }
943
+ },
944
+
945
+ /**
946
+ * Uninstall: (a) remove our plugin entry from the array; if the array is
947
+ * empty, delete the key. (b) Delete the plugin file ONLY if it has the
948
+ * failproofai marker — never delete a hand-written plugin file at the
949
+ * same path.
950
+ */
951
+ removeHooksFromFile(settingsPath) {
952
+ let removed = 0;
953
+ const settings = this.readSettings(settingsPath) as OpenCodeSettingsFile;
954
+ if (Array.isArray(settings.plugin)) {
955
+ const before = settings.plugin.length;
956
+ settings.plugin = settings.plugin.filter((entry) => !this.isFailproofaiHook(entry));
957
+ removed += before - settings.plugin.length;
958
+ if (settings.plugin.length === 0) delete settings.plugin;
959
+ }
960
+ this.writeSettings(settingsPath, settings as Record<string, unknown>);
961
+
962
+ const pluginPath = opencodePluginFilePath(settingsPath);
963
+ if (existsSync(pluginPath)) {
964
+ try {
965
+ const content = readFileSync(pluginPath, "utf8");
966
+ if (content.includes(FAILPROOFAI_HOOK_MARKER)) {
967
+ unlinkSync(pluginPath);
968
+ if (removed === 0) removed = 1; // file existed; treat as removed even if array was clean
969
+ }
970
+ } catch {
971
+ // Best-effort cleanup; ignore read/unlink failures.
972
+ }
973
+ }
974
+ return removed;
975
+ },
976
+
977
+ hooksInstalledInSettings(scope, cwd) {
978
+ const settingsPath = this.getSettingsPath(scope, cwd);
979
+ if (!existsSync(settingsPath)) return false;
980
+ try {
981
+ const settings = this.readSettings(settingsPath) as OpenCodeSettingsFile;
982
+ if (!Array.isArray(settings.plugin)) return false;
983
+ const hasEntry = settings.plugin.some((entry) => this.isFailproofaiHook(entry));
984
+ if (!hasEntry) return false;
985
+ const pluginPath = opencodePluginFilePath(settingsPath);
986
+ if (!existsSync(pluginPath)) return false;
987
+ const content = readFileSync(pluginPath, "utf8");
988
+ return content.includes(FAILPROOFAI_HOOK_MARKER);
989
+ } catch {
990
+ return false;
991
+ }
992
+ },
993
+
994
+ detectInstalled() {
995
+ return binaryExists("opencode");
996
+ },
997
+ };
998
+
999
+
1000
+ // ── Pi (pi-coding-agent) integration ───────────────────────────────────────
1001
+ //
1002
+ // Pi loads TypeScript extension packages registered in `.pi/settings.json`.
1003
+ // Schema (verified empirically against pi-coding-agent v0.72.1):
1004
+ //
1005
+ // {"packages": ["./relative/path", "/abs/path", "npm:@scope/name"]}
1006
+ //
1007
+ // Entries are PLAIN STRINGS — there's no per-entry object where the
1008
+ // FAILPROOFAI_HOOK_MARKER could live. We identify failproofai's entry by a
1009
+ // path-substring match (`includes("pi-extension") && includes("failproofai")`).
1010
+ //
1011
+ // Path semantics: a relative entry like `../pi-extension` is resolved relative
1012
+ // to the directory containing settings.json (i.e. `<cwd>/.pi/`). For dogfood
1013
+ // where the extension lives at `<cwd>/pi-extension/`, the correct entry is
1014
+ // `"../pi-extension"`. For user-scope global installs where failproofai lives
1015
+ // in the npm global root, we write the absolute path.
1016
+ //
1017
+ // Settings file paths (verified — `~/.pi/settings.json` does NOT exist on a
1018
+ // fresh install; user-scope is under `~/.pi/agent/`):
1019
+ // user → ~/.pi/agent/settings.json
1020
+ // project → <cwd>/.pi/settings.json
1021
+ //
1022
+ // Pi events arrive as `tool_call` / `user_bash` / `input` / `session_start`
1023
+ // (underscore_lower_snake_case); handler.ts canonicalizes via PI_EVENT_MAP.
1024
+ // Tool-call payloads use camelCase: `event.toolName`, `event.input`,
1025
+ // `event.toolCallId`. `tool_call` handlers can `return { block: true, reason }`
1026
+ // to veto the tool call — this is how PreToolUse deny is enforced.
1027
+ //
1028
+ // Detected via the `pi` binary on PATH.
1029
+
1030
+ interface PiSettingsFile {
1031
+ packages?: string[];
1032
+ [key: string]: unknown;
1033
+ }
1034
+
1035
+ /** Returns the absolute path to the failproofai-shipped Pi extension package. */
1036
+ function getPiExtensionPath(): string {
1037
+ // Resolve relative to the installed failproofai package root, falling back
1038
+ // to FAILPROOFAI_PACKAGE_ROOT (set by bin/failproofai.mjs) for dev mode.
1039
+ const fromEnv = process.env.FAILPROOFAI_PACKAGE_ROOT;
1040
+ if (fromEnv) return resolve(fromEnv, "pi-extension");
1041
+ // Fallback: walk up from this file (src/hooks/integrations.ts) two levels.
1042
+ return resolve(fileURLToPath(import.meta.url), "..", "..", "..", "pi-extension");
1043
+ }
1044
+
1045
+ /** True iff a Pi packages-array entry was written by failproofai. */
1046
+ function isFailproofaiPiEntry(source: unknown): boolean {
1047
+ if (typeof source !== "string") return false;
1048
+ // Project-scope writes a relative `../pi-extension` (or similar) — these
1049
+ // must be detected as ours so reinstall/uninstall/hooksInstalledInSettings
1050
+ // don't double-write or leak entries.
1051
+ if (/(?:^|\/)pi-extension\/?$/.test(source)) return true;
1052
+ // Absolute / scoped forms include "failproofai" somewhere in the path
1053
+ // (the canonical `<failproofai-install>/pi-extension/` and a future
1054
+ // `@failproofai/pi-extension` npm scope both qualify).
1055
+ return source.includes("pi-extension") && source.includes("failproofai");
1056
+ }
1057
+
1058
+ export const pi: Integration = {
1059
+ id: "pi",
1060
+ displayName: "Pi",
1061
+ scopes: PI_HOOK_SCOPES,
1062
+ eventTypes: PI_HOOK_EVENT_TYPES,
1063
+
1064
+ getSettingsPath(scope, cwd) {
1065
+ const base = cwd ? resolve(cwd) : process.cwd();
1066
+ switch (scope) {
1067
+ case "user":
1068
+ return resolve(homedir(), ".pi", "agent", "settings.json");
1069
+ case "project":
1070
+ return resolve(base, ".pi", "settings.json");
1071
+ case "local":
1072
+ // Pi has no "local" scope; CLI rejects --cli pi --scope local before
1073
+ // reaching here, but fall back to project so callers don't crash.
1074
+ return resolve(base, ".pi", "settings.json");
1075
+ }
1076
+ },
1077
+
1078
+ readSettings(settingsPath) {
1079
+ return readJsonFile(settingsPath);
1080
+ },
1081
+
1082
+ writeSettings(settingsPath, settings) {
1083
+ writeJsonFile(settingsPath, settings);
1084
+ },
1085
+
1086
+ buildHookEntry(_binaryPath, _eventType, scope) {
1087
+ // Pi registers extensions at the package level — one entry covers all
1088
+ // events. The package's index.ts wires the four pi.on(...) handlers.
1089
+ // The "entry" returned here is a sentinel object so the Integration
1090
+ // interface's typing is satisfied; writeHookEntries resolves the actual
1091
+ // string entry below.
1092
+ return {
1093
+ [FAILPROOFAI_HOOK_MARKER]: true,
1094
+ _piPackagePath: getPiExtensionPath(),
1095
+ _piScope: scope,
1096
+ };
1097
+ },
1098
+
1099
+ isFailproofaiHook(hook) {
1100
+ // Real on-disk entries are plain strings (a packages array entry).
1101
+ if (typeof hook === "string") return isFailproofaiPiEntry(hook);
1102
+ if (!hook || typeof hook !== "object") return false;
1103
+ const h = hook as Record<string, unknown>;
1104
+ if (h[FAILPROOFAI_HOOK_MARKER] === true) return true;
1105
+ // Test fixtures sometimes pass a wrapper `{source: "..."}`; preserve that shape.
1106
+ if (typeof h.source === "string") return isFailproofaiPiEntry(h.source);
1107
+ return false;
1108
+ },
1109
+
1110
+ writeHookEntries(settings, _binaryPath, scope) {
1111
+ const s = settings as PiSettingsFile;
1112
+ if (!Array.isArray(s.packages)) s.packages = [];
1113
+
1114
+ const extPath = getPiExtensionPath();
1115
+ // Project-scope writes a relative path (resolved by Pi at load time
1116
+ // against `<cwd>/.pi/`) so a committed `.pi/settings.json` is portable
1117
+ // across contributors. User-scope writes an absolute path because each
1118
+ // user's failproofai install has its own absolute location.
1119
+ const entry = scope === "project"
1120
+ ? makePiProjectRelativeEntry(extPath)
1121
+ : extPath;
1122
+
1123
+ // Idempotent: replace any existing failproofai entry, otherwise append.
1124
+ const idx = s.packages.findIndex((p) => isFailproofaiPiEntry(p));
1125
+ if (idx >= 0) {
1126
+ s.packages[idx] = entry;
1127
+ } else {
1128
+ s.packages.push(entry);
1129
+ }
1130
+ },
1131
+
1132
+ removeHooksFromFile(settingsPath) {
1133
+ if (!existsSync(settingsPath)) return 0;
1134
+ const settings = this.readSettings(settingsPath) as PiSettingsFile;
1135
+ if (!Array.isArray(settings.packages)) return 0;
1136
+
1137
+ const before = settings.packages.length;
1138
+ settings.packages = settings.packages.filter((p) => !isFailproofaiPiEntry(p));
1139
+ const removed = before - settings.packages.length;
1140
+
1141
+ if (settings.packages.length === 0) delete settings.packages;
1142
+ this.writeSettings(settingsPath, settings as Record<string, unknown>);
1143
+ return removed;
1144
+ },
1145
+
1146
+ hooksInstalledInSettings(scope, cwd) {
1147
+ const settingsPath = this.getSettingsPath(scope, cwd);
1148
+ if (!existsSync(settingsPath)) return false;
1149
+ try {
1150
+ const settings = this.readSettings(settingsPath) as PiSettingsFile;
1151
+ if (!Array.isArray(settings.packages)) return false;
1152
+ return settings.packages.some((p) => isFailproofaiPiEntry(p));
1153
+ } catch {
1154
+ // Corrupt settings — treat as not installed
1155
+ return false;
1156
+ }
1157
+ },
1158
+
1159
+ detectInstalled() {
1160
+ return binaryExists("pi");
1161
+ },
1162
+ };
1163
+
1164
+ /**
1165
+ * Compute a relative path from `<settings.json's parent>` to the extension
1166
+ * directory, so the entry is portable across contributors who clone the repo
1167
+ * to different absolute paths.
1168
+ *
1169
+ * For project scope, settings.json lives at `<cwd>/.pi/settings.json`, and
1170
+ * the extension at `<cwd>/pi-extension/`. The relative path Pi expects
1171
+ * (resolved against `<cwd>/.pi/`) is `../pi-extension`.
1172
+ *
1173
+ * If the extension path is not under the project root (e.g. failproofai is
1174
+ * installed globally and being written to a project), falls back to the
1175
+ * absolute path so resolution still works on this machine.
1176
+ */
1177
+ function makePiProjectRelativeEntry(extPath: string): string {
1178
+ const cwd = process.cwd();
1179
+ const cwdResolved = resolve(cwd);
1180
+ const extResolved = resolve(extPath);
1181
+ if (extResolved.startsWith(cwdResolved + "/") || extResolved === cwdResolved) {
1182
+ // Walk back up from <cwd>/.pi/ to <cwd>/, then forward to the extension.
1183
+ const fromSettingsDir = "../" + extResolved.slice(cwdResolved.length + 1);
1184
+ return fromSettingsDir;
1185
+ }
1186
+ // Extension lives outside the project — keep it absolute. Not portable, but
1187
+ // works for the local user.
1188
+ return extResolved;
1189
+ }
1190
+ // ── Gemini CLI integration ──────────────────────────────────────────────────
1191
+ //
1192
+ // Gemini's hook contract is the closest thing to a Claude Code clone we've
1193
+ // shipped: same `{matcher, hooks: [{type, command, timeout}]}` settings shape,
1194
+ // PascalCase event names, snake_case stdin payload field names (session_id,
1195
+ // tool_name, tool_input, hook_event_name, cwd, transcript_path), subprocess
1196
+ // execution model, and `$CLAUDE_PROJECT_DIR` env-var alias on top of its own
1197
+ // `$GEMINI_PROJECT_DIR`. The integration is structurally identical to
1198
+ // claudeCode below, with three deltas:
1199
+ //
1200
+ // • Settings paths: ~/.gemini/settings.json (user) / <cwd>/.gemini/settings.json (project).
1201
+ // System scope (/etc/gemini-cli/settings.json) is documented but not exposed.
1202
+ //
1203
+ // • Matcher field: each Gemini matcher entry carries an explicit `matcher`
1204
+ // regex (e.g. `"write_file|replace"`). We default to `"*"` so policies fire
1205
+ // on every tool call, mirroring the failproofai default of "every event,
1206
+ // every tool". Users can hand-edit settings.json to scope tighter; we
1207
+ // preserve their `matcher` field across re-installs by NOT replacing
1208
+ // entries that aren't failproofai-marked.
1209
+ //
1210
+ // • Tool name canonicalization happens in handler.ts (snake_case →
1211
+ // PascalCase via GEMINI_TOOL_MAP) so policies match unchanged; not the
1212
+ // install layer's concern.
1213
+ //
1214
+ // Detected via the `gemini` binary on PATH.
1215
+ //
1216
+ // Ref: https://geminicli.com/docs/hooks/
1217
+
1218
+ interface GeminiHookMatcher {
1219
+ matcher?: string;
1220
+ hooks?: Array<ClaudeHookEntry | Record<string, unknown>>;
1221
+ }
1222
+
1223
+ interface GeminiSettingsFile {
1224
+ hooks?: Record<string, GeminiHookMatcher[]>;
1225
+ [key: string]: unknown;
1226
+ }
1227
+
1228
+ export const gemini: Integration = {
1229
+ id: "gemini",
1230
+ displayName: "Gemini CLI",
1231
+ scopes: GEMINI_HOOK_SCOPES,
1232
+ eventTypes: GEMINI_HOOK_EVENT_TYPES,
1233
+
1234
+ getSettingsPath(scope, cwd) {
1235
+ const base = cwd ? resolve(cwd) : process.cwd();
1236
+ switch (scope) {
1237
+ case "user":
1238
+ return resolve(homedir(), ".gemini", "settings.json");
1239
+ case "project":
1240
+ return resolve(base, ".gemini", "settings.json");
1241
+ case "local":
1242
+ // Gemini has no "local" scope; CLI rejects --cli gemini --scope local
1243
+ // before reaching here, but fall back to project so callers don't crash.
1244
+ return resolve(base, ".gemini", "settings.json");
1245
+ }
1246
+ },
1247
+
1248
+ readSettings(settingsPath) {
1249
+ return readJsonFile(settingsPath);
1250
+ },
1251
+
1252
+ writeSettings(settingsPath, settings) {
1253
+ writeJsonFile(settingsPath, settings);
1254
+ },
1255
+
1256
+ buildHookEntry(binaryPath, eventType, scope) {
1257
+ const command =
1258
+ scope === "project"
1259
+ ? `npx -y failproofai --hook ${eventType} --cli gemini`
1260
+ : `"${binaryPath}" --hook ${eventType} --cli gemini`;
1261
+ return {
1262
+ type: "command",
1263
+ command,
1264
+ timeout: 60_000,
1265
+ [FAILPROOFAI_HOOK_MARKER]: true,
1266
+ };
1267
+ },
1268
+
1269
+ isFailproofaiHook: isMarkedHook,
1270
+
1271
+ writeHookEntries(settings, binaryPath, scope) {
1272
+ const s = settings as GeminiSettingsFile;
1273
+ if (!s.hooks) s.hooks = {};
1274
+
1275
+ for (const eventType of GEMINI_HOOK_EVENT_TYPES) {
1276
+ const hookEntry = this.buildHookEntry(binaryPath, eventType, scope) as unknown as ClaudeHookEntry;
1277
+ if (!s.hooks[eventType]) s.hooks[eventType] = [];
1278
+ const matchers: GeminiHookMatcher[] = s.hooks[eventType];
1279
+
1280
+ // Idempotent: replace an existing failproofai-marked entry inside our
1281
+ // own matcher; otherwise append a new `{matcher: "*", hooks: [...]}`.
1282
+ // Hand-written matchers (with their own `matcher` regex) are never
1283
+ // touched — we identify our matcher by checking whether ANY of its
1284
+ // inner hooks are failproofai-marked.
1285
+ let found = false;
1286
+ for (const matcher of matchers) {
1287
+ if (!matcher.hooks) continue;
1288
+ const idx = matcher.hooks.findIndex((h) => isMarkedHook(h as Record<string, unknown>));
1289
+ if (idx >= 0) {
1290
+ matcher.hooks[idx] = hookEntry;
1291
+ found = true;
1292
+ break;
1293
+ }
1294
+ }
1295
+ if (!found) matchers.push({ matcher: "*", hooks: [hookEntry] });
1296
+ }
1297
+ },
1298
+
1299
+ removeHooksFromFile(settingsPath) {
1300
+ const settings = this.readSettings(settingsPath) as GeminiSettingsFile;
1301
+ if (!settings.hooks) return 0;
1302
+
1303
+ let removed = 0;
1304
+ for (const eventType of Object.keys(settings.hooks)) {
1305
+ const matchers = settings.hooks[eventType];
1306
+ if (!Array.isArray(matchers)) continue;
1307
+ for (let i = matchers.length - 1; i >= 0; i--) {
1308
+ const matcher = matchers[i];
1309
+ if (!matcher.hooks) continue;
1310
+ const before = matcher.hooks.length;
1311
+ matcher.hooks = matcher.hooks.filter((h) => !isMarkedHook(h as Record<string, unknown>));
1312
+ removed += before - matcher.hooks.length;
1313
+ if (matcher.hooks.length === 0) matchers.splice(i, 1);
1314
+ }
1315
+ if (matchers.length === 0) delete settings.hooks[eventType];
1316
+ }
1317
+ if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
1318
+
1319
+ this.writeSettings(settingsPath, settings as Record<string, unknown>);
1320
+ return removed;
1321
+ },
1322
+
1323
+ hooksInstalledInSettings(scope, cwd) {
1324
+ const settingsPath = this.getSettingsPath(scope, cwd);
1325
+ if (!existsSync(settingsPath)) return false;
1326
+ try {
1327
+ const settings = this.readSettings(settingsPath) as GeminiSettingsFile;
1328
+ if (!settings.hooks) return false;
1329
+ for (const matchers of Object.values(settings.hooks)) {
1330
+ if (!Array.isArray(matchers)) continue;
1331
+ for (const matcher of matchers) {
1332
+ if (!matcher.hooks) continue;
1333
+ if (matcher.hooks.some((h) => isMarkedHook(h as Record<string, unknown>))) return true;
1334
+ }
1335
+ }
1336
+ } catch {
1337
+ // Corrupt settings — treat as not installed
1338
+ }
1339
+ return false;
1340
+ },
1341
+
1342
+ detectInstalled() {
1343
+ return binaryExists("gemini");
1344
+ },
1345
+ };
1346
+
351
1347
  // ── Registry ────────────────────────────────────────────────────────────────
352
1348
 
353
1349
  const INTEGRATIONS: Record<IntegrationType, Integration> = {
354
1350
  claude: claudeCode,
355
1351
  codex,
1352
+ copilot,
1353
+ cursor,
1354
+ opencode,
1355
+ pi,
1356
+ gemini,
356
1357
  };
357
1358
 
358
1359
  export function getIntegration(id: IntegrationType): Integration {