failproofai 0.0.8 → 0.0.9-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/.next/standalone/.codex/hooks.json +77 -0
  2. package/.next/standalone/.next/BUILD_ID +1 -1
  3. package/.next/standalone/.next/build-manifest.json +6 -6
  4. package/.next/standalone/.next/prerender-manifest.json +3 -3
  5. package/.next/standalone/.next/required-server-files.json +1 -1
  6. package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +3 -3
  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.nft.json +1 -1
  9. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  11. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  12. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  13. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  14. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
  15. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
  16. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  17. package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +3 -3
  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.nft.json +1 -1
  20. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  21. package/.next/standalone/.next/server/app/_not-found.html +2 -2
  22. package/.next/standalone/.next/server/app/_not-found.rsc +17 -17
  23. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +17 -17
  24. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  25. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +11 -11
  26. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  27. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  28. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  29. package/.next/standalone/.next/server/app/index.html +1 -1
  30. package/.next/standalone/.next/server/app/index.rsc +16 -16
  31. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  32. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +16 -16
  33. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  34. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +11 -11
  35. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  36. package/.next/standalone/.next/server/app/page/build-manifest.json +3 -3
  37. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  38. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  39. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  40. package/.next/standalone/.next/server/app/policies/page/build-manifest.json +3 -3
  41. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +8 -8
  42. package/.next/standalone/.next/server/app/policies/page.js +1 -1
  43. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  44. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  45. package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +3 -3
  46. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  47. package/.next/standalone/.next/server/app/project/[name]/page.js +1 -1
  48. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  49. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  50. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +3 -3
  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 +1 -1
  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/build-manifest.json +3 -3
  57. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  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]__0g72weg._.js +1 -1
  61. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_05pz9._._.js +1 -1
  62. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_0z7w.hh._.js +1 -1
  63. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0h3orxc._.js → [root-of-the-server]__0.f_cyx._.js} +2 -2
  64. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0m72uj7._.js → [root-of-the-server]__03rd.z8._.js} +2 -2
  65. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__092s1ta._.js +2 -2
  66. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__09icjsf._.js +2 -2
  67. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0dub28-._.js +3 -0
  68. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e74wa-._.js +3 -0
  69. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0g.lg8b._.js +2 -2
  70. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0h..k-e._.js +2 -2
  71. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0okos0k._.js → [root-of-the-server]__0vu.o-3._.js} +3 -3
  72. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0w6l33k._.js +7 -7
  73. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0of~riu._.js → [root-of-the-server]__0zqcovi._.js} +2 -2
  74. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__11pa2ra._.js +2 -2
  75. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__12t-wym._.js +2 -2
  76. package/.next/standalone/.next/server/chunks/ssr/_07a1g.3._.js +3 -0
  77. package/.next/standalone/.next/server/chunks/ssr/_0uy6m~m._.js +3 -0
  78. package/.next/standalone/.next/server/chunks/ssr/_0zaq1hm._.js +3 -0
  79. package/.next/standalone/.next/server/chunks/ssr/_10lm7or._.js +2 -2
  80. package/.next/standalone/.next/server/chunks/ssr/_11rg2a_._.js +3 -0
  81. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_0xerkr6._.js +1 -1
  82. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_0q-m0y-._.js +2 -2
  83. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_0h9llsw._.js +1 -1
  84. package/.next/standalone/.next/server/chunks/ssr/node_modules_posthog-node_dist_entrypoints_index_node_mjs_0mebn66._.js +1 -1
  85. package/.next/standalone/.next/server/middleware-build-manifest.js +6 -6
  86. package/.next/standalone/.next/server/pages/404.html +2 -2
  87. package/.next/standalone/.next/server/pages/500.html +1 -1
  88. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  89. package/.next/standalone/.next/server/server-reference-manifest.json +9 -9
  90. package/.next/standalone/.next/static/chunks/0.rk1iwdt1d7c.css +1 -0
  91. package/.next/standalone/.next/static/chunks/00b5h4r1el.6f.js +1 -0
  92. package/.next/standalone/.next/static/chunks/{01q52wg_amm60.js → 0_c_yox08g_44.js} +2 -2
  93. package/.next/standalone/.next/static/chunks/0bghqwo4iloy0.js +1 -0
  94. package/.next/standalone/.next/static/chunks/0fw2h.g66c0h3.js +1 -0
  95. package/.next/standalone/.next/static/chunks/0gu87mlr5ssnt.js +6 -0
  96. package/.next/standalone/.next/static/chunks/{0mbc8hyeqe2c4.js → 0igf3xbisp1lx.js} +1 -1
  97. package/.next/standalone/.next/static/chunks/{175-vim0.ztb2.js → 0jryicwtm9z2g.js} +2 -2
  98. package/.next/standalone/.next/static/chunks/{12simlrcfk3g2.js → 0kzk5-mh1_x53.js} +1 -1
  99. package/.next/standalone/.next/static/chunks/0p5zh2diw90a1.js +1 -0
  100. package/.next/standalone/.next/static/chunks/{0eowehbf5egcz.js → 0ufq8smh~i7wc.js} +1 -1
  101. package/.next/standalone/.next/static/chunks/{0vlk_pv4somht.js → 0vwqucikost_q.js} +1 -1
  102. package/.next/standalone/.next/static/chunks/{0t3euwspxi_zg.js → 0w1f.k~gi-y6..js} +1 -1
  103. package/.next/standalone/.next/static/chunks/{151bdxm9n-pry.js → 0z-jh701rc~j8.js} +1 -1
  104. package/.next/standalone/.next/static/chunks/{turbopack-0o7k.hakttp4k.js → turbopack-0s36is87fc9r2.js} +1 -1
  105. package/.next/standalone/app/actions/install-hooks-web.ts +21 -5
  106. package/.next/standalone/app/policies/hooks-client.tsx +23 -0
  107. package/.next/standalone/assets/logos/claude.svg +1 -0
  108. package/.next/standalone/assets/logos/openai-dark.svg +1 -0
  109. package/.next/standalone/assets/logos/openai-light.svg +1 -0
  110. package/.next/standalone/package.json +2 -2
  111. package/.next/standalone/server.js +1 -1
  112. package/README.md +22 -3
  113. package/bin/failproofai.mjs +89 -9
  114. package/dist/cli.mjs +1123 -281
  115. package/package.json +2 -2
  116. package/src/hooks/builtin-policies.ts +29 -6
  117. package/src/hooks/handler.ts +39 -10
  118. package/src/hooks/hook-activity-store.ts +2 -0
  119. package/src/hooks/install-prompt.ts +165 -0
  120. package/src/hooks/integrations.ts +373 -0
  121. package/src/hooks/manager.ts +96 -171
  122. package/src/hooks/policy-evaluator.ts +28 -1
  123. package/src/hooks/policy-types.ts +3 -1
  124. package/src/hooks/resolve-permission-mode.ts +147 -0
  125. package/src/hooks/types.ts +30 -1
  126. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0_rr1ty._.js +0 -3
  127. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0dj-tbi._.js +0 -3
  128. package/.next/standalone/.next/server/chunks/ssr/_0h21oar._.js +0 -3
  129. package/.next/standalone/.next/server/chunks/ssr/_0i~.gk_._.js +0 -3
  130. package/.next/standalone/.next/server/chunks/ssr/_0q3h.2s._.js +0 -3
  131. package/.next/standalone/.next/server/chunks/ssr/_0x..fj-._.js +0 -3
  132. package/.next/standalone/.next/static/chunks/096~b1zwv69ph.js +0 -1
  133. package/.next/standalone/.next/static/chunks/0bkir2pd22ski.js +0 -1
  134. package/.next/standalone/.next/static/chunks/0ksdlt_1hucdm.js +0 -1
  135. package/.next/standalone/.next/static/chunks/0lua3p__elu_..js +0 -6
  136. package/.next/standalone/.next/static/chunks/0mir9jdxn35~s.css +0 -1
  137. package/.next/standalone/.next/static/chunks/0s_18.dox44e9.js +0 -1
  138. /package/.next/standalone/.next/static/{RYld7TSCDXm2_WhJq20rD → CiVeb_yiVt-O2JYrzGzB7}/_buildManifest.js +0 -0
  139. /package/.next/standalone/.next/static/{RYld7TSCDXm2_WhJq20rD → CiVeb_yiVt-O2JYrzGzB7}/_clientMiddlewareManifest.js +0 -0
  140. /package/.next/standalone/.next/static/{RYld7TSCDXm2_WhJq20rD → CiVeb_yiVt-O2JYrzGzB7}/_ssgManifest.js +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "failproofai",
