@cleocode/adapters 2026.10.1 → 2026.10.2

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.
@@ -0,0 +1,621 @@
1
+ /**
2
+ * Tests for installing the heavy-command hook (T12983).
3
+ *
4
+ * Coverage:
5
+ * - the hook command line, under every shell present: starts no `cleo`
6
+ * unless a tool name appears as a whole word, prints only JSON, exits 0
7
+ * when `cleo` is missing, and asks an older CLEO only once (a marker keyed
8
+ * on the binary, invalidated by a newer binary)
9
+ * - JSON configs (Claude Code settings.local.json, Codex hooks.json):
10
+ * install, idempotency, refresh of a stale hook, user hooks untouched even
11
+ * inside CLEO's matcher group, `off` removes only CLEO's hook, duplicates
12
+ * collapse, a malformed file is never rewritten
13
+ * - the opencode plugin: write, idempotency, `off` deletes it, and the
14
+ * generated module applies the hook's rewrite
15
+ * - installer wiring: Claude Code writes the project settings.local.json
16
+ * and removes an earlier entry from settings.json (never the user's
17
+ * settings), Codex and opencode install only when a mode is passed, the
18
+ * home directory is skipped
19
+ *
20
+ * Every write goes to a temp directory; HOME and CLAUDE_HOME point there too.
21
+ *
22
+ * @task T12983
23
+ */
24
+
25
+ import { execFileSync } from 'node:child_process';
26
+ import {
27
+ chmodSync,
28
+ copyFileSync,
29
+ existsSync,
30
+ lstatSync,
31
+ mkdirSync,
32
+ mkdtempSync,
33
+ readdirSync,
34
+ readFileSync,
35
+ rmSync,
36
+ symlinkSync,
37
+ utimesSync,
38
+ writeFileSync,
39
+ } from 'node:fs';
40
+ import { tmpdir } from 'node:os';
41
+ import { join } from 'node:path';
42
+ import { pathToFileURL } from 'node:url';
43
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
44
+ import { ClaudeCodeInstallProvider } from '../../claude-code/install.js';
45
+ import { CodexInstallProvider } from '../../codex/install.js';
46
+ import { OpenCodeInstallProvider } from '../../opencode/install.js';
47
+ import {
48
+ clearOlderCleoMarkers,
49
+ excludeLocalSettingsFromGit,
50
+ HEAVY_COMMAND_HOOK_TIMEOUT_SEC,
51
+ heavyCommandHookCommand,
52
+ heavyCommandHookEntry,
53
+ heavyCommandHookObject,
54
+ isUserHomeDir,
55
+ LOCAL_SETTINGS_EXCLUDE_MARKER,
56
+ OLDER_CLEO_HOOK_ANSWER,
57
+ OLDER_CLEO_MARKER_PREFIX,
58
+ OPENCODE_HEAVY_COMMAND_PLUGIN,
59
+ syncJsonHeavyCommandHook,
60
+ syncOpencodeHeavyCommandPlugin,
61
+ } from '../heavy-command-hook-install.js';
62
+
63
+ let dir: string;
64
+ let bin: string;
65
+ const saved: Record<string, string | undefined> = {};
66
+ const ENV_KEYS = [
67
+ 'HOME',
68
+ 'USERPROFILE',
69
+ 'CLAUDE_HOME',
70
+ 'CLAUDE_SETTINGS',
71
+ 'CLEO_HOME',
72
+ 'PATH',
73
+ ] as const;
74
+
75
+ beforeEach(() => {
76
+ dir = mkdtempSync(join(tmpdir(), 'cleo-heavy-hook-install-'));
77
+ bin = join(dir, 'bin');
78
+ mkdirSync(bin);
79
+ for (const key of ENV_KEYS) saved[key] = process.env[key];
80
+ const home = join(dir, 'home');
81
+ mkdirSync(join(home, '.cleo', 'templates'), { recursive: true });
82
+ writeFileSync(join(home, '.cleo', 'templates', 'CLEO-INJECTION.md'), 'fixture\n');
83
+ process.env.CLEO_HOME = join(home, '.cleo');
84
+ process.env.HOME = home;
85
+ process.env.USERPROFILE = home;
86
+ process.env.CLAUDE_HOME = join(home, '.claude');
87
+ delete process.env.CLAUDE_SETTINGS;
88
+ });
89
+ afterEach(() => {
90
+ for (const key of ENV_KEYS) {
91
+ if (saved[key] === undefined) delete process.env[key];
92
+ else process.env[key] = saved[key];
93
+ }
94
+ rmSync(dir, { recursive: true, force: true });
95
+ });
96
+
97
+ /** A fake `cleo` on PATH running `script` (a POSIX sh body). */
98
+ function fakeCleo(script: string): void {
99
+ writeFileSync(join(bin, 'cleo'), `#!/bin/sh\n${script}\n`);
100
+ chmodSync(join(bin, 'cleo'), 0o755);
101
+ }
102
+
103
+ /**
104
+ * Shells to run the hook line under: Claude Code uses `sh -c` (bash on macOS,
105
+ * dash on Debian/Ubuntu), Codex the user's login shell (often zsh).
106
+ */
107
+ const SHELLS = ['/bin/sh', '/bin/dash', '/bin/bash', '/bin/zsh'].filter((s) => existsSync(s));
108
+
109
+ /**
110
+ * Run a hook command line the way a harness does: `<shell> -c`, JSON on
111
+ * stdin. `TMPDIR` (where the older-CLEO marker lives) is the test's temp dir;
112
+ * a fake `cleo` appends to `$CALLS` each time it starts.
113
+ */
114
+ function runHookLine(
115
+ line: string,
116
+ stdin: string,
117
+ shell = '/bin/sh',
118
+ cwd = dir,
119
+ ): { stdout: string; status: number } {
120
+ try {
121
+ const stdout = execFileSync(shell, ['-c', line], {
122
+ input: stdin,
123
+ cwd,
124
+ encoding: 'utf-8',
125
+ env: { PATH: `${bin}:/usr/bin:/bin`, TMPDIR: dir, CALLS: join(dir, 'calls') },
126
+ });
127
+ return { stdout, status: 0 };
128
+ } catch (err) {
129
+ const e = err as { stdout?: string; status?: number };
130
+ return { stdout: e.stdout ?? '', status: e.status ?? -1 };
131
+ }
132
+ }
133
+
134
+ /** A shell-tool payload as Claude Code sends it. */
135
+ const payload = (command: string, description = 'run it'): string =>
136
+ JSON.stringify({ tool_name: 'Bash', tool_input: { command, description } });
137
+
138
+ /** How many times the fake `cleo` started. */
139
+ const calls = (): number =>
140
+ existsSync(join(dir, 'calls'))
141
+ ? readFileSync(join(dir, 'calls'), 'utf-8').split('\n').length - 1
142
+ : 0;
143
+
144
+ describe.each(SHELLS)('heavyCommandHookCommand under %s', (shell) => {
145
+ const run = (line: string, stdin: string, cwd = dir) => runHookLine(line, stdin, shell, cwd);
146
+
147
+ it('starts no cleo when no tool name appears as a whole word', () => {
148
+ fakeCleo('echo x >> "$CALLS"; cat');
149
+ for (const command of [
150
+ 'ls -la',
151
+ 'cat test-notes.md',
152
+ 'git commit -m "fix the build and install steps"',
153
+ 'grep -rn testing src',
154
+ 'cleo show T1',
155
+ ]) {
156
+ expect(run(heavyCommandHookCommand('claude-code'), payload(command, 'x')), command).toEqual({
157
+ stdout: '',
158
+ status: 0,
159
+ });
160
+ }
161
+ expect(calls()).toBe(0);
162
+ });
163
+
164
+ it('passes a call naming a tool to cleo hook heavy-command and prints its JSON answer', () => {
165
+ fakeCleo(
166
+ 'echo x >> "$CALLS"\n[ "$1 $2 $3 $4" = "hook heavy-command --provider codex" ] || exit 9\ncat',
167
+ );
168
+ for (const command of [
169
+ 'pnpm test',
170
+ './node_modules/.bin/vitest run a.test.ts',
171
+ 'echo \'"quoted"\' && cargo build',
172
+ 'cd pkg\nnpx tsc --noEmit',
173
+ ]) {
174
+ const stdin = payload(command);
175
+ expect(run(heavyCommandHookCommand('codex'), stdin), command).toEqual({
176
+ stdout: `${stdin}\n`,
177
+ status: 0,
178
+ });
179
+ }
180
+ expect(calls()).toBe(4);
181
+ });
182
+
183
+ it('sends every kind of heavy command run-class knows on to cleo', () => {
184
+ fakeCleo('echo x >> "$CALLS"; cat > /dev/null');
185
+ const heavy = [
186
+ 'vitest run',
187
+ 'npx jest',
188
+ 'pnpm exec tsc -b',
189
+ 'turbo run build',
190
+ 'nx run-many -t build',
191
+ 'cargo test',
192
+ 'go test ./...',
193
+ 'pytest -x',
194
+ 'bun test',
195
+ 'yarn build',
196
+ 'npm ci',
197
+ 'biome check .',
198
+ 'eslint .',
199
+ 'next build',
200
+ 'vite build',
201
+ 'webpack',
202
+ 'rollup -c',
203
+ 'esbuild src/x.ts',
204
+ 'tsup',
205
+ 'playwright test',
206
+ 'mocha',
207
+ 'svelte-check',
208
+ 'ava',
209
+ 'tap',
210
+ 'rspec',
211
+ 'phpunit',
212
+ 'pnpx vitest',
213
+ 'bunx tsc',
214
+ ];
215
+ for (const command of heavy) run(heavyCommandHookCommand('claude-code'), payload(command, 'x'));
216
+ expect(calls()).toBe(heavy.length);
217
+ });
218
+
219
+ it('exits 0 and prints nothing when cleo is missing', () => {
220
+ expect(run(heavyCommandHookCommand('claude-code'), payload('pnpm test'))).toEqual({
221
+ stdout: '',
222
+ status: 0,
223
+ });
224
+ });
225
+
226
+ it('an older cleo is asked once; later calls start no cleo until the binary changes', () => {
227
+ // What 2026.10.1 does with `cleo hook`: "Unknown command hook" on stderr, exit 127.
228
+ fakeCleo('echo x >> "$CALLS"\ncat > /dev/null\necho "Unknown command hook" >&2\nexit 127');
229
+ const heavy = payload('pnpm vitest run a.test.ts');
230
+ for (let i = 0; i < 3; i++) {
231
+ const r = run(heavyCommandHookCommand('claude-code'), heavy);
232
+ expect(r.status).toBe(0);
233
+ expect(JSON.parse(r.stdout)).toEqual(JSON.parse(OLDER_CLEO_HOOK_ANSWER));
234
+ }
235
+ expect(calls()).toBe(1);
236
+ // A call with no tool word stays silent and free, even for an older cleo.
237
+ expect(run(heavyCommandHookCommand('claude-code'), payload('ls')).stdout).toBe('');
238
+ // Reinstalling cleo (a binary newer than the marker) invalidates the marker.
239
+ const marker = () => readdirSync(dir).filter((n) => n.startsWith(OLDER_CLEO_MARKER_PREFIX));
240
+ const now = Date.now() / 1000;
241
+ for (const m of marker()) utimesSync(join(dir, m), now - 120, now - 120);
242
+ utimesSync(join(bin, 'cleo'), now - 60, now - 60);
243
+ run(heavyCommandHookCommand('claude-code'), heavy);
244
+ expect(calls()).toBe(2);
245
+ run(heavyCommandHookCommand('claude-code'), heavy);
246
+ expect(calls()).toBe(2);
247
+ // Another directory may resolve another cleo (a mise shim): asked again there.
248
+ const other = join(dir, 'other-project');
249
+ mkdirSync(other);
250
+ run(heavyCommandHookCommand('claude-code'), heavy, other);
251
+ expect(calls()).toBe(3);
252
+ // A marker older than an hour lapses.
253
+ const markers = marker();
254
+ expect(markers.length).toBe(2);
255
+ const hoursAgo = Date.now() / 1000 - 2 * 3600;
256
+ for (const m of markers) utimesSync(join(dir, m), hoursAgo, hoursAgo);
257
+ utimesSync(join(bin, 'cleo'), hoursAgo - 60, hoursAgo - 60);
258
+ run(heavyCommandHookCommand('claude-code'), heavy);
259
+ expect(calls()).toBe(4);
260
+ // A newer CLEO installing the hook clears every marker.
261
+ expect(clearOlderCleoMarkers(dir)).toBe(2);
262
+ expect(readdirSync(dir).some((n) => n.startsWith(OLDER_CLEO_MARKER_PREFIX))).toBe(false);
263
+ });
264
+
265
+ it('a current cleo that crashes or is killed fails open and is asked again next time', () => {
266
+ // As jetsam / the OOM killer would: SIGKILL mid-hook (exit 137), or another failure.
267
+ for (const body of [
268
+ 'kill -9 $$',
269
+ 'echo "boom" >&2; exit 1',
270
+ 'echo "env: node: No such file" >&2; exit 127',
271
+ ]) {
272
+ rmSync(join(dir, 'calls'), { force: true });
273
+ fakeCleo(`echo x >> "$CALLS"\ncat > /dev/null\n${body}`);
274
+ for (let i = 0; i < 3; i++) {
275
+ expect(run(heavyCommandHookCommand('claude-code'), payload('pnpm test')), body).toEqual({
276
+ stdout: '',
277
+ status: 0,
278
+ });
279
+ }
280
+ expect(calls(), body).toBe(3);
281
+ expect(
282
+ readdirSync(dir).some((n) => n.startsWith(OLDER_CLEO_MARKER_PREFIX)),
283
+ body,
284
+ ).toBe(false);
285
+ }
286
+ });
287
+
288
+ it('never follows a symlink planted at the marker path', () => {
289
+ fakeCleo('echo x >> "$CALLS"\ncat > /dev/null\necho "Unknown command hook" >&2\nexit 127');
290
+ run(heavyCommandHookCommand('claude-code'), payload('pnpm test'));
291
+ const [name] = readdirSync(dir).filter((n) => n.startsWith(OLDER_CLEO_MARKER_PREFIX));
292
+ expect(name).toBeDefined();
293
+ const marker = join(dir, name as string);
294
+ const victim = join(dir, 'victim.txt');
295
+ writeFileSync(victim, 'keep me\n');
296
+ rmSync(marker);
297
+ symlinkSync(victim, marker);
298
+ // A symlink is never taken as the cache, and replacing it leaves its target alone.
299
+ run(heavyCommandHookCommand('claude-code'), payload('pnpm test'));
300
+ expect(calls()).toBe(2);
301
+ expect(readFileSync(victim, 'utf-8')).toBe('keep me\n');
302
+ expect(lstatSync(marker).isSymbolicLink()).toBe(false);
303
+ });
304
+
305
+ it('matches a backslash with [\\] rather than \\\\ (fish keeps \\\\ in single quotes)', () => {
306
+ for (const provider of ['claude-code', 'codex'] as const) {
307
+ const line = heavyCommandHookCommand(provider);
308
+ expect(line).toContain('s/[\\][nrt]/ /g');
309
+ expect(line).not.toContain('\\\\');
310
+ }
311
+ });
312
+
313
+ it('never forwards output that is not a JSON object', () => {
314
+ fakeCleo('cat > /dev/null\necho "cleo 2026.9.1"');
315
+ expect(run(heavyCommandHookCommand('claude-code'), payload('pnpm test'))).toEqual({
316
+ stdout: '',
317
+ status: 0,
318
+ });
319
+ });
320
+
321
+ it('carries the cleo-hook marker; the Codex line hands the script to /bin/sh', () => {
322
+ expect(heavyCommandHookCommand('claude-code')).toMatch(/^command -v cleo .* # cleo-hook$/);
323
+ expect(heavyCommandHookCommand('codex')).toMatch(
324
+ /^\/bin\/sh -c 'command -v cleo .*' # cleo-hook$/,
325
+ );
326
+ });
327
+ });
328
+
329
+ describe('syncJsonHeavyCommandHook', () => {
330
+ const read = (p: string) => JSON.parse(readFileSync(p, 'utf-8'));
331
+
332
+ it('creates the file with one PreToolUse/Bash entry, then is idempotent', async () => {
333
+ const path = join(dir, 'project', '.claude', 'settings.json');
334
+ expect(await syncJsonHeavyCommandHook(path, 'claude-code', 'rewrite')).toBe('installed');
335
+ const before = readFileSync(path, 'utf-8');
336
+ expect(read(path)).toEqual({ hooks: { PreToolUse: [heavyCommandHookEntry('claude-code')] } });
337
+ expect(read(path).hooks.PreToolUse[0].hooks[0].timeout).toBe(HEAVY_COMMAND_HOOK_TIMEOUT_SEC);
338
+ expect(await syncJsonHeavyCommandHook(path, 'claude-code', 'warn')).toBe('unchanged');
339
+ expect(readFileSync(path, 'utf-8')).toBe(before);
340
+ });
341
+
342
+ it('keeps user settings and hooks, and refreshes a stale CLEO entry in place', async () => {
343
+ const path = join(dir, 'hooks.json');
344
+ const user = { matcher: 'Bash', hooks: [{ type: 'command', command: './guard.sh' }] };
345
+ const stale = {
346
+ matcher: 'Bash',
347
+ hooks: [{ type: 'command', command: 'cleo hook heavy-command # cleo-hook' }],
348
+ };
349
+ writeFileSync(path, JSON.stringify({ model: 'x', hooks: { PreToolUse: [user, stale] } }));
350
+ expect(await syncJsonHeavyCommandHook(path, 'codex', 'rewrite')).toBe('updated');
351
+ expect(read(path)).toEqual({
352
+ model: 'x',
353
+ hooks: { PreToolUse: [user, heavyCommandHookEntry('codex')] },
354
+ });
355
+ });
356
+
357
+ it('off removes only the heavy-command entry', async () => {
358
+ const path = join(dir, 'settings.json');
359
+ const user = { matcher: 'Bash', hooks: [{ type: 'command', command: './guard.sh' }] };
360
+ const precompact = {
361
+ matcher: '',
362
+ hooks: [{ type: 'command', command: '"/x/precompact-safestop.sh" # cleo-hook' }],
363
+ };
364
+ writeFileSync(
365
+ path,
366
+ JSON.stringify({
367
+ hooks: {
368
+ PreToolUse: [user, heavyCommandHookEntry('claude-code')],
369
+ PreCompact: [precompact],
370
+ },
371
+ }),
372
+ );
373
+ expect(await syncJsonHeavyCommandHook(path, 'claude-code', 'off')).toBe('removed');
374
+ expect(read(path)).toEqual({ hooks: { PreToolUse: [user], PreCompact: [precompact] } });
375
+ expect(await syncJsonHeavyCommandHook(path, 'claude-code', 'off')).toBe('unchanged');
376
+ expect(await syncJsonHeavyCommandHook(join(dir, 'missing.json'), 'codex', 'off')).toBe(
377
+ 'unchanged',
378
+ );
379
+ expect(existsSync(join(dir, 'missing.json'))).toBe(false);
380
+ });
381
+
382
+ it('keeps a user hook that shares the matcher group with CLEO on refresh and off', async () => {
383
+ const path = join(dir, 'settings.local.json');
384
+ const userHook = { type: 'command', command: './audit.sh' };
385
+ const staleCleo = { type: 'command', command: 'cleo hook heavy-command # cleo-hook' };
386
+ writeFileSync(
387
+ path,
388
+ JSON.stringify({
389
+ hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [userHook, staleCleo] }] },
390
+ }),
391
+ );
392
+ expect(await syncJsonHeavyCommandHook(path, 'claude-code', 'rewrite')).toBe('updated');
393
+ expect(read(path).hooks.PreToolUse).toEqual([
394
+ { matcher: 'Bash', hooks: [userHook, heavyCommandHookObject('claude-code')] },
395
+ ]);
396
+ expect(await syncJsonHeavyCommandHook(path, 'claude-code', 'rewrite')).toBe('unchanged');
397
+ expect(await syncJsonHeavyCommandHook(path, 'claude-code', 'off')).toBe('removed');
398
+ expect(read(path).hooks.PreToolUse).toEqual([{ matcher: 'Bash', hooks: [userHook] }]);
399
+ });
400
+
401
+ it('drops duplicate CLEO hooks and keeps one', async () => {
402
+ const path = join(dir, 'hooks.json');
403
+ const dup = heavyCommandHookEntry('codex');
404
+ writeFileSync(path, JSON.stringify({ hooks: { PreToolUse: [dup, dup] } }));
405
+ expect(await syncJsonHeavyCommandHook(path, 'codex', 'rewrite')).toBe('updated');
406
+ expect(read(path).hooks.PreToolUse).toEqual([heavyCommandHookEntry('codex')]);
407
+ });
408
+
409
+ it('refuses a malformed file and leaves it untouched', async () => {
410
+ const path = join(dir, 'settings.json');
411
+ writeFileSync(path, '{ not json');
412
+ await expect(syncJsonHeavyCommandHook(path, 'claude-code', 'rewrite')).rejects.toThrow();
413
+ expect(readFileSync(path, 'utf-8')).toBe('{ not json');
414
+ });
415
+ });
416
+
417
+ describe('syncOpencodeHeavyCommandPlugin', () => {
418
+ it('writes the plugin once, and off deletes it', () => {
419
+ const project = join(dir, 'project');
420
+ const plugin = join(project, '.opencode', 'plugins', OPENCODE_HEAVY_COMMAND_PLUGIN);
421
+ expect(syncOpencodeHeavyCommandPlugin(project, 'rewrite')).toBe('installed');
422
+ expect(syncOpencodeHeavyCommandPlugin(project, 'rewrite')).toBe('unchanged');
423
+ writeFileSync(plugin, '// edited');
424
+ expect(syncOpencodeHeavyCommandPlugin(project, 'warn')).toBe('updated');
425
+ expect(syncOpencodeHeavyCommandPlugin(project, 'off')).toBe('removed');
426
+ expect(existsSync(plugin)).toBe(false);
427
+ expect(syncOpencodeHeavyCommandPlugin(project, 'off')).toBe('unchanged');
428
+ });
429
+
430
+ it('generates a module that applies the rewrite and timeout, and appends the context', async () => {
431
+ const project = join(dir, 'project');
432
+ syncOpencodeHeavyCommandPlugin(project, 'rewrite');
433
+ const mjs = join(dir, 'plugin.mjs');
434
+ copyFileSync(join(project, '.opencode', 'plugins', OPENCODE_HEAVY_COMMAND_PLUGIN), mjs);
435
+ const answer = {
436
+ command: 'cleo run --wait --passthrough --class test -- pnpm test',
437
+ timeout: 240_000,
438
+ context: '[cleo] routed',
439
+ };
440
+ fakeCleo(
441
+ `echo x >> "${join(dir, 'calls')}"\ncat > /dev/null\necho '${JSON.stringify(answer)}'`,
442
+ );
443
+ process.env.PATH = `${bin}:/usr/bin:/bin`;
444
+ type Input = { tool: string; callID?: string };
445
+ const mod = (await import(pathToFileURL(mjs).href)) as {
446
+ CleoHeavyCommand: (ctx: { directory: string }) => Promise<{
447
+ 'tool.execute.before': (
448
+ input: Input,
449
+ output: { args: { command: string; timeout?: number } },
450
+ ) => Promise<void>;
451
+ 'tool.execute.after': (input: Input, output: { output: string }) => Promise<void>;
452
+ }>;
453
+ };
454
+ const hooks = await mod.CleoHeavyCommand({ directory: project });
455
+ const output: { args: { command: string; timeout?: number } } = {
456
+ args: { command: 'pnpm test' },
457
+ };
458
+ await hooks['tool.execute.before']({ tool: 'bash', callID: 'c1' }, output);
459
+ expect(output.args).toEqual({ command: answer.command, timeout: 240_000 });
460
+ const result = { output: 'tests passed' };
461
+ await hooks['tool.execute.after']({ tool: 'bash', callID: 'c1' }, result);
462
+ expect(result.output).toBe('tests passed\n\n[cleo] routed');
463
+ // Not the shell tool, or no tool word: cleo is never started.
464
+ const other = { args: { command: 'pnpm test' } };
465
+ await hooks['tool.execute.before']({ tool: 'read', callID: 'c2' }, other);
466
+ const light = { args: { command: 'cat test-notes.md' } };
467
+ await hooks['tool.execute.before']({ tool: 'bash', callID: 'c3' }, light);
468
+ expect([other.args.command, light.args.command]).toEqual(['pnpm test', 'cat test-notes.md']);
469
+ expect(calls()).toBe(1);
470
+ });
471
+ });
472
+
473
+ describe('installer wiring', () => {
474
+ it('Claude Code writes the project settings.local.json, never settings.json or the user settings', async () => {
475
+ const project = join(dir, 'project');
476
+ mkdirSync(join(project, '.claude'), { recursive: true });
477
+ // An earlier build wrote the hook into the shared settings.json; a user hook sits beside it.
478
+ const userGroup = { matcher: 'Bash', hooks: [{ type: 'command', command: './lint.sh' }] };
479
+ writeFileSync(
480
+ join(project, '.claude', 'settings.json'),
481
+ JSON.stringify({ hooks: { PreToolUse: [userGroup, heavyCommandHookEntry('claude-code')] } }),
482
+ );
483
+ const result = await new ClaudeCodeInstallProvider().install({
484
+ projectDir: project,
485
+ heavyCommandHook: 'rewrite',
486
+ });
487
+ expect(result.details?.heavyCommandHook).toBe('installed');
488
+ const local = JSON.parse(
489
+ readFileSync(join(project, '.claude', 'settings.local.json'), 'utf-8'),
490
+ );
491
+ expect(local.hooks.PreToolUse).toEqual([heavyCommandHookEntry('claude-code')]);
492
+ const shared = JSON.parse(readFileSync(join(project, '.claude', 'settings.json'), 'utf-8'));
493
+ expect(shared.hooks.PreToolUse).toEqual([userGroup]);
494
+ const userSettings = join(process.env.CLAUDE_HOME as string, 'settings.json');
495
+ const user = existsSync(userSettings) ? readFileSync(userSettings, 'utf-8') : '';
496
+ expect(user).not.toContain('heavy-command');
497
+ });
498
+
499
+ it('keeps a CLEO-written settings.local.json out of git, and undoes only its own block', async () => {
500
+ const project = join(dir, 'repo');
501
+ mkdirSync(project);
502
+ execFileSync('git', ['init', '-q'], { cwd: project });
503
+ const ignored = () => {
504
+ try {
505
+ execFileSync('git', ['check-ignore', '-q', '.claude/settings.local.json'], {
506
+ cwd: project,
507
+ });
508
+ return true;
509
+ } catch {
510
+ return false;
511
+ }
512
+ };
513
+ const exclude = join(project, '.git', 'info', 'exclude');
514
+ expect(ignored()).toBe(false);
515
+ for (let i = 0; i < 2; i++) {
516
+ await new ClaudeCodeInstallProvider().install({
517
+ projectDir: project,
518
+ heavyCommandHook: 'rewrite',
519
+ });
520
+ }
521
+ expect(ignored()).toBe(true);
522
+ const text = readFileSync(exclude, 'utf-8');
523
+ expect(text.split(LOCAL_SETTINGS_EXCLUDE_MARKER).length - 1).toBe(1);
524
+ expect(text).toContain('/.claude/settings.local.json');
525
+ await new ClaudeCodeInstallProvider().install({ projectDir: project, heavyCommandHook: 'off' });
526
+ expect(readFileSync(exclude, 'utf-8')).not.toContain(LOCAL_SETTINGS_EXCLUDE_MARKER);
527
+ expect(ignored()).toBe(false);
528
+
529
+ // Already ignored by the project: nothing is added.
530
+ writeFileSync(join(project, '.gitignore'), '.claude/settings.local.json\n');
531
+ expect(excludeLocalSettingsFromGit(project)).toBe(false);
532
+ expect(readFileSync(exclude, 'utf-8')).not.toContain(LOCAL_SETTINGS_EXCLUDE_MARKER);
533
+ // Outside a git work tree: nothing happens.
534
+ const plain = join(dir, 'plain');
535
+ mkdirSync(plain);
536
+ expect(excludeLocalSettingsFromGit(plain)).toBe(false);
537
+ });
538
+
539
+ it('gives a second CLEO project in a subdirectory of the same repository its own line', async () => {
540
+ const repo = join(dir, 'mono');
541
+ const sub = join(repo, 'packages', 'app');
542
+ mkdirSync(sub, { recursive: true });
543
+ execFileSync('git', ['init', '-q'], { cwd: repo });
544
+ const exclude = join(repo, '.git', 'info', 'exclude');
545
+ const lines = () => readFileSync(exclude, 'utf-8').split('\n');
546
+ await new ClaudeCodeInstallProvider().install({
547
+ projectDir: repo,
548
+ heavyCommandHook: 'rewrite',
549
+ });
550
+ await new ClaudeCodeInstallProvider().install({ projectDir: sub, heavyCommandHook: 'rewrite' });
551
+ await new ClaudeCodeInstallProvider().install({ projectDir: sub, heavyCommandHook: 'rewrite' });
552
+ expect(lines().filter((l) => l.endsWith('.claude/settings.local.json'))).toEqual([
553
+ '/.claude/settings.local.json',
554
+ '/packages/app/.claude/settings.local.json',
555
+ ]);
556
+ execFileSync('git', ['check-ignore', '-q', 'packages/app/.claude/settings.local.json'], {
557
+ cwd: repo,
558
+ });
559
+ // `off` in the subdirectory removes only its own block.
560
+ await new ClaudeCodeInstallProvider().install({ projectDir: sub, heavyCommandHook: 'off' });
561
+ expect(lines().filter((l) => l.endsWith('.claude/settings.local.json'))).toEqual([
562
+ '/.claude/settings.local.json',
563
+ ]);
564
+ expect(lines().filter((l) => l === LOCAL_SETTINGS_EXCLUDE_MARKER)).toHaveLength(1);
565
+ });
566
+
567
+ it('skips the home directory, whose provider configs are the user-global ones', async () => {
568
+ const home = process.env.HOME as string;
569
+ // Sequential: Codex and opencode both write AGENTS.md here.
570
+ const results = [
571
+ await new ClaudeCodeInstallProvider().install({
572
+ projectDir: home,
573
+ heavyCommandHook: 'rewrite',
574
+ }),
575
+ await new CodexInstallProvider().install({ projectDir: home, heavyCommandHook: 'rewrite' }),
576
+ await new OpenCodeInstallProvider().install({
577
+ projectDir: home,
578
+ heavyCommandHook: 'rewrite',
579
+ }),
580
+ ];
581
+ expect(results.map((r) => r.details?.heavyCommandHook)).toEqual([
582
+ 'skipped',
583
+ 'skipped',
584
+ 'skipped',
585
+ ]);
586
+ const userSettings = join(home, '.claude', 'settings.json');
587
+ const user = existsSync(userSettings) ? readFileSync(userSettings, 'utf-8') : '';
588
+ expect(user).not.toContain('heavy-command');
589
+ expect(existsSync(join(home, '.codex', 'hooks.json'))).toBe(false);
590
+ expect(existsSync(join(home, '.opencode', 'plugins', OPENCODE_HEAVY_COMMAND_PLUGIN))).toBe(
591
+ false,
592
+ );
593
+ expect(isUserHomeDir(join(home, 'project'))).toBe(false);
594
+ });
595
+
596
+ it('Codex and opencode install only when a mode is passed', async () => {
597
+ const project = join(dir, 'project');
598
+ mkdirSync(project);
599
+ await new CodexInstallProvider().install({ projectDir: project });
600
+ await new OpenCodeInstallProvider().install({ projectDir: project });
601
+ expect(existsSync(join(project, '.codex', 'hooks.json'))).toBe(false);
602
+ expect(existsSync(join(project, '.opencode', 'plugins', OPENCODE_HEAVY_COMMAND_PLUGIN))).toBe(
603
+ false,
604
+ );
605
+
606
+ const codex = await new CodexInstallProvider().install({
607
+ projectDir: project,
608
+ heavyCommandHook: 'rewrite',
609
+ });
610
+ const opencode = await new OpenCodeInstallProvider().install({
611
+ projectDir: project,
612
+ heavyCommandHook: 'rewrite',
613
+ });
614
+ expect([codex.details?.heavyCommandHook, opencode.details?.heavyCommandHook]).toEqual([
615
+ 'installed',
616
+ 'installed',
617
+ ]);
618
+ const hooks = JSON.parse(readFileSync(join(project, '.codex', 'hooks.json'), 'utf-8'));
619
+ expect(hooks.hooks.PreToolUse).toEqual([heavyCommandHookEntry('codex')]);
620
+ });
621
+ });