@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 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
 
@@ -1,6 +1,11 @@
1
1
  #!/usr/bin/env bun
2
2
  // @bun @bytecode @bun-cjs
3
- (function(exports, require, module, __filename, __dirname) {var L=require("child_process"),M="/Users/sean/dev/tripwire/src/../dist/tripwire.js",O=(f)=>{let j="Bash",z=!1,x,F,G,J,K;for(let q of f){if(q==="--post"){z=!0;continue}if(q.startsWith("--tool=")){j=q.slice(7);continue}if(q.startsWith("--path=")){x=q.slice(7);continue}if(q.startsWith("--stdout=")){G=q.slice(9);continue}if(q.startsWith("--stderr=")){J=q.slice(9);continue}if(q.startsWith("--content=")){K=q.slice(10);continue}F??=q}return{tool:j,post:z,path:x,command:F,stdout:G,stderr:J,content:K}},Q=(f,j)=>{if(f==="Bash")return{command:j.command??""};if(f==="Read")return{file_path:j.path??""};if(f==="Write")return{file_path:j.path??"",content:j.content??""};if(f==="Edit"||f==="MultiEdit")return{file_path:j.path??"",old_string:"",new_string:j.content??""};return},R=(f)=>{let j=f.post?"PostToolUse":"PreToolUse",z=f.tool,x={hook_event_name:j,tool_name:z,cwd:process.cwd(),session_id:"tripwire-cli-test",tool_input:Q(z,f)};if(f.post)x.tool_response=z==="Bash"?{stdout:f.stdout??"",stderr:f.stderr??""}:{content:f.content??""};return x},V=()=>{process.stdout.write(["tripwire CLI \u2014 synthetic-event tester","","Usage:"," tripwire test '<command>' # PreToolUse Bash"," tripwire test --tool=Read --path=.env # PreToolUse Read"," tripwire test --tool=Write --path=foo.ts --content='TODO finish'"," tripwire test --post --tool=Bash --stdout='ghp_REAL_TOKEN' # PostToolUse",""].join(`
4
- `))},W=()=>{let f=process.argv.slice(2);if(f.length===0||f[0]!=="test")V(),process.exit(0);let j=O(f.slice(1)),z=R(j),x=L.spawnSync(M,[],{input:JSON.stringify(z),encoding:"utf8",timeout:1e4});if(x.error!==void 0)process.stderr.write(`error: ${String(x.error)}
5
- `),process.exit(1);let F=x.stdout;try{let G=JSON.parse(F);process.stdout.write(`${JSON.stringify(G,null,2)}
6
- `)}catch{process.stdout.write(F)}};W();})
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();})
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seanmozeik/tripwire",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Opinionated hooks dispatcher for AI coding agents with configurable safety rules",
5
5
  "license": "MIT",
6
6
  "bin": {
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 { spawnSync } from 'node:child_process';
12
-
13
- const DISPATCH_BIN = `${import.meta.dir}/../dist/tripwire.js`;
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
- interface CliArgs {
16
- readonly tool: string;
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 parseArgs = (argv: readonly string[]): CliArgs => {
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 = (tool: string, args: CliArgs): unknown => {
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: args.command ?? '' };
42
+ return { command: command ?? '' };
75
43
  }
76
44
  if (tool === 'Read') {
77
- return { file_path: args.path ?? '' };
45
+ return { file_path: path ?? '' };
78
46
  }
79
47
  if (tool === 'Write') {
80
- return { file_path: args.path ?? '', content: args.content ?? '' };
48
+ return { file_path: path ?? '', content: content ?? '' };
81
49
  }
82
50
  if (tool === 'Edit' || tool === 'MultiEdit') {
83
- return { file_path: args.path ?? '', old_string: '', new_string: args.content ?? '' };
51
+ return { file_path: path ?? '', old_string: '', new_string: content ?? '' };
84
52
  }
85
53
  return undefined;
86
54
  };
87
55
 
88
- const buildEvent = (args: CliArgs): BuiltEvent => {
89
- const eventName = args.post ? 'PostToolUse' : 'PreToolUse';
90
- const tool = args.tool;
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, args),
74
+ tool_input: buildToolInput(tool, command, path, content),
97
75
  };
98
- if (args.post) {
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 printUsage = (): void => {
108
- process.stdout.write(
109
- [
110
- 'tripwire CLI synthetic-event tester',
111
- '',
112
- 'Usage:',
113
- " tripwire test '<command>' # PreToolUse Bash",
114
- ' tripwire test --tool=Read --path=.env # PreToolUse Read',
115
- " tripwire test --tool=Write --path=foo.ts --content='TODO finish'",
116
- " tripwire test --post --tool=Bash --stdout='ghp_REAL_TOKEN' # PostToolUse",
117
- '',
118
- ].join('\n'),
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 main = (): void => {
123
- const argv = process.argv.slice(2);
124
- if (argv.length === 0 || argv[0] !== 'test') {
125
- printUsage();
126
- process.exit(0);
127
- }
128
- const args = parseArgs(argv.slice(1));
129
- const event = buildEvent(args);
130
- const result = spawnSync(DISPATCH_BIN, [], {
131
- input: JSON.stringify(event),
132
- encoding: 'utf8',
133
- timeout: 10_000,
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
- if (result.error !== undefined) {
136
- process.stderr.write(`error: ${String(result.error)}\n`);
137
- process.exit(1);
138
- }
139
- const stdout: string = result.stdout;
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
- const parsed = JSON.parse(stdout) as unknown;
142
- process.stdout.write(`${JSON.stringify(parsed, null, 2)}\n`);
143
- } catch {
144
- process.stdout.write(stdout);
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
- main();
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
- // eslint-disable-next-line promise/prefer-await-to-then
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
+ };