failproofai 0.0.8 → 0.0.9-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/.codex/hooks.json +77 -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.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.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/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.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 +1 -1
- 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 +1 -1
- 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.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]__0g72weg._.js +1 -1
- package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_05pz9._._.js +1 -1
- 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]__0m72uj7._.js → [root-of-the-server]__03rd.z8._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__092s1ta._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__09icjsf._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0h3orxc._.js → [root-of-the-server]__0ca1zru._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e74wa-._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ea22pr._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0g.lg8b._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0h..k-e._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0okos0k._.js → [root-of-the-server]__0vu.o-3._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0w6l33k._.js +7 -7
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0of~riu._.js → [root-of-the-server]__0zqcovi._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__11pa2ra._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__12t-wym._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_07a1g.3._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_0uy6m~m._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_0zaq1hm._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_10lm7or._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_11rg2a_._.js +3 -0
- 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/node_modules_next_dist_0h9llsw._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/node_modules_posthog-node_dist_entrypoints_index_node_mjs_0mebn66._.js +1 -1
- 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/0.rk1iwdt1d7c.css +1 -0
- package/.next/standalone/.next/static/chunks/00b5h4r1el.6f.js +1 -0
- package/.next/standalone/.next/static/chunks/{151bdxm9n-pry.js → 03lsndql_yml5.js} +1 -1
- package/.next/standalone/.next/static/chunks/0amfi~vb_gfgo.js +1 -0
- package/.next/standalone/.next/static/chunks/0fw2h.g66c0h3.js +1 -0
- package/.next/standalone/.next/static/chunks/{0mbc8hyeqe2c4.js → 0jce49ygr4fdv.js} +1 -1
- package/.next/standalone/.next/static/chunks/0mungg3~jpwe7.js +1 -0
- package/.next/standalone/.next/static/chunks/{175-vim0.ztb2.js → 0uq_5p-p7myfe.js} +2 -2
- package/.next/standalone/.next/static/chunks/0v.xuf4ynzp~~.js +6 -0
- package/.next/standalone/.next/static/chunks/{0eowehbf5egcz.js → 0vb8xxj_v2tz8.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0vlk_pv4somht.js → 0vwqucikost_q.js} +1 -1
- package/.next/standalone/.next/static/chunks/0~mroziiwl1m5.js +1 -0
- package/.next/standalone/app/actions/install-hooks-web.ts +21 -5
- package/.next/standalone/app/policies/hooks-client.tsx +23 -0
- package/.next/standalone/assets/logos/claude.svg +1 -0
- package/.next/standalone/assets/logos/openai-dark.svg +1 -0
- package/.next/standalone/assets/logos/openai-light.svg +1 -0
- package/.next/standalone/package.json +2 -2
- package/.next/standalone/server.js +1 -1
- package/README.md +22 -3
- package/bin/failproofai.mjs +89 -9
- package/dist/cli.mjs +1039 -281
- package/package.json +2 -2
- package/src/hooks/builtin-policies.ts +29 -6
- package/src/hooks/handler.ts +39 -10
- package/src/hooks/hook-activity-store.ts +2 -0
- package/src/hooks/install-prompt.ts +69 -0
- package/src/hooks/integrations.ts +373 -0
- package/src/hooks/manager.ts +96 -171
- package/src/hooks/policy-evaluator.ts +28 -1
- package/src/hooks/policy-types.ts +3 -1
- package/src/hooks/resolve-permission-mode.ts +147 -0
- package/src/hooks/types.ts +30 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0_rr1ty._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0dj-tbi._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_0h21oar._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_0i~.gk_._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_0q3h.2s._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_0x..fj-._.js +0 -3
- package/.next/standalone/.next/static/chunks/096~b1zwv69ph.js +0 -1
- package/.next/standalone/.next/static/chunks/0bkir2pd22ski.js +0 -1
- package/.next/standalone/.next/static/chunks/0ksdlt_1hucdm.js +0 -1
- package/.next/standalone/.next/static/chunks/0lua3p__elu_..js +0 -6
- package/.next/standalone/.next/static/chunks/0mir9jdxn35~s.css +0 -1
- package/.next/standalone/.next/static/chunks/0s_18.dox44e9.js +0 -1
- package/.next/standalone/.next/static/chunks/0t3euwspxi_zg.js +0 -1
- /package/.next/standalone/.next/static/{RYld7TSCDXm2_WhJq20rD → oUO8u4z9JvtTzS_2RJoGo}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{RYld7TSCDXm2_WhJq20rD → oUO8u4z9JvtTzS_2RJoGo}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{RYld7TSCDXm2_WhJq20rD → oUO8u4z9JvtTzS_2RJoGo}/_ssgManifest.js +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "failproofai",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.9-beta.0",
|
|
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.
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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: {
|
package/src/hooks/handler.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
85
|
+
sessionId,
|
|
63
86
|
transcriptPath: parsed.transcript_path as string | undefined,
|
|
64
87
|
cwd: parsed.cwd as string | undefined,
|
|
65
|
-
permissionMode: parsed
|
|
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:
|
|
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=${
|
|
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(
|
|
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:
|
|
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,73 @@ 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 both detected and stdin is a TTY → ask single-keypress B/C/D.
|
|
41
|
+
* • Otherwise → default to ["claude"] for back-compat.
|
|
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
|
+
// Both detected. Prompt or default.
|
|
65
|
+
if (!process.stdin.isTTY) return detected; // non-interactive: install for both
|
|
66
|
+
|
|
67
|
+
const labels = detected.map((id) => getIntegration(id).displayName).join(" + ");
|
|
68
|
+
process.stdout.write(
|
|
69
|
+
`Detected ${labels}. Install for [B]oth (default), [C]laude Code only, or co[D]ex only? `,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
return new Promise<IntegrationType[]>((resolve) => {
|
|
73
|
+
readline.emitKeypressEvents(process.stdin);
|
|
74
|
+
const wasRaw = process.stdin.isRaw;
|
|
75
|
+
if (process.stdin.setRawMode) process.stdin.setRawMode(true);
|
|
76
|
+
const restore = () => {
|
|
77
|
+
if (process.stdin.setRawMode) process.stdin.setRawMode(wasRaw ?? false);
|
|
78
|
+
process.stdin.removeListener("keypress", onKey);
|
|
79
|
+
};
|
|
80
|
+
const onKey = (str: string, key: { ctrl?: boolean; name?: string } | undefined) => {
|
|
81
|
+
// Honor Ctrl+C / Ctrl+D as abort — restore terminal and exit, never
|
|
82
|
+
// silently install for both. Mirrors the keypress contract used by
|
|
83
|
+
// promptPolicySelection().
|
|
84
|
+
if (key && key.ctrl && (key.name === "c" || key.name === "d")) {
|
|
85
|
+
restore();
|
|
86
|
+
process.stdout.write("\n");
|
|
87
|
+
process.exit(130); // SIGINT-equivalent
|
|
88
|
+
}
|
|
89
|
+
const ch = (str || "").toLowerCase();
|
|
90
|
+
restore();
|
|
91
|
+
process.stdout.write("\n");
|
|
92
|
+
if (ch === "c") resolve(["claude"]);
|
|
93
|
+
else if (ch === "d") resolve(["codex"]);
|
|
94
|
+
else resolve(detected); // Enter, B, anything else → both
|
|
95
|
+
};
|
|
96
|
+
process.stdin.on("keypress", onKey);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
31
100
|
/**
|
|
32
101
|
* Show interactive searchable policy selector.
|
|
33
102
|
* @param preSelected — policy names to pre-check (e.g. from existing config).
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-CLI hook integration registry.
|
|
3
|
+
*
|
|
4
|
+
* An `Integration` describes how failproofai hooks are installed, detected, and
|
|
5
|
+
* read for a specific agent CLI (Claude Code, OpenAI Codex). The runtime hot
|
|
6
|
+
* path (`handler.ts`, `policy-evaluator.ts`, `BUILTIN_POLICIES`, `policy-helpers`)
|
|
7
|
+
* is agent-agnostic — only install/uninstall plumbing varies.
|
|
8
|
+
*/
|
|
9
|
+
import { execSync } from "node:child_process";
|
|
10
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
11
|
+
import { resolve, dirname } from "node:path";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import {
|
|
14
|
+
HOOK_EVENT_TYPES,
|
|
15
|
+
HOOK_SCOPES,
|
|
16
|
+
CODEX_HOOK_EVENT_TYPES,
|
|
17
|
+
CODEX_HOOK_SCOPES,
|
|
18
|
+
CODEX_EVENT_MAP,
|
|
19
|
+
FAILPROOFAI_HOOK_MARKER,
|
|
20
|
+
INTEGRATION_TYPES,
|
|
21
|
+
type IntegrationType,
|
|
22
|
+
type HookScope,
|
|
23
|
+
type ClaudeSettings,
|
|
24
|
+
type ClaudeHookMatcher,
|
|
25
|
+
type ClaudeHookEntry,
|
|
26
|
+
type CodexHookEventType,
|
|
27
|
+
} from "./types";
|
|
28
|
+
|
|
29
|
+
// ── Generic helpers ─────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
function readJsonFile(path: string): Record<string, unknown> {
|
|
32
|
+
if (!existsSync(path)) return {};
|
|
33
|
+
const raw = readFileSync(path, "utf8");
|
|
34
|
+
return JSON.parse(raw) as Record<string, unknown>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function writeJsonFile(path: string, data: Record<string, unknown>): void {
|
|
38
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
39
|
+
writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf8");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isMarkedHook(hook: Record<string, unknown>): boolean {
|
|
43
|
+
if (hook[FAILPROOFAI_HOOK_MARKER] === true) return true;
|
|
44
|
+
// Fallback for legacy installs predating the marker
|
|
45
|
+
const cmd = typeof hook.command === "string" ? hook.command : "";
|
|
46
|
+
return cmd.includes("failproofai") && cmd.includes("--hook");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function binaryExists(name: string): boolean {
|
|
50
|
+
try {
|
|
51
|
+
const cmd = process.platform === "win32" ? `where ${name}` : `which ${name}`;
|
|
52
|
+
execSync(cmd, { encoding: "utf8", stdio: "pipe" });
|
|
53
|
+
return true;
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Integration interface ───────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
export interface Integration {
|
|
62
|
+
id: IntegrationType;
|
|
63
|
+
displayName: string;
|
|
64
|
+
/** Settings scopes this integration supports (e.g. claude: user/project/local; codex: user/project). */
|
|
65
|
+
scopes: readonly HookScope[];
|
|
66
|
+
/** Hook events this integration fires (Claude: PascalCase, Codex: snake_case stored as Pascal in settings). */
|
|
67
|
+
eventTypes: readonly string[];
|
|
68
|
+
|
|
69
|
+
/** Resolve the per-scope settings/hooks file path. */
|
|
70
|
+
getSettingsPath(scope: HookScope, cwd?: string): string;
|
|
71
|
+
|
|
72
|
+
/** Read the raw settings/hooks file (returns {} when missing). */
|
|
73
|
+
readSettings(settingsPath: string): Record<string, unknown>;
|
|
74
|
+
|
|
75
|
+
/** Write the settings/hooks file. */
|
|
76
|
+
writeSettings(settingsPath: string, settings: Record<string, unknown>): void;
|
|
77
|
+
|
|
78
|
+
/** Build a single hook entry for a given event. */
|
|
79
|
+
buildHookEntry(binaryPath: string, eventType: string, scope?: HookScope): Record<string, unknown>;
|
|
80
|
+
|
|
81
|
+
/** Whether a hook entry is owned by failproofai. */
|
|
82
|
+
isFailproofaiHook(hook: Record<string, unknown>): boolean;
|
|
83
|
+
|
|
84
|
+
/** Mutate `settings` in place, registering failproofai across all event types. Idempotent. */
|
|
85
|
+
writeHookEntries(settings: Record<string, unknown>, binaryPath: string, scope?: HookScope): void;
|
|
86
|
+
|
|
87
|
+
/** Remove all failproofai hook entries from a settings file. Returns the number removed. */
|
|
88
|
+
removeHooksFromFile(settingsPath: string): number;
|
|
89
|
+
|
|
90
|
+
/** Whether failproofai hooks are present in a given scope. */
|
|
91
|
+
hooksInstalledInSettings(scope: HookScope, cwd?: string): boolean;
|
|
92
|
+
|
|
93
|
+
/** Whether the agent CLI binary is installed (probes PATH). */
|
|
94
|
+
detectInstalled(): boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Claude Code integration ─────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
export const claudeCode: Integration = {
|
|
100
|
+
id: "claude",
|
|
101
|
+
displayName: "Claude Code",
|
|
102
|
+
scopes: HOOK_SCOPES,
|
|
103
|
+
eventTypes: HOOK_EVENT_TYPES,
|
|
104
|
+
|
|
105
|
+
getSettingsPath(scope, cwd) {
|
|
106
|
+
const base = cwd ? resolve(cwd) : process.cwd();
|
|
107
|
+
switch (scope) {
|
|
108
|
+
case "user":
|
|
109
|
+
return resolve(homedir(), ".claude", "settings.json");
|
|
110
|
+
case "project":
|
|
111
|
+
return resolve(base, ".claude", "settings.json");
|
|
112
|
+
case "local":
|
|
113
|
+
return resolve(base, ".claude", "settings.local.json");
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
readSettings(settingsPath) {
|
|
118
|
+
return readJsonFile(settingsPath);
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
writeSettings(settingsPath, settings) {
|
|
122
|
+
writeJsonFile(settingsPath, settings);
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
buildHookEntry(binaryPath, eventType, scope) {
|
|
126
|
+
// No --cli flag on the Claude command line: the handler defaults to
|
|
127
|
+
// claude when --cli is omitted, preserving back-compat with hooks
|
|
128
|
+
// installed before multi-CLI support was added.
|
|
129
|
+
const command =
|
|
130
|
+
scope === "project"
|
|
131
|
+
? `npx -y failproofai --hook ${eventType}`
|
|
132
|
+
: `"${binaryPath}" --hook ${eventType}`;
|
|
133
|
+
return {
|
|
134
|
+
type: "command",
|
|
135
|
+
command,
|
|
136
|
+
timeout: 60_000,
|
|
137
|
+
[FAILPROOFAI_HOOK_MARKER]: true,
|
|
138
|
+
};
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
isFailproofaiHook: isMarkedHook,
|
|
142
|
+
|
|
143
|
+
writeHookEntries(settings, binaryPath, scope) {
|
|
144
|
+
const s = settings as ClaudeSettings;
|
|
145
|
+
if (!s.hooks) s.hooks = {};
|
|
146
|
+
|
|
147
|
+
for (const eventType of HOOK_EVENT_TYPES) {
|
|
148
|
+
const hookEntry = this.buildHookEntry(binaryPath, eventType, scope) as unknown as ClaudeHookEntry;
|
|
149
|
+
if (!s.hooks[eventType]) s.hooks[eventType] = [];
|
|
150
|
+
const matchers: ClaudeHookMatcher[] = s.hooks[eventType];
|
|
151
|
+
|
|
152
|
+
let found = false;
|
|
153
|
+
for (const matcher of matchers) {
|
|
154
|
+
if (!matcher.hooks) continue;
|
|
155
|
+
const idx = matcher.hooks.findIndex((h) => isMarkedHook(h as Record<string, unknown>));
|
|
156
|
+
if (idx >= 0) {
|
|
157
|
+
matcher.hooks[idx] = hookEntry;
|
|
158
|
+
found = true;
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (!found) matchers.push({ hooks: [hookEntry] });
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
removeHooksFromFile(settingsPath) {
|
|
167
|
+
const settings = this.readSettings(settingsPath) as ClaudeSettings;
|
|
168
|
+
if (!settings.hooks) return 0;
|
|
169
|
+
|
|
170
|
+
let removed = 0;
|
|
171
|
+
for (const eventType of Object.keys(settings.hooks)) {
|
|
172
|
+
const matchers = settings.hooks[eventType];
|
|
173
|
+
if (!Array.isArray(matchers)) continue;
|
|
174
|
+
for (let i = matchers.length - 1; i >= 0; i--) {
|
|
175
|
+
const matcher = matchers[i];
|
|
176
|
+
if (!matcher.hooks) continue;
|
|
177
|
+
const before = matcher.hooks.length;
|
|
178
|
+
matcher.hooks = matcher.hooks.filter((h) => !isMarkedHook(h as Record<string, unknown>));
|
|
179
|
+
removed += before - matcher.hooks.length;
|
|
180
|
+
if (matcher.hooks.length === 0) matchers.splice(i, 1);
|
|
181
|
+
}
|
|
182
|
+
if (matchers.length === 0) delete settings.hooks[eventType];
|
|
183
|
+
}
|
|
184
|
+
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
|
185
|
+
|
|
186
|
+
this.writeSettings(settingsPath, settings as Record<string, unknown>);
|
|
187
|
+
return removed;
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
hooksInstalledInSettings(scope, cwd) {
|
|
191
|
+
const settingsPath = this.getSettingsPath(scope, cwd);
|
|
192
|
+
if (!existsSync(settingsPath)) return false;
|
|
193
|
+
try {
|
|
194
|
+
const settings = this.readSettings(settingsPath) as ClaudeSettings;
|
|
195
|
+
if (!settings.hooks) return false;
|
|
196
|
+
for (const matchers of Object.values(settings.hooks)) {
|
|
197
|
+
if (!Array.isArray(matchers)) continue;
|
|
198
|
+
for (const matcher of matchers) {
|
|
199
|
+
if (!matcher.hooks) continue;
|
|
200
|
+
if (matcher.hooks.some((h) => isMarkedHook(h as Record<string, unknown>))) return true;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
} catch {
|
|
204
|
+
// Corrupt settings — treat as not installed
|
|
205
|
+
}
|
|
206
|
+
return false;
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
detectInstalled() {
|
|
210
|
+
return binaryExists("claude") || binaryExists("claude-code");
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// ── OpenAI Codex integration ────────────────────────────────────────────────
|
|
215
|
+
//
|
|
216
|
+
// Codex's hook protocol is Claude-compatible by design (see the parity matrix
|
|
217
|
+
// in plans/great-in-failproofai-i-vectorized-treasure.md). The only material
|
|
218
|
+
// differences are:
|
|
219
|
+
// • Settings paths: ~/.codex/hooks.json (user) and <cwd>/.codex/hooks.json (project)
|
|
220
|
+
// • Stdin event names arrive snake_case (pre_tool_use); we canonicalize to PascalCase before policy lookup
|
|
221
|
+
// • No "local" scope
|
|
222
|
+
// • Settings file carries a top-level "version": 1 marker
|
|
223
|
+
|
|
224
|
+
interface CodexSettingsFile {
|
|
225
|
+
version?: number;
|
|
226
|
+
hooks?: Record<string, ClaudeHookMatcher[]>;
|
|
227
|
+
[key: string]: unknown;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export const codex: Integration = {
|
|
231
|
+
id: "codex",
|
|
232
|
+
displayName: "OpenAI Codex",
|
|
233
|
+
scopes: CODEX_HOOK_SCOPES,
|
|
234
|
+
eventTypes: CODEX_HOOK_EVENT_TYPES,
|
|
235
|
+
|
|
236
|
+
getSettingsPath(scope, cwd) {
|
|
237
|
+
const base = cwd ? resolve(cwd) : process.cwd();
|
|
238
|
+
switch (scope) {
|
|
239
|
+
case "user":
|
|
240
|
+
return resolve(homedir(), ".codex", "hooks.json");
|
|
241
|
+
case "project":
|
|
242
|
+
return resolve(base, ".codex", "hooks.json");
|
|
243
|
+
case "local":
|
|
244
|
+
// Codex has no "local" scope; fall back to project so callers don't crash.
|
|
245
|
+
// The CLI rejects --cli codex --scope local before reaching here.
|
|
246
|
+
return resolve(base, ".codex", "hooks.json");
|
|
247
|
+
}
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
readSettings(settingsPath) {
|
|
251
|
+
const raw = readJsonFile(settingsPath);
|
|
252
|
+
if (raw.version === undefined) raw.version = 1;
|
|
253
|
+
return raw;
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
writeSettings(settingsPath, settings) {
|
|
257
|
+
writeJsonFile(settingsPath, settings);
|
|
258
|
+
},
|
|
259
|
+
|
|
260
|
+
buildHookEntry(binaryPath, eventType, scope) {
|
|
261
|
+
// `eventType` here is the snake_case Codex event name; Codex stores under
|
|
262
|
+
// PascalCase keys but invokes the command with the snake_case form, which
|
|
263
|
+
// we canonicalize on the way into policy-evaluator.
|
|
264
|
+
const command =
|
|
265
|
+
scope === "project"
|
|
266
|
+
? `npx -y failproofai --hook ${eventType} --cli codex`
|
|
267
|
+
: `"${binaryPath}" --hook ${eventType} --cli codex`;
|
|
268
|
+
return {
|
|
269
|
+
type: "command",
|
|
270
|
+
command,
|
|
271
|
+
timeout: 60_000,
|
|
272
|
+
[FAILPROOFAI_HOOK_MARKER]: true,
|
|
273
|
+
};
|
|
274
|
+
},
|
|
275
|
+
|
|
276
|
+
isFailproofaiHook: isMarkedHook,
|
|
277
|
+
|
|
278
|
+
writeHookEntries(settings, binaryPath, scope) {
|
|
279
|
+
const s = settings as CodexSettingsFile;
|
|
280
|
+
if (s.version === undefined) s.version = 1;
|
|
281
|
+
if (!s.hooks) s.hooks = {};
|
|
282
|
+
|
|
283
|
+
for (const eventType of CODEX_HOOK_EVENT_TYPES) {
|
|
284
|
+
const pascalKey = CODEX_EVENT_MAP[eventType as CodexHookEventType];
|
|
285
|
+
const hookEntry = this.buildHookEntry(binaryPath, eventType, scope) as unknown as ClaudeHookEntry;
|
|
286
|
+
if (!s.hooks[pascalKey]) s.hooks[pascalKey] = [];
|
|
287
|
+
const matchers: ClaudeHookMatcher[] = s.hooks[pascalKey];
|
|
288
|
+
|
|
289
|
+
let found = false;
|
|
290
|
+
for (const matcher of matchers) {
|
|
291
|
+
if (!matcher.hooks) continue;
|
|
292
|
+
const idx = matcher.hooks.findIndex((h) => isMarkedHook(h as Record<string, unknown>));
|
|
293
|
+
if (idx >= 0) {
|
|
294
|
+
matcher.hooks[idx] = hookEntry;
|
|
295
|
+
found = true;
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (!found) matchers.push({ hooks: [hookEntry] });
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
|
|
303
|
+
removeHooksFromFile(settingsPath) {
|
|
304
|
+
const settings = this.readSettings(settingsPath) as CodexSettingsFile;
|
|
305
|
+
if (!settings.hooks) return 0;
|
|
306
|
+
|
|
307
|
+
let removed = 0;
|
|
308
|
+
for (const eventType of Object.keys(settings.hooks)) {
|
|
309
|
+
const matchers = settings.hooks[eventType];
|
|
310
|
+
if (!Array.isArray(matchers)) continue;
|
|
311
|
+
for (let i = matchers.length - 1; i >= 0; i--) {
|
|
312
|
+
const matcher = matchers[i];
|
|
313
|
+
if (!matcher.hooks) continue;
|
|
314
|
+
const before = matcher.hooks.length;
|
|
315
|
+
matcher.hooks = matcher.hooks.filter((h) => !isMarkedHook(h as Record<string, unknown>));
|
|
316
|
+
removed += before - matcher.hooks.length;
|
|
317
|
+
if (matcher.hooks.length === 0) matchers.splice(i, 1);
|
|
318
|
+
}
|
|
319
|
+
if (matchers.length === 0) delete settings.hooks[eventType];
|
|
320
|
+
}
|
|
321
|
+
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
|
322
|
+
|
|
323
|
+
this.writeSettings(settingsPath, settings as Record<string, unknown>);
|
|
324
|
+
return removed;
|
|
325
|
+
},
|
|
326
|
+
|
|
327
|
+
hooksInstalledInSettings(scope, cwd) {
|
|
328
|
+
const settingsPath = this.getSettingsPath(scope, cwd);
|
|
329
|
+
if (!existsSync(settingsPath)) return false;
|
|
330
|
+
try {
|
|
331
|
+
const settings = this.readSettings(settingsPath) as CodexSettingsFile;
|
|
332
|
+
if (!settings.hooks) return false;
|
|
333
|
+
for (const matchers of Object.values(settings.hooks)) {
|
|
334
|
+
if (!Array.isArray(matchers)) continue;
|
|
335
|
+
for (const matcher of matchers) {
|
|
336
|
+
if (!matcher.hooks) continue;
|
|
337
|
+
if (matcher.hooks.some((h) => isMarkedHook(h as Record<string, unknown>))) return true;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
} catch {
|
|
341
|
+
// Corrupt settings — treat as not installed
|
|
342
|
+
}
|
|
343
|
+
return false;
|
|
344
|
+
},
|
|
345
|
+
|
|
346
|
+
detectInstalled() {
|
|
347
|
+
return binaryExists("codex");
|
|
348
|
+
},
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
// ── Registry ────────────────────────────────────────────────────────────────
|
|
352
|
+
|
|
353
|
+
const INTEGRATIONS: Record<IntegrationType, Integration> = {
|
|
354
|
+
claude: claudeCode,
|
|
355
|
+
codex,
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
export function getIntegration(id: IntegrationType): Integration {
|
|
359
|
+
const integration = INTEGRATIONS[id];
|
|
360
|
+
if (!integration) {
|
|
361
|
+
throw new Error(`Unknown integration: ${id}. Valid: ${INTEGRATION_TYPES.join(", ")}`);
|
|
362
|
+
}
|
|
363
|
+
return integration;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function listIntegrations(): Integration[] {
|
|
367
|
+
return INTEGRATION_TYPES.map((id) => INTEGRATIONS[id]);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Detect which agent CLIs are installed on PATH. */
|
|
371
|
+
export function detectInstalledClis(): IntegrationType[] {
|
|
372
|
+
return INTEGRATION_TYPES.filter((id) => INTEGRATIONS[id].detectInstalled());
|
|
373
|
+
}
|