failproofai 0.0.9-beta.2 → 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.
- package/.next/standalone/.cursor/hooks.json +47 -0
- package/.next/standalone/.gemini/settings.json +147 -0
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +3 -3
- package/.next/standalone/.next/prerender-manifest.json +3 -3
- package/.next/standalone/.next/required-server-files.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page.js +1 -1
- package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +2 -2
- package/.next/standalone/.next/server/app/_not-found.rsc +17 -17
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +17 -17
- package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
- package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +11 -11
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js +2 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +16 -16
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +16 -16
- package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
- package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +11 -11
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/page.js +1 -1
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +8 -8
- package/.next/standalone/.next/server/app/policies/page.js +1 -1
- package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page.js +2 -2
- package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +5 -5
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/projects/page.js +2 -2
- package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0.~nmr9._.js +3 -0
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0yspgjy._.js → [root-of-the-server]__010i6f5._.js} +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__08px0ym._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0b57.gk._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0dtn9lr._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0kjo7d_._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0vlhtkc._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0wu7fr7._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0yfq1yr._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0z4c5dj._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0zso~62._.js +3 -0
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_0z7w.hh._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0-2wr.c._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0.~m-w2._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__09icjsf._.js → [root-of-the-server]__0709m8.._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0bz245.._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0dl0kgt._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0gmhxyo._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0mup1hi._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ohb3gc._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qbpe_v._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s~gy6y._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0t5l7a5._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ymlddl._.js +152 -6
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0vrlxf2._.js → [root-of-the-server]__0ymn496._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0eu4j_n._.js → [root-of-the-server]__10h.ggz._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_03d7qyt._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/{_07a1g.3._.js → _0zx~s__._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_10lm7or._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/app_0cdqd9w._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_0xerkr6._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_0q-m0y-._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/lib_codex-projects_ts_0eosib~._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/lib_copilot-projects_ts_0r8xkn8._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/lib_cursor-projects_ts_0qt1scg._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/lib_gemini-projects_ts_0sl~yqr._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/lib_opencode-projects_ts_0op9gyp._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/lib_pi-projects_ts_103tsh1._.js +3 -0
- package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
- package/.next/standalone/.next/server/pages/404.html +2 -2
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +9 -9
- package/.next/standalone/.next/static/chunks/{06j6c0ofqjy0v.js → 00ay03h8bq4b~.js} +2 -2
- package/.next/standalone/.next/static/chunks/{15_mi91qaeieu.js → 0agmlhk5ml7x5.js} +1 -1
- package/.next/standalone/.next/static/chunks/0bi2r.m~yokoo.js +1 -0
- package/.next/standalone/.next/static/chunks/0en4v5k2nnxks.js +1 -0
- package/.next/standalone/.next/static/chunks/0q5bmqop--9yk.js +1 -0
- package/.next/standalone/.next/static/chunks/{0zdn~84f58hvf.js → 0s6nux54y~l~r.js} +1 -1
- package/.next/standalone/.next/static/chunks/{10mlwc4y_kqo2.js → 0tpse0wu2wwo0.js} +1 -1
- package/.next/standalone/.next/static/chunks/12po2vpc-4_c1.css +1 -0
- package/.next/standalone/.next/static/chunks/{0e8c_1f7-8e7t.js → 1400rtd5ywbt..js} +2 -2
- package/.next/standalone/.next/static/chunks/{0mumk7h5i.1xd.js → 14lmf8boay-zu.js} +1 -1
- package/.next/standalone/.next/static/chunks/{18a9xv2p3~x.9.js → 17htukxga7bil.js} +1 -1
- package/.next/standalone/.opencode/opencode.json +4 -0
- package/.next/standalone/.opencode/plugins/failproofai.mjs +131 -0
- package/.next/standalone/.pi/settings.json +5 -0
- package/.next/standalone/app/components/cli-badge.tsx +7 -11
- package/.next/standalone/app/components/project-list.tsx +32 -4
- package/.next/standalone/app/policies/hooks-client.tsx +31 -15
- package/.next/standalone/app/project/[name]/page.tsx +52 -16
- package/.next/standalone/app/project/[name]/session/[sessionId]/page.tsx +92 -15
- package/.next/standalone/assets/logos/copilot-dark.svg +1 -0
- package/.next/standalone/assets/logos/copilot-light.svg +1 -0
- package/.next/standalone/assets/logos/cursor-dark.svg +1 -0
- package/.next/standalone/assets/logos/cursor-light.svg +1 -0
- package/.next/standalone/assets/logos/gemini-dark.svg +13 -0
- package/.next/standalone/assets/logos/gemini-light.svg +13 -0
- package/.next/standalone/assets/logos/opencode-dark.svg +1 -0
- package/.next/standalone/assets/logos/opencode-light.svg +1 -0
- package/.next/standalone/assets/logos/pi-dark.svg +7 -0
- package/.next/standalone/assets/logos/pi-light.svg +7 -0
- package/.next/standalone/lib/cli-registry.ts +107 -0
- package/.next/standalone/lib/codex-projects.ts +3 -3
- package/.next/standalone/lib/copilot-projects.ts +224 -0
- package/.next/standalone/lib/copilot-sessions.ts +395 -0
- package/.next/standalone/lib/cursor-projects.ts +312 -0
- package/.next/standalone/lib/cursor-sessions.ts +467 -0
- package/.next/standalone/lib/gemini-projects.ts +203 -0
- package/.next/standalone/lib/gemini-sessions.ts +365 -0
- package/.next/standalone/lib/opencode-projects.ts +232 -0
- package/.next/standalone/lib/opencode-sessions.ts +237 -0
- package/.next/standalone/lib/pi-projects.ts +230 -0
- package/.next/standalone/lib/pi-sessions.ts +325 -0
- package/.next/standalone/lib/projects.ts +67 -31
- package/.next/standalone/next.config.ts +5 -4
- package/.next/standalone/package.json +2 -1
- package/.next/standalone/pi-extension/index.ts +373 -0
- package/.next/standalone/pi-extension/package.json +12 -0
- package/.next/standalone/server.js +1 -1
- package/README.md +37 -3
- package/bin/failproofai.mjs +61 -21
- package/dist/cli.mjs +2248 -246
- package/lib/cli-registry.ts +107 -0
- package/lib/codex-projects.ts +3 -3
- package/lib/copilot-projects.ts +224 -0
- package/lib/copilot-sessions.ts +395 -0
- package/lib/cursor-projects.ts +312 -0
- package/lib/cursor-sessions.ts +467 -0
- package/lib/gemini-projects.ts +203 -0
- package/lib/gemini-sessions.ts +365 -0
- package/lib/opencode-projects.ts +232 -0
- package/lib/opencode-sessions.ts +237 -0
- package/lib/pi-projects.ts +230 -0
- package/lib/pi-sessions.ts +325 -0
- package/lib/projects.ts +67 -31
- package/package.json +2 -1
- package/pi-extension/index.ts +373 -0
- package/pi-extension/package.json +12 -0
- package/scripts/translate-docs/mdx-translator.ts +56 -2
- package/scripts/translate-docs/translator.ts +1 -1
- package/src/hooks/builtin-policies.ts +84 -14
- package/src/hooks/handler.ts +67 -5
- package/src/hooks/install-prompt.ts +33 -10
- package/src/hooks/integrations.ts +1007 -6
- package/src/hooks/policy-evaluator.ts +299 -3
- package/src/hooks/resolve-permission-mode.ts +23 -0
- package/src/hooks/types.ts +307 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0g72weg._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0su~k6f._.js +0 -3
- package/.next/standalone/.next/server/chunks/lib_codex-projects_ts_07qqk1g._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01743wx._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__092s1ta._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0g.lg8b._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0gs6wz4._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0h..k-e._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0it81ys._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u4a9jq._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__11pa2ra._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__12.h2mg._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__12t-wym._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/_04w00cm._.js +0 -3
- package/.next/standalone/.next/static/chunks/0.rk1iwdt1d7c.css +0 -1
- package/.next/standalone/.next/static/chunks/03egp37o1l629.js +0 -1
- package/.next/standalone/.next/static/chunks/06x4-d1~o-opr.js +0 -1
- package/.next/standalone/.next/static/chunks/0n~s0gafwnp2y.js +0 -1
- /package/.next/standalone/.next/static/{SyaO-J1hupjAiRCG-Syzg → 68TLSFdjAQYIulNHfP0QY}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{SyaO-J1hupjAiRCG-Syzg → 68TLSFdjAQYIulNHfP0QY}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{SyaO-J1hupjAiRCG-Syzg → 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:
|
|
43
|
-
if (hook
|
|
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
|
|
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:
|
|
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 {
|