@seanmozeik/tripwire 0.1.0 → 0.2.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/README.md +6 -1
- package/dist/tripwire-cli.js +9 -4
- package/dist/tripwire-cli.js.jsc +0 -0
- package/dist/tripwire.js.jsc +0 -0
- package/package.json +1 -1
- package/src/cli.ts +190 -98
- package/src/lib/config.ts +1 -1
- package/src/lib/install.ts +238 -0
package/README.md
CHANGED
|
@@ -14,11 +14,16 @@ bun install @seanmozeik/tripwire
|
|
|
14
14
|
tripwire test '<command>' # Test a command
|
|
15
15
|
tripwire test --tool=Read --path=.env # Test Read tool
|
|
16
16
|
tripwire test --post --tool=Bash --stdout='ghp_TOKEN' # Test PostToolUse
|
|
17
|
+
|
|
18
|
+
tripwire install claude # Install hooks for Claude Code
|
|
19
|
+
tripwire install codex # Install hooks for Codex
|
|
20
|
+
tripwire install pi # Install hooks for pi-guardrails
|
|
21
|
+
tripwire install all # Install hooks for all agents
|
|
17
22
|
```
|
|
18
23
|
|
|
19
24
|
## Hook Configuration
|
|
20
25
|
|
|
21
|
-
Configure your AI agent to call `tripwire-hook` for hook events:
|
|
26
|
+
Configure your AI agent to call `tripwire-hook` for hook events. You can do this manually, or use the `tripwire install` command to automatically configure hooks for supported agents:
|
|
22
27
|
|
|
23
28
|
### Claude Code
|
|
24
29
|
|
package/dist/tripwire-cli.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
// @bun @bytecode @bun-cjs
|
|
3
|
-
(function(exports, require, module, __filename, __dirname) {var
|
|
4
|
-
`)
|
|
5
|
-
`),
|
|
6
|
-
`)}catch{
|
|
3
|
+
(function(exports, require, module, __filename, __dirname) {var S=require("@effect/platform-bun"),Q=require("effect"),w=require("effect/unstable/cli");var W={name:"@seanmozeik/tripwire",version:"0.2.0",description:"Opinionated hooks dispatcher for AI coding agents with configurable safety rules",license:"MIT",bin:{tripwire:"./dist/tripwire-cli.js","tripwire-hook":"./dist/tripwire.js"},files:["dist","package.json","src","README.md"],type:"module",exports:{".":"./src/index.ts"},publishConfig:{access:"public"},scripts:{build:"bun scripts/build.ts",prepublishOnly:"bun run build",check:"bun run format && bun run lint:fix && bun run typecheck",format:"oxfmt --write .",lint:"oxlint --tsconfig tsconfig.oxlint.json .","lint:fix":"oxlint --format agent --tsconfig tsconfig.oxlint.json --fix .",test:"bun test",typecheck:"tsc --noEmit"},dependencies:{"@effect/platform-bun":"^4.0.0-beta.65",effect:"^4.0.0-beta.65","shell-quote":"^1.8.3"},devDependencies:{"@types/bun":"^1.3.13","@types/shell-quote":"^1.7.5",oxfmt:"^0.48.0",oxlint:"^1.61.0","oxlint-tsgolint":"^0.22.1",typescript:"^6.0.3"},engines:{bun:">=1.0"}};var B=require("os"),N=globalThis.Bun,X="tripwire-hook",Z=(b)=>{if(!b)return[[{hooks:[{type:"command",command:X}]}],!1];let q=!1,x=b.map((L)=>({hooks:L.hooks.map((D)=>{if(D.command===X||D.command.endsWith("/tripwire-hook")){if(D.command!==X)return q=!0,{...D,command:X};return D}return D})}));if(x.some((L)=>L.hooks.some((D)=>D.command===X)))return[x,!q];return[[...x,{hooks:[{type:"command",command:X}]}],!1]},v=async()=>{let b=`${B.homedir()}/.claude/settings.json`,q=N.file(b);try{let x=await q.text(),y=JSON.parse(x);y.hooks??={};let[G,L]=Z(y.hooks.PreToolUse),[D,j]=Z(y.hooks.PostToolUse);if(y.hooks.PreToolUse=G,y.hooks.PostToolUse=D,L&&j)return{success:!0,message:`Already configured: ${b}`};return await q.write(`${JSON.stringify(y,null,2)}
|
|
4
|
+
`),{success:!0,message:`Updated ${b}`}}catch(x){let y=x instanceof Error?x.message:String(x);if(y.includes("No such file"))return{success:!1,message:`Config file not found: ${b}`};return{success:!1,message:`Failed to update Claude config: ${y}`}}},A=async()=>{let b=`${B.homedir()}/.pi/agent/settings.json`,q=N.file(b);try{let x=await q.text(),y=JSON.parse(x);y.hooks??={};let[G,L]=Z(y.hooks.PreToolUse),[D,j]=Z(y.hooks.PostToolUse);if(y.hooks.PreToolUse=G,y.hooks.PostToolUse=D,L&&j)return{success:!0,message:`Already configured: ${b}`};return await q.write(`${JSON.stringify(y,null,2)}
|
|
5
|
+
`),{success:!0,message:`Updated ${b}`}}catch(x){let y=x instanceof Error?x.message:String(x);if(y.includes("No such file"))return{success:!1,message:`Config file not found: ${b}`};return{success:!1,message:`Failed to update pi config: ${y}`}}},J=async()=>{let b=`${B.homedir()}/.codex/config.toml`,q=`${B.homedir()}/.codex/hooks.json`,x=N.file(q),y=N.file(b),G=!1,L=!1;try{let D=await x.text(),j=JSON.parse(D);j.hooks??={};let[Y,V]=Z(j.hooks.PreToolUse),[z,$]=Z(j.hooks.PostToolUse);if(j.hooks.PreToolUse=Y,j.hooks.PostToolUse=z,!V||!$)G=!0;let K=(U)=>{return U?.map((E)=>({hooks:E.hooks.map((M)=>{if(M.command===X&&M.timeout===void 0)return{...M,timeout:10};return M})}))??[]};if(j.hooks.PreToolUse=K(j.hooks.PreToolUse),j.hooks.PostToolUse=K(j.hooks.PostToolUse),G)await x.write(`${JSON.stringify(j,null,2)}
|
|
6
|
+
`)}catch(D){let j=D instanceof Error?D.message:String(D);if(j.includes("No such file"))return{success:!1,message:`Config file not found: ${q}`};return{success:!1,message:`Failed to update Codex hooks.json: ${j}`}}try{let j=await y.text();if(j.includes("hooks = true"));else if(L=!0,j.includes("[features]")){let Y=j.indexOf("[features]"),V=j.indexOf(`
|
|
7
|
+
[`,Y+1);if(V===-1)j+=`
|
|
8
|
+
hooks = true`;else j=`${j.slice(0,V)}
|
|
9
|
+
hooks = true${j.slice(V)}`}else j+=`
|
|
10
|
+
[features]
|
|
11
|
+
hooks = true`;if(L)await y.write(j)}catch(D){let j=D instanceof Error?D.message:String(D);if(j.includes("No such file"))return{success:!1,message:`Config file not found: ${b}`};return{success:!1,message:`Failed to update Codex config.toml: ${j}`}}if(!G&&!L)return{success:!0,message:`Already configured: ${b} and ${q}`};return{success:!0,message:`Updated ${b} and ${q}`}},_=async()=>{return[{target:"claude",...await v()},{target:"codex",...await J()},{target:"pi",...await A()}]};var C="/Users/sean/dev/tripwire/src/../dist/tripwire.js",H=(b,q,x,y)=>{if(b==="Bash")return{command:q??""};if(b==="Read")return{file_path:x??""};if(b==="Write")return{file_path:x??"",content:y??""};if(b==="Edit"||b==="MultiEdit")return{file_path:x??"",old_string:"",new_string:y??""};return},O=(b)=>{let{tool:q,post:x,command:y,path:G,stdout:L,stderr:D,content:j}=b,V={hook_event_name:x?"PostToolUse":"PreToolUse",tool_name:q,cwd:process.cwd(),session_id:"tripwire-cli-test",tool_input:H(q,y,G,j)};if(x)V.tool_response=q==="Bash"?{stdout:L??"",stderr:D??""}:{content:j??""};return V},u=(b)=>Q.Effect.sync(()=>{let{command:q,content:x,path:y,post:G,stderr:L,stdout:D,tool:j}=b,Y=O({tool:j,post:G,command:q,path:y,stdout:D,stderr:L,content:x}),V=Bun.spawnSync([C],{stdin:new TextEncoder().encode(JSON.stringify(Y)),timeout:1e4,stdout:"pipe",stderr:"pipe"});if(V.exitCode!==0){let $=new TextDecoder().decode(V.stderr);console.error(`error: ${$}`),process.exit(1)}let z=new TextDecoder().decode(V.stdout);try{let $=JSON.parse(z);console.log(JSON.stringify($,null,2))}catch{console.log(z)}}),T=w.Command.make("test",{command:w.Argument.string("command").pipe(w.Argument.optional,w.Argument.withDescription("Command to test (for Bash tool)")),content:w.Flag.string("content").pipe(w.Flag.optional,w.Flag.withDescription("Content for Write/Edit tools")),path:w.Flag.string("path").pipe(w.Flag.optional,w.Flag.withDescription("File path for Read/Write/Edit tools")),post:w.Flag.boolean("post").pipe(w.Flag.withDescription("Test PostToolUse instead of PreToolUse")),stderr:w.Flag.string("stderr").pipe(w.Flag.optional,w.Flag.withDescription("Stderr for PostToolUse Bash")),stdout:w.Flag.string("stdout").pipe(w.Flag.optional,w.Flag.withDescription("Stdout for PostToolUse Bash")),tool:w.Flag.string("tool").pipe(w.Flag.withDefault("Bash"),w.Flag.withDescription("Tool name (Bash, Read, Write, Edit, MultiEdit)"))},({command:b,content:q,path:x,post:y,stderr:G,stdout:L,tool:D})=>u({command:Q.Option.getOrUndefined(b),content:Q.Option.getOrUndefined(q),path:Q.Option.getOrUndefined(x),post:y,stderr:Q.Option.getOrUndefined(G),stdout:Q.Option.getOrUndefined(L),tool:D})).pipe(w.Command.withDescription("Test a synthetic hook event")),I=(b)=>Q.Effect.gen(function*(){if(!["claude","codex","pi","all"].includes(b))console.error(`error: unknown target "${b}"`),console.error("Valid targets: claude, codex, pi, all"),process.exit(1);let q;switch(b){case"claude":{q=[{target:"claude",result:yield*Q.Effect.promise(()=>v())}];break}case"codex":{q=[{target:"codex",result:yield*Q.Effect.promise(()=>J())}];break}case"pi":{q=[{target:"pi",result:yield*Q.Effect.promise(()=>A())}];break}case"all":{q=(yield*Q.Effect.promise(()=>_())).map((G)=>({target:G.target,result:G}));break}default:{q=[];break}}let x=!1;for(let{target:y,result:G}of q)if(G.success){let L=G.message.startsWith("Already configured")?"\u2299":"\u2713";console.log(`${L} [${y}] ${G.message}`)}else console.error(`\u2717 [${y}] ${G.message}`),x=!0;if(x)process.exit(1)}),P=w.Command.make("install",{target:w.Argument.string("target").pipe(w.Argument.withDescription("Target agent (claude, codex, pi, or all)"))},({target:b})=>I(b)).pipe(w.Command.withDescription("Install tripwire hooks for AI agents")),F=w.Command.make("tripwire").pipe(w.Command.withDescription("Opinionated hooks dispatcher for AI coding agents"),w.Command.withSubcommands([T,P])),p=w.Command.run(F,{version:W.version}),c=async()=>{try{await Q.Effect.runPromise(p.pipe(Q.Effect.provide(S.BunServices.layer)))}catch(b){let q=b instanceof Error?b.message:String(b);console.error(q),process.exitCode=1}};c();})
|
package/dist/tripwire-cli.js.jsc
CHANGED
|
Binary file
|
package/dist/tripwire.js.jsc
CHANGED
|
Binary file
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -3,62 +3,25 @@
|
|
|
3
3
|
// Dispatcher and pretty-print the decision. Indispensable for tuning
|
|
4
4
|
// Rules without going through Claude Code.
|
|
5
5
|
//
|
|
6
|
+
// `tripwire install <target>` — install tripwire hooks for AI agents.
|
|
7
|
+
//
|
|
6
8
|
// Usage:
|
|
7
9
|
// Bun src/cli.ts test 'rm -rf /etc'
|
|
8
10
|
// Bun src/cli.ts test --tool=Read --path=.env
|
|
9
11
|
// Bun src/cli.ts test --post --tool=Bash --stdout='ghp_<token>'
|
|
12
|
+
// Bun src/cli.ts install claude
|
|
13
|
+
// Bun src/cli.ts install codex
|
|
14
|
+
// Bun src/cli.ts install pi
|
|
15
|
+
// Bun src/cli.ts install all
|
|
10
16
|
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
17
|
+
import { BunServices } from '@effect/platform-bun';
|
|
18
|
+
import { Effect, Option } from 'effect';
|
|
19
|
+
import { Argument, Command, Flag } from 'effect/unstable/cli';
|
|
14
20
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
readonly post: boolean;
|
|
18
|
-
readonly path: string | undefined;
|
|
19
|
-
readonly command: string | undefined;
|
|
20
|
-
readonly stdout: string | undefined;
|
|
21
|
-
readonly stderr: string | undefined;
|
|
22
|
-
readonly content: string | undefined;
|
|
23
|
-
}
|
|
21
|
+
import pkg from '../package.json' with { type: 'json' };
|
|
22
|
+
import { installAll, installClaude, installCodex, installPi } from './lib/install';
|
|
24
23
|
|
|
25
|
-
const
|
|
26
|
-
let tool = 'Bash';
|
|
27
|
-
let post = false;
|
|
28
|
-
let path: string | undefined;
|
|
29
|
-
let command: string | undefined;
|
|
30
|
-
let stdout: string | undefined;
|
|
31
|
-
let stderr: string | undefined;
|
|
32
|
-
let content: string | undefined;
|
|
33
|
-
for (const a of argv) {
|
|
34
|
-
if (a === '--post') {
|
|
35
|
-
post = true;
|
|
36
|
-
continue;
|
|
37
|
-
}
|
|
38
|
-
if (a.startsWith('--tool=')) {
|
|
39
|
-
tool = a.slice('--tool='.length);
|
|
40
|
-
continue;
|
|
41
|
-
}
|
|
42
|
-
if (a.startsWith('--path=')) {
|
|
43
|
-
path = a.slice('--path='.length);
|
|
44
|
-
continue;
|
|
45
|
-
}
|
|
46
|
-
if (a.startsWith('--stdout=')) {
|
|
47
|
-
stdout = a.slice('--stdout='.length);
|
|
48
|
-
continue;
|
|
49
|
-
}
|
|
50
|
-
if (a.startsWith('--stderr=')) {
|
|
51
|
-
stderr = a.slice('--stderr='.length);
|
|
52
|
-
continue;
|
|
53
|
-
}
|
|
54
|
-
if (a.startsWith('--content=')) {
|
|
55
|
-
content = a.slice('--content='.length);
|
|
56
|
-
continue;
|
|
57
|
-
}
|
|
58
|
-
command ??= a;
|
|
59
|
-
}
|
|
60
|
-
return { tool, post, path, command, stdout, stderr, content };
|
|
61
|
-
};
|
|
24
|
+
const DISPATCH_BIN = `${import.meta.dir}/../dist/tripwire.js`;
|
|
62
25
|
|
|
63
26
|
interface BuiltEvent {
|
|
64
27
|
hook_event_name: string;
|
|
@@ -69,80 +32,209 @@ interface BuiltEvent {
|
|
|
69
32
|
tool_response?: unknown;
|
|
70
33
|
}
|
|
71
34
|
|
|
72
|
-
const buildToolInput = (
|
|
35
|
+
const buildToolInput = (
|
|
36
|
+
tool: string,
|
|
37
|
+
command: string | undefined,
|
|
38
|
+
path: string | undefined,
|
|
39
|
+
content: string | undefined,
|
|
40
|
+
): unknown => {
|
|
73
41
|
if (tool === 'Bash') {
|
|
74
|
-
return { command:
|
|
42
|
+
return { command: command ?? '' };
|
|
75
43
|
}
|
|
76
44
|
if (tool === 'Read') {
|
|
77
|
-
return { file_path:
|
|
45
|
+
return { file_path: path ?? '' };
|
|
78
46
|
}
|
|
79
47
|
if (tool === 'Write') {
|
|
80
|
-
return { file_path:
|
|
48
|
+
return { file_path: path ?? '', content: content ?? '' };
|
|
81
49
|
}
|
|
82
50
|
if (tool === 'Edit' || tool === 'MultiEdit') {
|
|
83
|
-
return { file_path:
|
|
51
|
+
return { file_path: path ?? '', old_string: '', new_string: content ?? '' };
|
|
84
52
|
}
|
|
85
53
|
return undefined;
|
|
86
54
|
};
|
|
87
55
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
56
|
+
interface EventParams {
|
|
57
|
+
readonly tool: string;
|
|
58
|
+
readonly post: boolean;
|
|
59
|
+
readonly command: string | undefined;
|
|
60
|
+
readonly path: string | undefined;
|
|
61
|
+
readonly stdout: string | undefined;
|
|
62
|
+
readonly stderr: string | undefined;
|
|
63
|
+
readonly content: string | undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const buildEvent = (params: EventParams): BuiltEvent => {
|
|
67
|
+
const { tool, post, command, path, stdout, stderr, content } = params;
|
|
68
|
+
const eventName = post ? 'PostToolUse' : 'PreToolUse';
|
|
91
69
|
const event: BuiltEvent = {
|
|
92
70
|
hook_event_name: eventName,
|
|
93
71
|
tool_name: tool,
|
|
94
72
|
cwd: process.cwd(),
|
|
95
73
|
session_id: 'tripwire-cli-test',
|
|
96
|
-
tool_input: buildToolInput(tool,
|
|
74
|
+
tool_input: buildToolInput(tool, command, path, content),
|
|
97
75
|
};
|
|
98
|
-
if (
|
|
76
|
+
if (post) {
|
|
99
77
|
event.tool_response =
|
|
100
|
-
tool === 'Bash'
|
|
101
|
-
? { stdout: args.stdout ?? '', stderr: args.stderr ?? '' }
|
|
102
|
-
: { content: args.content ?? '' };
|
|
78
|
+
tool === 'Bash' ? { stdout: stdout ?? '', stderr: stderr ?? '' } : { content: content ?? '' };
|
|
103
79
|
}
|
|
104
80
|
return event;
|
|
105
81
|
};
|
|
106
82
|
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
83
|
+
const runTest = (config: {
|
|
84
|
+
readonly command: string | undefined;
|
|
85
|
+
readonly content: string | undefined;
|
|
86
|
+
readonly path: string | undefined;
|
|
87
|
+
readonly post: boolean;
|
|
88
|
+
readonly stderr: string | undefined;
|
|
89
|
+
readonly stdout: string | undefined;
|
|
90
|
+
readonly tool: string;
|
|
91
|
+
}): Effect.Effect<void> =>
|
|
92
|
+
Effect.sync(() => {
|
|
93
|
+
const { command, content, path, post, stderr, stdout, tool } = config;
|
|
94
|
+
const event = buildEvent({ tool, post, command, path, stdout, stderr, content });
|
|
95
|
+
const result = Bun.spawnSync([DISPATCH_BIN], {
|
|
96
|
+
stdin: new TextEncoder().encode(JSON.stringify(event)),
|
|
97
|
+
timeout: 10_000,
|
|
98
|
+
stdout: 'pipe',
|
|
99
|
+
stderr: 'pipe',
|
|
100
|
+
});
|
|
101
|
+
if (result.exitCode !== 0) {
|
|
102
|
+
const errorOutput = new TextDecoder().decode(result.stderr);
|
|
103
|
+
console.error(`error: ${errorOutput}`);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
const output = new TextDecoder().decode(result.stdout);
|
|
107
|
+
try {
|
|
108
|
+
const parsed = JSON.parse(output) as unknown;
|
|
109
|
+
console.log(JSON.stringify(parsed, null, 2));
|
|
110
|
+
} catch {
|
|
111
|
+
console.log(output);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
121
114
|
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
115
|
+
const testCommand = Command.make(
|
|
116
|
+
'test',
|
|
117
|
+
{
|
|
118
|
+
command: Argument.string('command').pipe(
|
|
119
|
+
Argument.optional,
|
|
120
|
+
Argument.withDescription('Command to test (for Bash tool)'),
|
|
121
|
+
),
|
|
122
|
+
content: Flag.string('content').pipe(
|
|
123
|
+
Flag.optional,
|
|
124
|
+
Flag.withDescription('Content for Write/Edit tools'),
|
|
125
|
+
),
|
|
126
|
+
path: Flag.string('path').pipe(
|
|
127
|
+
Flag.optional,
|
|
128
|
+
Flag.withDescription('File path for Read/Write/Edit tools'),
|
|
129
|
+
),
|
|
130
|
+
post: Flag.boolean('post').pipe(Flag.withDescription('Test PostToolUse instead of PreToolUse')),
|
|
131
|
+
stderr: Flag.string('stderr').pipe(
|
|
132
|
+
Flag.optional,
|
|
133
|
+
Flag.withDescription('Stderr for PostToolUse Bash'),
|
|
134
|
+
),
|
|
135
|
+
stdout: Flag.string('stdout').pipe(
|
|
136
|
+
Flag.optional,
|
|
137
|
+
Flag.withDescription('Stdout for PostToolUse Bash'),
|
|
138
|
+
),
|
|
139
|
+
tool: Flag.string('tool').pipe(
|
|
140
|
+
Flag.withDefault('Bash'),
|
|
141
|
+
Flag.withDescription('Tool name (Bash, Read, Write, Edit, MultiEdit)'),
|
|
142
|
+
),
|
|
143
|
+
},
|
|
144
|
+
({ command, content, path, post, stderr, stdout, tool }) =>
|
|
145
|
+
runTest({
|
|
146
|
+
command: Option.getOrUndefined(command),
|
|
147
|
+
content: Option.getOrUndefined(content),
|
|
148
|
+
path: Option.getOrUndefined(path),
|
|
149
|
+
post,
|
|
150
|
+
stderr: Option.getOrUndefined(stderr),
|
|
151
|
+
stdout: Option.getOrUndefined(stdout),
|
|
152
|
+
tool,
|
|
153
|
+
}),
|
|
154
|
+
).pipe(Command.withDescription('Test a synthetic hook event'));
|
|
155
|
+
|
|
156
|
+
const runInstall = (target: string): Effect.Effect<void> =>
|
|
157
|
+
Effect.gen(function* () {
|
|
158
|
+
if (!['claude', 'codex', 'pi', 'all'].includes(target)) {
|
|
159
|
+
console.error(`error: unknown target "${target}"`);
|
|
160
|
+
console.error('Valid targets: claude, codex, pi, all');
|
|
161
|
+
process.exit(1);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let results: {
|
|
165
|
+
readonly target: string;
|
|
166
|
+
readonly result: { readonly success: boolean; readonly message: string };
|
|
167
|
+
}[];
|
|
168
|
+
|
|
169
|
+
switch (target) {
|
|
170
|
+
case 'claude': {
|
|
171
|
+
const result = yield* Effect.promise(() => installClaude());
|
|
172
|
+
results = [{ target: 'claude', result }];
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
case 'codex': {
|
|
176
|
+
const result = yield* Effect.promise(() => installCodex());
|
|
177
|
+
results = [{ target: 'codex', result }];
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
case 'pi': {
|
|
181
|
+
const result = yield* Effect.promise(() => installPi());
|
|
182
|
+
results = [{ target: 'pi', result }];
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
case 'all': {
|
|
186
|
+
const installResults = yield* Effect.promise(() => installAll());
|
|
187
|
+
results = installResults.map((r) => ({ target: r.target, result: r }));
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
default: {
|
|
191
|
+
results = [];
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
let hasFailure = false;
|
|
197
|
+
for (const { target: t, result: r } of results) {
|
|
198
|
+
if (r.success) {
|
|
199
|
+
const symbol = r.message.startsWith('Already configured') ? '⊙' : '✓';
|
|
200
|
+
console.log(`${symbol} [${t}] ${r.message}`);
|
|
201
|
+
} else {
|
|
202
|
+
console.error(`✗ [${t}] ${r.message}`);
|
|
203
|
+
hasFailure = true;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (hasFailure) {
|
|
208
|
+
process.exit(1);
|
|
209
|
+
}
|
|
134
210
|
});
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
211
|
+
|
|
212
|
+
const installCommand = Command.make(
|
|
213
|
+
'install',
|
|
214
|
+
{
|
|
215
|
+
target: Argument.string('target').pipe(
|
|
216
|
+
Argument.withDescription('Target agent (claude, codex, pi, or all)'),
|
|
217
|
+
),
|
|
218
|
+
},
|
|
219
|
+
({ target }) => runInstall(target),
|
|
220
|
+
).pipe(Command.withDescription('Install tripwire hooks for AI agents'));
|
|
221
|
+
|
|
222
|
+
const app = Command.make('tripwire').pipe(
|
|
223
|
+
Command.withDescription('Opinionated hooks dispatcher for AI coding agents'),
|
|
224
|
+
Command.withSubcommands([testCommand, installCommand]),
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
const program = Command.run(app, { version: pkg.version });
|
|
228
|
+
|
|
229
|
+
const main = async (): Promise<void> => {
|
|
140
230
|
try {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
231
|
+
await Effect.runPromise(program.pipe(Effect.provide(BunServices.layer)));
|
|
232
|
+
} catch (error) {
|
|
233
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
234
|
+
console.error(message);
|
|
235
|
+
process.exitCode = 1;
|
|
145
236
|
}
|
|
146
237
|
};
|
|
147
238
|
|
|
148
|
-
|
|
239
|
+
// oxlint-disable-next-line no-void, unicorn/prefer-top-level-await
|
|
240
|
+
void main();
|
package/src/lib/config.ts
CHANGED
|
@@ -89,7 +89,7 @@ export const loadConfig = (): Effect.Effect<Config> =>
|
|
|
89
89
|
return mergeWithDefaults(config);
|
|
90
90
|
}).pipe(
|
|
91
91
|
Effect.timeout(1000),
|
|
92
|
-
//
|
|
92
|
+
// oxlint-disable-next-line promise/prefer-await-to-then
|
|
93
93
|
Effect.catch(() => {
|
|
94
94
|
// Log error but return defaults to never block the agent
|
|
95
95
|
console.error('[tripwire] Config loading failed, using defaults');
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// Config installation module for tripwire hooks.
|
|
2
|
+
// Parses and upserts hook configurations for Claude Code, Codex, and pi-guardrails.
|
|
3
|
+
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
|
|
6
|
+
import { file } from 'bun';
|
|
7
|
+
|
|
8
|
+
interface ClaudeConfig {
|
|
9
|
+
hooks?: {
|
|
10
|
+
PreToolUse?: { hooks: { type: string; command: string }[] }[];
|
|
11
|
+
PostToolUse?: { hooks: { type: string; command: string }[] }[];
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface PiConfig {
|
|
16
|
+
hooks?: {
|
|
17
|
+
PreToolUse?: { hooks: { type: string; command: string }[] }[];
|
|
18
|
+
PostToolUse?: { hooks: { type: string; command: string }[] }[];
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface CodexHooksConfig {
|
|
23
|
+
hooks?: {
|
|
24
|
+
PreToolUse?: { hooks: { type: string; command: string; timeout?: number }[] }[];
|
|
25
|
+
PostToolUse?: { hooks: { type: string; command: string; timeout?: number }[] }[];
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const TRIPWIRE_HOOK = 'tripwire-hook';
|
|
30
|
+
|
|
31
|
+
const addHookIfMissing = (
|
|
32
|
+
hooks: { hooks: { type: string; command: string; timeout?: number }[] }[] | undefined,
|
|
33
|
+
): [{ hooks: { type: string; command: string; timeout?: number }[] }[], boolean] => {
|
|
34
|
+
if (!hooks) {
|
|
35
|
+
const newHooks: { hooks: { type: string; command: string; timeout?: number }[] }[] = [
|
|
36
|
+
{ hooks: [{ type: 'command', command: TRIPWIRE_HOOK }] },
|
|
37
|
+
];
|
|
38
|
+
return [newHooks, false];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let needsNormalization = false;
|
|
42
|
+
|
|
43
|
+
const normalizedHooks = hooks.map((h) => ({
|
|
44
|
+
hooks: h.hooks.map((hook) => {
|
|
45
|
+
if (hook.command === TRIPWIRE_HOOK || hook.command.endsWith('/tripwire-hook')) {
|
|
46
|
+
if (hook.command !== TRIPWIRE_HOOK) {
|
|
47
|
+
needsNormalization = true;
|
|
48
|
+
return { ...hook, command: TRIPWIRE_HOOK };
|
|
49
|
+
}
|
|
50
|
+
return hook;
|
|
51
|
+
}
|
|
52
|
+
return hook;
|
|
53
|
+
}),
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
const hasTripwire = normalizedHooks.some((h) =>
|
|
57
|
+
h.hooks.some((hook) => hook.command === TRIPWIRE_HOOK),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
if (hasTripwire) {
|
|
61
|
+
return [normalizedHooks, !needsNormalization];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const newHooks: { hooks: { type: string; command: string; timeout?: number }[] }[] = [
|
|
65
|
+
...normalizedHooks,
|
|
66
|
+
{ hooks: [{ type: 'command', command: TRIPWIRE_HOOK }] },
|
|
67
|
+
];
|
|
68
|
+
return [newHooks, false];
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const installClaude = async (): Promise<{ success: boolean; message: string }> => {
|
|
72
|
+
const configPath = `${homedir()}/.claude/settings.json`;
|
|
73
|
+
const configFile = file(configPath);
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const raw = await configFile.text();
|
|
77
|
+
const config = JSON.parse(raw) as ClaudeConfig;
|
|
78
|
+
|
|
79
|
+
config.hooks ??= {};
|
|
80
|
+
const [preToolUse, preSkipped] = addHookIfMissing(config.hooks.PreToolUse);
|
|
81
|
+
const [postToolUse, postSkipped] = addHookIfMissing(config.hooks.PostToolUse);
|
|
82
|
+
|
|
83
|
+
config.hooks.PreToolUse = preToolUse;
|
|
84
|
+
config.hooks.PostToolUse = postToolUse;
|
|
85
|
+
|
|
86
|
+
if (preSkipped && postSkipped) {
|
|
87
|
+
return { success: true, message: `Already configured: ${configPath}` };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
await configFile.write(`${JSON.stringify(config, null, 2)}\n`);
|
|
91
|
+
|
|
92
|
+
return { success: true, message: `Updated ${configPath}` };
|
|
93
|
+
} catch (error) {
|
|
94
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
95
|
+
if (message.includes('No such file')) {
|
|
96
|
+
return { success: false, message: `Config file not found: ${configPath}` };
|
|
97
|
+
}
|
|
98
|
+
return { success: false, message: `Failed to update Claude config: ${message}` };
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export const installPi = async (): Promise<{ success: boolean; message: string }> => {
|
|
103
|
+
const configPath = `${homedir()}/.pi/agent/settings.json`;
|
|
104
|
+
const configFile = file(configPath);
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const raw = await configFile.text();
|
|
108
|
+
const config = JSON.parse(raw) as PiConfig;
|
|
109
|
+
|
|
110
|
+
config.hooks ??= {};
|
|
111
|
+
const [preToolUse, preSkipped] = addHookIfMissing(config.hooks.PreToolUse);
|
|
112
|
+
const [postToolUse, postSkipped] = addHookIfMissing(config.hooks.PostToolUse);
|
|
113
|
+
|
|
114
|
+
config.hooks.PreToolUse = preToolUse;
|
|
115
|
+
config.hooks.PostToolUse = postToolUse;
|
|
116
|
+
|
|
117
|
+
if (preSkipped && postSkipped) {
|
|
118
|
+
return { success: true, message: `Already configured: ${configPath}` };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
await configFile.write(`${JSON.stringify(config, null, 2)}\n`);
|
|
122
|
+
|
|
123
|
+
return { success: true, message: `Updated ${configPath}` };
|
|
124
|
+
} catch (error) {
|
|
125
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
126
|
+
if (message.includes('No such file')) {
|
|
127
|
+
return { success: false, message: `Config file not found: ${configPath}` };
|
|
128
|
+
}
|
|
129
|
+
return { success: false, message: `Failed to update pi config: ${message}` };
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export const installCodex = async (): Promise<{ success: boolean; message: string }> => {
|
|
134
|
+
const configTomlPath = `${homedir()}/.codex/config.toml`;
|
|
135
|
+
const hooksJsonPath = `${homedir()}/.codex/hooks.json`;
|
|
136
|
+
const hooksJsonFile = file(hooksJsonPath);
|
|
137
|
+
const configTomlFile = file(configTomlPath);
|
|
138
|
+
|
|
139
|
+
let hooksUpdated = false;
|
|
140
|
+
let tomlUpdated = false;
|
|
141
|
+
|
|
142
|
+
// First, update hooks.json
|
|
143
|
+
try {
|
|
144
|
+
const raw = await hooksJsonFile.text();
|
|
145
|
+
const config = JSON.parse(raw) as CodexHooksConfig;
|
|
146
|
+
|
|
147
|
+
config.hooks ??= {};
|
|
148
|
+
const [preToolUse, preSkipped] = addHookIfMissing(config.hooks.PreToolUse);
|
|
149
|
+
const [postToolUse, postSkipped] = addHookIfMissing(config.hooks.PostToolUse);
|
|
150
|
+
|
|
151
|
+
config.hooks.PreToolUse = preToolUse;
|
|
152
|
+
config.hooks.PostToolUse = postToolUse;
|
|
153
|
+
|
|
154
|
+
if (!preSkipped || !postSkipped) {
|
|
155
|
+
hooksUpdated = true;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Add timeout to tripwire-hook if not present
|
|
159
|
+
const addTimeout = (
|
|
160
|
+
hooks: { hooks: { type: string; command: string; timeout?: number }[] }[] | undefined,
|
|
161
|
+
): { hooks: { type: string; command: string; timeout?: number }[] }[] => {
|
|
162
|
+
return (
|
|
163
|
+
hooks?.map((h) => ({
|
|
164
|
+
hooks: h.hooks.map((hook) => {
|
|
165
|
+
if (hook.command === TRIPWIRE_HOOK && hook.timeout === undefined) {
|
|
166
|
+
return { ...hook, timeout: 10 };
|
|
167
|
+
}
|
|
168
|
+
return hook;
|
|
169
|
+
}),
|
|
170
|
+
})) ?? []
|
|
171
|
+
);
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
config.hooks.PreToolUse = addTimeout(config.hooks.PreToolUse);
|
|
175
|
+
config.hooks.PostToolUse = addTimeout(config.hooks.PostToolUse);
|
|
176
|
+
|
|
177
|
+
if (hooksUpdated) {
|
|
178
|
+
await hooksJsonFile.write(`${JSON.stringify(config, null, 2)}\n`);
|
|
179
|
+
}
|
|
180
|
+
} catch (error) {
|
|
181
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
182
|
+
if (message.includes('No such file')) {
|
|
183
|
+
return { success: false, message: `Config file not found: ${hooksJsonPath}` };
|
|
184
|
+
}
|
|
185
|
+
return { success: false, message: `Failed to update Codex hooks.json: ${message}` };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Then, update config.toml to enable hooks
|
|
189
|
+
try {
|
|
190
|
+
const raw = await configTomlFile.text();
|
|
191
|
+
let toml = raw;
|
|
192
|
+
|
|
193
|
+
// Enable hooks in [features] section
|
|
194
|
+
if (toml.includes('hooks = true')) {
|
|
195
|
+
// Already enabled, nothing to do
|
|
196
|
+
} else {
|
|
197
|
+
tomlUpdated = true;
|
|
198
|
+
if (toml.includes('[features]')) {
|
|
199
|
+
// Find [features] section and add hooks = true
|
|
200
|
+
const featuresIndex = toml.indexOf('[features]');
|
|
201
|
+
const nextSectionIndex = toml.indexOf('\n[', featuresIndex + 1);
|
|
202
|
+
if (nextSectionIndex === -1) {
|
|
203
|
+
toml += '\nhooks = true';
|
|
204
|
+
} else {
|
|
205
|
+
toml = `${toml.slice(0, nextSectionIndex)}\nhooks = true${toml.slice(nextSectionIndex)}`;
|
|
206
|
+
}
|
|
207
|
+
} else {
|
|
208
|
+
toml += '\n[features]\nhooks = true';
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (tomlUpdated) {
|
|
213
|
+
await configTomlFile.write(toml);
|
|
214
|
+
}
|
|
215
|
+
} catch (error) {
|
|
216
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
217
|
+
if (message.includes('No such file')) {
|
|
218
|
+
return { success: false, message: `Config file not found: ${configTomlPath}` };
|
|
219
|
+
}
|
|
220
|
+
return { success: false, message: `Failed to update Codex config.toml: ${message}` };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (!hooksUpdated && !tomlUpdated) {
|
|
224
|
+
return { success: true, message: `Already configured: ${configTomlPath} and ${hooksJsonPath}` };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return { success: true, message: `Updated ${configTomlPath} and ${hooksJsonPath}` };
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
export const installAll = async (): Promise<
|
|
231
|
+
{ target: string; success: boolean; message: string }[]
|
|
232
|
+
> => {
|
|
233
|
+
return [
|
|
234
|
+
{ target: 'claude', ...(await installClaude()) },
|
|
235
|
+
{ target: 'codex', ...(await installCodex()) },
|
|
236
|
+
{ target: 'pi', ...(await installPi()) },
|
|
237
|
+
];
|
|
238
|
+
};
|