3
- "version": "0.0.8",
3
+ "version": "0.0.9-beta.1",
4
4
  "description": "The easiest way to manage policies that keep your AI agents reliable, on-task, and running autonomously — for Claude Code & the Agents SDK",
5
5
  "bin": {
6
6
  "failproofai": "./dist/cli.mjs"
@@ -90,7 +90,7 @@
90
90
  "tailwind-merge": "^3.4.0",
91
91
  "tailwindcss": "^4.1.18",
92
92
  "typescript": "^6.0.2",
93
- "@anthropic-ai/sdk": "^0.90.0",
93
+ "@anthropic-ai/sdk": "^0.91.1",
94
94
  "vitest": "^4.0.18"
95
95
  },
96
96
  "dependencies": {
@@ -10,15 +10,36 @@ import { allow, deny, instruct } from "./policy-helpers";
10
10
  import { normalizePolicyName, registerPolicy } from "./policy-registry";
11
11
  import { hookLogWarn } from "./hook-logger";
12
12
 
13
- function isClaudeInternalPath(resolved: string): boolean {
14
- const claudeDir = join(homedir(), ".claude");
15
- return resolved === claudeDir || resolved.startsWith(claudeDir + "/");
13
+ /**
14
+ * Whether `resolved` lives under an agent CLI's home directory
15
+ * (~/.claude/ or ~/.codex/). Used to whitelist agent self-reads of their own
16
+ * config and transcripts.
17
+ */
18
+ function isAgentInternalPath(resolved: string): boolean {
19
+ for (const dir of [".claude", ".codex"]) {
20
+ const root = join(homedir(), dir);
21
+ if (resolved === root || resolved.startsWith(root + "/")) return true;
22
+ }
23
+ return false;
16
24
  }
17
25
 
18
- function isClaudeSettingsFile(resolved: string): boolean {
19
- return /[\\/]\.claude[\\/]settings(?:\.[^/\\]+)?\.json$/.test(resolved);
26
+ /**
27
+ * Whether `resolved` is a settings/hooks file for an agent CLI:
28
+ * • Claude Code: `.claude/settings.json`, `.claude/settings.local.json`, etc.
29
+ * • Codex: `.codex/hooks.json`
30
+ * These must NEVER be edited by the agent itself — that would let it disable
31
+ * its own protections.
32
+ */
33
+ function isAgentSettingsFile(resolved: string): boolean {
34
+ if (/[\\/]\.claude[\\/]settings(?:\.[^/\\]+)?\.json$/.test(resolved)) return true;
35
+ if (/[\\/]\.codex[\\/]hooks\.json$/.test(resolved)) return true;
36
+ return false;
20
37
  }
21
38
 
39
+ // Back-compat aliases — kept for any caller that imports the old names.
40
+ const isClaudeInternalPath = isAgentInternalPath;
41
+ const isClaudeSettingsFile = isAgentSettingsFile;
42
+
22
43
  function getCommand(ctx: PolicyContext): string {
23
44
  return (ctx.toolInput?.command as string) ?? "";
24
45
  }
@@ -1483,7 +1504,9 @@ export const BUILTIN_POLICIES: BuiltinPolicyDefinition[] = [
1483
1504
  name: "block-sudo",
1484
1505
  description: "Block sudo commands",
1485
1506
  fn: blockSudo,
1486
- match: { events: ["PreToolUse"], toolNames: ["Bash"] },
1507
+ // PermissionRequest is Codex's escalation-approval event; fire the same
1508
+ // sudo guard there so Codex sandbox bypasses are blocked too.
1509
+ match: { events: ["PreToolUse", "PermissionRequest"], toolNames: ["Bash"] },
1487
1510
  defaultEnabled: true,
1488
1511
  category: "Dangerous Commands",
1489
1512
  params: {
@@ -5,7 +5,8 @@
5
5
  * ~/.failproofai/policies-config.json, evaluates matching policies, persists
6
6
  * activity to disk, and returns the appropriate exit code + stdout response.
7
7
  */
8
- import type { HookEventType, SessionMetadata } from "./types";
8
+ import type { HookEventType, IntegrationType, SessionMetadata, CodexHookEventType } from "./types";
9
+ import { CODEX_EVENT_MAP } from "./types";
9
10
  import type { PolicyFunction, PolicyResult } from "./policy-types";
10
11
  import { readMergedHooksConfig } from "./hooks-config";
11
12
  import { registerBuiltinPolicies } from "./builtin-policies";
@@ -15,10 +16,28 @@ import { loadAllCustomHooks } from "./custom-hooks-loader";
15
16
  import type { CustomHook } from "./policy-types";
16
17
  import { persistHookActivity } from "./hook-activity-store";
17
18
  import { trackHookEvent } from "./hook-telemetry";
19
+ import { resolvePermissionMode } from "./resolve-permission-mode";
18
20
  import { getInstanceId } from "../../lib/telemetry-id";
19
21
  import { hookLogInfo, hookLogWarn } from "./hook-logger";
20
22
 
21
- export async function handleHookEvent(eventType: string): Promise<number> {
23
+ /**
24
+ * Canonicalize an event name to PascalCase. Codex sends snake_case event names
25
+ * on stdin and as the --hook arg; Claude Code sends PascalCase. The internal
26
+ * registry, builtin policies, and policy.match.events all key on PascalCase.
27
+ */
28
+ function canonicalizeEventType(raw: string, cli: IntegrationType): HookEventType {
29
+ if (cli === "codex") {
30
+ const mapped = CODEX_EVENT_MAP[raw as CodexHookEventType];
31
+ if (mapped) return mapped;
32
+ }
33
+ // Already PascalCase or unknown — pass through; HOOK_EVENT_TYPES type-checks downstream.
34
+ return raw as HookEventType;
35
+ }
36
+
37
+ export async function handleHookEvent(
38
+ eventType: string,
39
+ cli: IntegrationType = "claude",
40
+ ): Promise<number> {
22
41
  const startTime = performance.now();
23
42
 
24
43
  // Read stdin payload (Claude passes JSON)
@@ -57,13 +76,18 @@ export async function handleHookEvent(eventType: string): Promise<number> {
57
76
  }
58
77
  }
59
78
 
79
+ // Canonicalize event name (Codex sends snake_case; internals expect PascalCase)
80
+ const canonicalEventType = canonicalizeEventType(eventType, cli);
81
+
60
82
  // Extract session metadata from payload
83
+ const sessionId = parsed.session_id as string | undefined;
61
84
  const session: SessionMetadata = {
62
- sessionId: parsed.session_id as string | undefined,
85
+ sessionId,
63
86
  transcriptPath: parsed.transcript_path as string | undefined,
64
87
  cwd: parsed.cwd as string | undefined,
65
- permissionMode: parsed.permission_mode as string | undefined,
88
+ permissionMode: resolvePermissionMode(cli, parsed, sessionId),
66
89
  hookEventName: parsed.hook_event_name as string | undefined,
90
+ cli,
67
91
  };
68
92
 
69
93
  // Load enabled policies (merge across project/local/global scopes)
@@ -98,6 +122,7 @@ export async function handleHookEvent(eventType: string): Promise<number> {
98
122
  hook_name: hookName,
99
123
  error_type: isTimeout ? "timeout" : "exception",
100
124
  event_type: eventType,
125
+ cli,
101
126
  is_convention_policy: isConvention,
102
127
  convention_scope: conventionScope ?? null,
103
128
  });
@@ -116,6 +141,7 @@ export async function handleHookEvent(eventType: string): Promise<number> {
116
141
  // Fire telemetry once per invocation for custom hook loads
117
142
  if (customHooksList.length > 0) {
118
143
  void trackHookEvent(getInstanceId(), "custom_hooks_loaded", {
144
+ cli,
119
145
  custom_hooks_count: customHooksList.length,
120
146
  custom_hook_names: customHooksList.map((h) => h.name),
121
147
  event_types_covered: [...new Set(customHooksList.flatMap((h) => h.match?.events ?? []))],
@@ -125,7 +151,8 @@ export async function handleHookEvent(eventType: string): Promise<number> {
125
151
  // Fire telemetry for convention-based policy discovery
126
152
  if (loadResult.conventionSources.length > 0) {
127
153
  void trackHookEvent(getInstanceId(), "convention_policies_loaded", {
128
- event_type: eventType,
154
+ event_type: canonicalEventType,
155
+ cli,
129
156
  project_file_count: loadResult.conventionSources.filter((s) => s.scope === "project").length,
130
157
  user_file_count: loadResult.conventionSources.filter((s) => s.scope === "user").length,
131
158
  convention_hook_count: conventionHookNames.size,
@@ -133,10 +160,10 @@ export async function handleHookEvent(eventType: string): Promise<number> {
133
160
  });
134
161
  }
135
162
 
136
- hookLogInfo(`event=${eventType} policies=${config.enabledPolicies.length} custom=${customHooksList.length} convention=${conventionHookNames.size}`);
163
+ hookLogInfo(`event=${canonicalEventType} cli=${cli} policies=${config.enabledPolicies.length} custom=${customHooksList.length} convention=${conventionHookNames.size}`);
137
164
 
138
- // Evaluate policies
139
- const result = await evaluatePolicies(eventType as HookEventType, parsed, session, config);
165
+ // Evaluate policies (use canonical PascalCase event type)
166
+ const result = await evaluatePolicies(canonicalEventType, parsed, session, config);
140
167
  const durationMs = Math.round(performance.now() - startTime);
141
168
  hookLogInfo(`result=${result.decision} policy=${result.policyName ?? "none"} duration=${durationMs}ms`);
142
169
 
@@ -150,7 +177,8 @@ export async function handleHookEvent(eventType: string): Promise<number> {
150
177
  // Persist activity to disk (visible in /policies activity tab)
151
178
  const activityEntry = {
152
179
  timestamp: Date.now(),
153
- eventType,
180
+ eventType: canonicalEventType,
181
+ integration: cli,
154
182
  toolName: (parsed.tool_name as string) ?? null,
155
183
  policyName: result.policyName,
156
184
  policyNames: result.policyNames,
@@ -203,7 +231,8 @@ export async function handleHookEvent(eventType: string): Promise<number> {
203
231
  : [];
204
232
  const distinctId = getInstanceId();
205
233
  await trackHookEvent(distinctId, "hook_policy_triggered", {
206
- event_type: eventType,
234
+ event_type: canonicalEventType,
235
+ cli,
207
236
  tool_name: (parsed.tool_name as string) ?? null,
208
237
  policy_name: result.policyName,
209
238
  decision: result.decision,
@@ -41,6 +41,8 @@ let rotateSeq = 0;
41
41
  export interface HookActivityEntry {
42
42
  timestamp: number;
43
43
  eventType: string;
44
+ /** Which agent CLI fired the hook (claude | codex). */
45
+ integration?: string;
44
46
  toolName: string | null;
45
47
  policyName: string | null;
46
48
  policyNames?: string[];
@@ -11,6 +11,8 @@
11
11
  */
12
12
  import * as readline from "node:readline";
13
13
  import { BUILTIN_POLICIES } from "./builtin-policies";
14
+ import { detectInstalledClis, getIntegration } from "./integrations";
15
+ import type { IntegrationType } from "./types";
14
16
 
15
17
  interface SelectItem {
16
18
  name: string;
@@ -28,6 +30,169 @@ export interface PromptOptions {
28
30
  includeBeta?: boolean;
29
31
  }
30
32
 
33
+ /**
34
+ * Resolve which agent CLIs to install hooks for.
35
+ *
36
+ * Rules:
37
+ * • If `explicit` is provided (from `--cli`), use it as-is.
38
+ * • Else, detect installed CLIs (PATH probe).
39
+ * • If exactly one detected → use just that one (no prompt).
40
+ * • If multiple detected and stdin is a TTY → arrow-key single-select.
41
+ * • Otherwise → default to all detected (or ["claude"] when none).
42
+ *
43
+ * Returns the selected IntegrationType[] (always non-empty).
44
+ */
45
+ export async function resolveTargetClis(explicit?: IntegrationType[]): Promise<IntegrationType[]> {
46
+ if (explicit && explicit.length > 0) return [...new Set(explicit)];
47
+
48
+ const detected = detectInstalledClis();
49
+
50
+ if (detected.length === 0) {
51
+ console.log(
52
+ "\x1B[33mWarning: no agent CLI binary found in PATH (claude, codex). " +
53
+ "Defaulting to Claude Code; hooks will activate when an agent is installed.\x1B[0m",
54
+ );
55
+ return ["claude"];
56
+ }
57
+
58
+ if (detected.length === 1) {
59
+ const integration = getIntegration(detected[0]);
60
+ console.log(`Detected ${integration.displayName}; installing hooks for it.`);
61
+ return detected;
62
+ }
63
+
64
+ // Multiple detected. Prompt or default.
65
+ if (!process.stdin.isTTY) return detected; // non-interactive: install for all detected
66
+
67
+ return promptCliTargetSelection(detected);
68
+ }
69
+
70
+ /**
71
+ * Interactive arrow-key single-select for "install for which CLI?" when
72
+ * multiple agent CLIs are detected. Visual style mirrors promptPolicySelection.
73
+ */
74
+ async function promptCliTargetSelection(
75
+ detected: IntegrationType[],
76
+ ): Promise<IntegrationType[]> {
77
+ const labels = detected.map((id) => getIntegration(id).displayName).join(" + ");
78
+ const options: Array<{ label: string; description: string; value: IntegrationType[] }> = [
79
+ { label: "Both", description: labels, value: detected },
80
+ ...detected.map((id) => ({
81
+ label: `${getIntegration(id).displayName} only`,
82
+ description: "",
83
+ value: [id] as IntegrationType[],
84
+ })),
85
+ ];
86
+
87
+ let cursor = 0;
88
+ let lastLineCount = 0;
89
+ let cursorHidden = false;
90
+
91
+ function hideCursor(): void {
92
+ if (!cursorHidden) {
93
+ process.stdout.write("\x1B[?25l");
94
+ cursorHidden = true;
95
+ }
96
+ }
97
+ function showCursor(): void {
98
+ if (cursorHidden) {
99
+ process.stdout.write("\x1B[?25h");
100
+ cursorHidden = false;
101
+ }
102
+ }
103
+
104
+ function truncateLine(line: string, width: number): string {
105
+ let visual = 0;
106
+ let result = "";
107
+ let i = 0;
108
+ while (i < line.length) {
109
+ if (line[i] === "\x1B" && line[i + 1] === "[") {
110
+ let j = i + 2;
111
+ while (j < line.length && !/[A-Za-z]/.test(line[j])) j++;
112
+ j++;
113
+ result += line.slice(i, j);
114
+ i = j;
115
+ } else {
116
+ if (visual >= width) break;
117
+ result += line[i];
118
+ visual++;
119
+ i++;
120
+ }
121
+ }
122
+ return result;
123
+ }
124
+
125
+ function render(): void {
126
+ const cols = process.stdout.columns || 120;
127
+ hideCursor();
128
+
129
+ const lines: string[] = [];
130
+ lines.push(" Failproof AI — Install Hooks");
131
+ lines.push("");
132
+ lines.push(` \x1B[2mDetected ${labels}. Choose where to install:\x1B[0m`);
133
+ lines.push("");
134
+
135
+ for (let i = 0; i < options.length; i++) {
136
+ const opt = options[i];
137
+ const isActive = i === cursor;
138
+ const pointer = isActive ? "\x1B[36m❯\x1B[0m" : " ";
139
+ const labelPart = isActive ? `\x1B[1;36m${opt.label}\x1B[0m` : opt.label;
140
+ const pad = opt.description ? " ".repeat(Math.max(2, 22 - opt.label.length)) : "";
141
+ const desc = opt.description ? `\x1B[2m${opt.description}\x1B[0m` : "";
142
+ lines.push(` ${pointer} ${labelPart}${pad}${desc}`);
143
+ }
144
+
145
+ lines.push("");
146
+ lines.push(" \x1B[2m" + "─".repeat(Math.max(2, cols - 2)) + "\x1B[0m");
147
+ lines.push(" [↑↓] Move [Enter] Select [^C] Quit");
148
+
149
+ if (lastLineCount > 0) {
150
+ process.stdout.write(`\x1B[${lastLineCount}A\x1B[J`);
151
+ }
152
+ const output =
153
+ lines.map((l) => (l === "" ? l : truncateLine(l, cols))).join("\n") + "\n";
154
+ process.stdout.write(output);
155
+ lastLineCount = lines.length;
156
+ }
157
+
158
+ return new Promise<IntegrationType[]>((resolve) => {
159
+ render();
160
+ readline.emitKeypressEvents(process.stdin);
161
+ const wasRaw = process.stdin.isRaw;
162
+ if (process.stdin.setRawMode) process.stdin.setRawMode(true);
163
+ process.stdin.resume();
164
+
165
+ function cleanup(): void {
166
+ showCursor();
167
+ process.stdin.removeListener("keypress", onKey);
168
+ if (process.stdin.setRawMode) process.stdin.setRawMode(wasRaw ?? false);
169
+ process.stdin.pause();
170
+ }
171
+
172
+ function onKey(_str: string | undefined, key: readline.Key): void {
173
+ if (!key) return;
174
+ if (key.ctrl && (key.name === "c" || key.name === "d")) {
175
+ cleanup();
176
+ process.stdout.write("\n");
177
+ process.exit(130); // SIGINT-equivalent
178
+ }
179
+ if (key.name === "up") {
180
+ cursor = cursor > 0 ? cursor - 1 : options.length - 1;
181
+ render();
182
+ } else if (key.name === "down") {
183
+ cursor = cursor < options.length - 1 ? cursor + 1 : 0;
184
+ render();
185
+ } else if (key.name === "return" || key.name === "space") {
186
+ cleanup();
187
+ process.stdout.write("\n");
188
+ resolve(options[cursor].value);
189
+ }
190
+ }
191
+
192
+ process.stdin.on("keypress", onKey);
193
+ });
194
+ }
195
+
31
196
  /**
32
197
  * Show interactive searchable policy selector.
33
198
  * @param preSelected — policy names to pre-check (e.g. from existing config).