@cleocode/adapters 2026.10.3 → 2026.10.4
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/dist/heavy-command-hook.d.ts +12 -0
- package/dist/heavy-command-hook.d.ts.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +647 -49
- package/dist/index.js.map +3 -3
- package/dist/providers/codex/install.d.ts.map +1 -1
- package/dist/providers/shared/heavy-command-hook-delivery.d.ts +97 -0
- package/dist/providers/shared/heavy-command-hook-delivery.d.ts.map +1 -0
- package/dist/providers/shared/heavy-command-hook-install.d.ts +153 -7
- package/dist/providers/shared/heavy-command-hook-install.d.ts.map +1 -1
- package/package.json +9 -5
- package/src/heavy-command-hook.ts +21 -0
- package/src/index.ts +12 -0
- package/src/providers/codex/install.ts +3 -4
- package/src/providers/shared/__tests__/heavy-command-hook-delivery.test.ts +580 -0
- package/src/providers/shared/heavy-command-hook-delivery.ts +620 -0
- package/src/providers/shared/heavy-command-hook-install.ts +395 -31
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for delivering the heavy-command hook to every provider a project
|
|
3
|
+
* uses (T13124).
|
|
4
|
+
*
|
|
5
|
+
* Coverage:
|
|
6
|
+
* - providers are independent: a stray `.codex` FILE (live in cleocode on
|
|
7
|
+
* 2026-10-03, where `mkdirSync` threw EEXIST and aborted the Codex install)
|
|
8
|
+
* blocks Codex with a reason and remedy while Claude Code and opencode
|
|
9
|
+
* still install; a malformed settings file fails only its provider
|
|
10
|
+
* - providers not in use are skipped, Kimi is `unsupported` (global config
|
|
11
|
+
* only), the home directory is skipped, mode `off` removes everywhere
|
|
12
|
+
* - inspection: installed, outdated (other build, legacy location, present
|
|
13
|
+
* while off), missing, blocked, unreadable, disabled, not-detected, and a
|
|
14
|
+
* hand-added Kimi entry
|
|
15
|
+
*
|
|
16
|
+
* Every write goes to a temp directory; detection reads only the injected env.
|
|
17
|
+
*
|
|
18
|
+
* @task T13124
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { execFileSync } from 'node:child_process';
|
|
22
|
+
import {
|
|
23
|
+
existsSync,
|
|
24
|
+
mkdirSync,
|
|
25
|
+
mkdtempSync,
|
|
26
|
+
readFileSync,
|
|
27
|
+
rmSync,
|
|
28
|
+
utimesSync,
|
|
29
|
+
writeFileSync,
|
|
30
|
+
} from 'node:fs';
|
|
31
|
+
import { tmpdir } from 'node:os';
|
|
32
|
+
import { join } from 'node:path';
|
|
33
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
34
|
+
import {
|
|
35
|
+
detectHeavyHookProvider,
|
|
36
|
+
inspectProjectHeavyCommandHooks,
|
|
37
|
+
probeHeavyHookCli,
|
|
38
|
+
syncProjectHeavyCommandHooks,
|
|
39
|
+
} from '../heavy-command-hook-delivery.js';
|
|
40
|
+
import { heavyCommandHookEntry, OLDER_CLEO_MARKER_PREFIX } from '../heavy-command-hook-install.js';
|
|
41
|
+
|
|
42
|
+
let dir: string;
|
|
43
|
+
let project: string;
|
|
44
|
+
let home: string;
|
|
45
|
+
let env: Record<string, string>;
|
|
46
|
+
const savedTmp = process.env.TMPDIR;
|
|
47
|
+
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
dir = mkdtempSync(join(tmpdir(), 'cleo-heavy-hook-delivery-'));
|
|
50
|
+
project = join(dir, 'project');
|
|
51
|
+
home = join(dir, 'home');
|
|
52
|
+
mkdirSync(project);
|
|
53
|
+
mkdirSync(home);
|
|
54
|
+
// An empty PATH dir: no harness CLI is found unless a test adds one.
|
|
55
|
+
mkdirSync(join(dir, 'bin'));
|
|
56
|
+
env = { HOME: home, PATH: join(dir, 'bin') };
|
|
57
|
+
// The older-CLEO marker sweep runs in $TMPDIR; keep it inside the sandbox.
|
|
58
|
+
process.env.TMPDIR = dir;
|
|
59
|
+
});
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
if (savedTmp === undefined) delete process.env.TMPDIR;
|
|
62
|
+
else process.env.TMPDIR = savedTmp;
|
|
63
|
+
rmSync(dir, { recursive: true, force: true });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const status = (outcomes: readonly { provider: string; status: string }[]) =>
|
|
67
|
+
Object.fromEntries(outcomes.map((o) => [o.provider, o.status]));
|
|
68
|
+
|
|
69
|
+
function useAll(): void {
|
|
70
|
+
mkdirSync(join(project, '.claude'));
|
|
71
|
+
mkdirSync(join(home, '.codex'));
|
|
72
|
+
mkdirSync(join(project, '.opencode'));
|
|
73
|
+
mkdirSync(join(home, '.kimi'));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe('syncProjectHeavyCommandHooks', () => {
|
|
77
|
+
it('installs for every provider in use, each on its own, then is idempotent', async () => {
|
|
78
|
+
useAll();
|
|
79
|
+
const first = await syncProjectHeavyCommandHooks(project, 'rewrite', { env });
|
|
80
|
+
expect(status(first)).toEqual({
|
|
81
|
+
'claude-code': 'installed',
|
|
82
|
+
codex: 'installed',
|
|
83
|
+
opencode: 'installed',
|
|
84
|
+
kimi: 'unsupported',
|
|
85
|
+
});
|
|
86
|
+
expect(first.find((o) => o.provider === 'kimi')?.remedy).toMatch(/\[\[hooks\]\]/);
|
|
87
|
+
expect(existsSync(join(project, '.claude', 'settings.local.json'))).toBe(true);
|
|
88
|
+
expect(existsSync(join(project, '.codex', 'hooks.json'))).toBe(true);
|
|
89
|
+
expect(existsSync(join(project, '.opencode', 'plugins', 'cleo-heavy-command.js'))).toBe(true);
|
|
90
|
+
const again = await syncProjectHeavyCommandHooks(project, 'rewrite', { env });
|
|
91
|
+
expect(status(again)).toEqual({
|
|
92
|
+
'claude-code': 'unchanged',
|
|
93
|
+
codex: 'unchanged',
|
|
94
|
+
opencode: 'unchanged',
|
|
95
|
+
kimi: 'unsupported',
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('a .codex FILE blocks only Codex, with the reason and remedy; the others install', async () => {
|
|
100
|
+
useAll();
|
|
101
|
+
writeFileSync(join(project, '.codex'), '');
|
|
102
|
+
const outcomes = await syncProjectHeavyCommandHooks(project, 'rewrite', { env });
|
|
103
|
+
expect(status(outcomes)).toMatchObject({
|
|
104
|
+
'claude-code': 'installed',
|
|
105
|
+
codex: 'blocked',
|
|
106
|
+
opencode: 'installed',
|
|
107
|
+
});
|
|
108
|
+
const codex = outcomes.find((o) => o.provider === 'codex');
|
|
109
|
+
expect(codex?.reason).toMatch(/\.codex exists but is not a directory/);
|
|
110
|
+
expect(codex?.remedy).toMatch(
|
|
111
|
+
/remove or rename .*\.codex.*cleo doctor heavy-command-hook --fix/,
|
|
112
|
+
);
|
|
113
|
+
// The file is left exactly as it was.
|
|
114
|
+
expect(readFileSync(join(project, '.codex'), 'utf-8')).toBe('');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('a malformed settings file fails only Claude Code and is left untouched', async () => {
|
|
118
|
+
useAll();
|
|
119
|
+
const local = join(project, '.claude', 'settings.local.json');
|
|
120
|
+
writeFileSync(local, '{ "permissions": ');
|
|
121
|
+
const outcomes = await syncProjectHeavyCommandHooks(project, 'rewrite', { env });
|
|
122
|
+
expect(status(outcomes)).toMatchObject({
|
|
123
|
+
'claude-code': 'failed',
|
|
124
|
+
codex: 'installed',
|
|
125
|
+
opencode: 'installed',
|
|
126
|
+
});
|
|
127
|
+
const claude = outcomes.find((o) => o.provider === 'claude-code');
|
|
128
|
+
expect(claude?.remedy).toMatch(/fix the JSON in .*settings\.local\.json/);
|
|
129
|
+
expect(readFileSync(local, 'utf-8')).toBe('{ "permissions": ');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('a .claude FILE blocks Claude Code instead of throwing', async () => {
|
|
133
|
+
writeFileSync(join(project, '.claude'), 'not a dir');
|
|
134
|
+
const outcomes = await syncProjectHeavyCommandHooks(project, 'rewrite', {
|
|
135
|
+
env,
|
|
136
|
+
providers: ['claude-code'],
|
|
137
|
+
});
|
|
138
|
+
expect(status(outcomes)).toEqual({ 'claude-code': 'blocked' });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('skips providers not in use, and skips everything in the home directory', async () => {
|
|
142
|
+
const none = await syncProjectHeavyCommandHooks(project, 'rewrite', { env });
|
|
143
|
+
expect(status(none)).toEqual({
|
|
144
|
+
'claude-code': 'skipped',
|
|
145
|
+
codex: 'skipped',
|
|
146
|
+
opencode: 'skipped',
|
|
147
|
+
kimi: 'skipped',
|
|
148
|
+
});
|
|
149
|
+
expect(existsSync(join(project, '.claude'))).toBe(false);
|
|
150
|
+
mkdirSync(join(home, '.claude'));
|
|
151
|
+
const atHome = await syncProjectHeavyCommandHooks(home, 'rewrite', { env });
|
|
152
|
+
expect(atHome.every((o) => o.status === 'skipped')).toBe(true);
|
|
153
|
+
expect(existsSync(join(home, '.claude', 'settings.local.json'))).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('mode off removes the hook from every provider, in use or not', async () => {
|
|
157
|
+
useAll();
|
|
158
|
+
await syncProjectHeavyCommandHooks(project, 'rewrite', { env });
|
|
159
|
+
rmSync(join(home, '.codex'), { recursive: true });
|
|
160
|
+
const off = await syncProjectHeavyCommandHooks(project, 'off', { env });
|
|
161
|
+
expect(status(off)).toEqual({
|
|
162
|
+
'claude-code': 'removed',
|
|
163
|
+
codex: 'removed',
|
|
164
|
+
opencode: 'removed',
|
|
165
|
+
kimi: 'unchanged',
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe('detectHeavyHookProvider', () => {
|
|
171
|
+
it('reads project dirs, PATH, user config dirs and the environment', () => {
|
|
172
|
+
expect(detectHeavyHookProvider('claude-code', project, env).detected).toBe(false);
|
|
173
|
+
expect(
|
|
174
|
+
detectHeavyHookProvider('claude-code', project, { ...env, CLAUDECODE: '1' }).why,
|
|
175
|
+
).toMatch(/inside Claude Code/);
|
|
176
|
+
writeFileSync(join(dir, 'bin', 'codex'), '#!/bin/sh\n', { mode: 0o755 });
|
|
177
|
+
expect(detectHeavyHookProvider('codex', project, env).why).toMatch(/`codex` is on PATH/);
|
|
178
|
+
writeFileSync(join(project, 'opencode.json'), '{}');
|
|
179
|
+
expect(detectHeavyHookProvider('opencode', project, env).detected).toBe(true);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe('inspectProjectHeavyCommandHooks', () => {
|
|
184
|
+
const state = (mode: 'rewrite' | 'warn' | 'off' = 'rewrite') =>
|
|
185
|
+
Object.fromEntries(
|
|
186
|
+
inspectProjectHeavyCommandHooks(project, mode, { env }).map((i) => [i.provider, i.state]),
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
it('reports missing for a provider in use, not-detected otherwise, installed after a sync', async () => {
|
|
190
|
+
mkdirSync(join(project, '.claude'));
|
|
191
|
+
expect(state()).toEqual({
|
|
192
|
+
'claude-code': 'missing',
|
|
193
|
+
codex: 'not-detected',
|
|
194
|
+
opencode: 'not-detected',
|
|
195
|
+
kimi: 'not-detected',
|
|
196
|
+
});
|
|
197
|
+
const missing = inspectProjectHeavyCommandHooks(project, 'rewrite', { env })[0];
|
|
198
|
+
expect(missing?.remedy).toBe('run: cleo doctor heavy-command-hook --fix');
|
|
199
|
+
await syncProjectHeavyCommandHooks(project, 'rewrite', { env });
|
|
200
|
+
expect(state()['claude-code']).toBe('installed');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('reports outdated for another build, a legacy location, or a hook left while off', async () => {
|
|
204
|
+
mkdirSync(join(project, '.claude'));
|
|
205
|
+
const local = join(project, '.claude', 'settings.local.json');
|
|
206
|
+
const stale = heavyCommandHookEntry('claude-code');
|
|
207
|
+
const hooks = stale.hooks as Array<Record<string, unknown>>;
|
|
208
|
+
hooks[0] = { ...hooks[0], timeout: 1 };
|
|
209
|
+
writeFileSync(local, JSON.stringify({ hooks: { PreToolUse: [stale] } }));
|
|
210
|
+
expect(state()['claude-code']).toBe('outdated');
|
|
211
|
+
rmSync(local);
|
|
212
|
+
writeFileSync(
|
|
213
|
+
join(project, '.claude', 'settings.json'),
|
|
214
|
+
JSON.stringify({ hooks: { PreToolUse: [heavyCommandHookEntry('claude-code')] } }),
|
|
215
|
+
);
|
|
216
|
+
expect(state()['claude-code']).toBe('outdated');
|
|
217
|
+
rmSync(join(project, '.claude', 'settings.json'));
|
|
218
|
+
await syncProjectHeavyCommandHooks(project, 'rewrite', { env });
|
|
219
|
+
expect(state('off')['claude-code']).toBe('outdated');
|
|
220
|
+
await syncProjectHeavyCommandHooks(project, 'off', { env });
|
|
221
|
+
expect(state('off')['claude-code']).toBe('disabled');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('reports blocked and unreadable with the exact remedy', () => {
|
|
225
|
+
mkdirSync(join(home, '.codex'));
|
|
226
|
+
writeFileSync(join(project, '.codex'), '');
|
|
227
|
+
const codex = inspectProjectHeavyCommandHooks(project, 'rewrite', { env }).find(
|
|
228
|
+
(i) => i.provider === 'codex',
|
|
229
|
+
);
|
|
230
|
+
expect(codex?.state).toBe('blocked');
|
|
231
|
+
expect(codex?.remedy).toMatch(/remove or rename .*\.codex/);
|
|
232
|
+
mkdirSync(join(project, '.claude'));
|
|
233
|
+
writeFileSync(join(project, '.claude', 'settings.local.json'), '[');
|
|
234
|
+
expect(state()['claude-code']).toBe('unreadable');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('Kimi: unsupported unless the user added the entry to ~/.kimi/config.toml', () => {
|
|
238
|
+
mkdirSync(join(home, '.kimi'));
|
|
239
|
+
expect(state().kimi).toBe('unsupported');
|
|
240
|
+
writeFileSync(
|
|
241
|
+
join(home, '.kimi', 'config.toml'),
|
|
242
|
+
'[[hooks]]\nevent = "PreToolUse"\ncommand = "cleo hook heavy-command --provider kimi"\n',
|
|
243
|
+
);
|
|
244
|
+
expect(state().kimi).toBe('installed');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('never writes anything', () => {
|
|
248
|
+
useAll();
|
|
249
|
+
inspectProjectHeavyCommandHooks(project, 'rewrite', { env });
|
|
250
|
+
expect(existsSync(join(project, '.claude', 'settings.local.json'))).toBe(false);
|
|
251
|
+
expect(existsSync(join(project, '.codex'))).toBe(false);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe('probeHeavyHookCli', () => {
|
|
256
|
+
/** A fake `cleo` on the test PATH running `body`. */
|
|
257
|
+
const fakeCleo = (body: string): string => {
|
|
258
|
+
const path = join(dir, 'bin', 'cleo');
|
|
259
|
+
writeFileSync(path, `#!/bin/sh\n${body}\n`, { mode: 0o755 });
|
|
260
|
+
return path;
|
|
261
|
+
};
|
|
262
|
+
const probeEnv = () => ({ ...env, PATH: `${join(dir, 'bin')}:/usr/bin:/bin`, TMPDIR: dir });
|
|
263
|
+
|
|
264
|
+
it('reports a missing cleo', () => {
|
|
265
|
+
expect(probeHeavyHookCli(project, { env }).state).toBe('missing');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('reports a cleo that answers the hook as current', () => {
|
|
269
|
+
fakeCleo('cat >/dev/null; exit 0');
|
|
270
|
+
expect(probeHeavyHookCli(project, { env: probeEnv() }).state).toBe('current');
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('reports a cleo without `cleo hook` as older, with the remedy', () => {
|
|
274
|
+
const path = fakeCleo('echo "Unknown command hook" >&2; exit 127');
|
|
275
|
+
const probe = probeHeavyHookCli(project, { env: probeEnv() });
|
|
276
|
+
expect(probe.state).toBe('older');
|
|
277
|
+
expect(probe.path).toBe(path);
|
|
278
|
+
expect(probe.remedy).toMatch(/upgrade the cleo at .*cleo doctor heavy-command-hook --fix/);
|
|
279
|
+
// Any other failure is unknown, not older.
|
|
280
|
+
fakeCleo('exit 3');
|
|
281
|
+
const broken = probeHeavyHookCli(project, { env: probeEnv() });
|
|
282
|
+
expect(broken.state).toBe('unknown');
|
|
283
|
+
expect(broken.detail).toMatch(/exit 3.*fails open/);
|
|
284
|
+
expect(broken.remedy).toMatch(/hook heavy-command < \/dev\/null/);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("trusts the hook's own marker for this binary and directory, within its hour", () => {
|
|
288
|
+
// This cleo would answer, but the hook recorded that it did not.
|
|
289
|
+
const path = fakeCleo('cat >/dev/null; exit 0');
|
|
290
|
+
const key = execFileSync(
|
|
291
|
+
'/bin/sh',
|
|
292
|
+
['-c', `printf '%s|%s' "$1" "$2" | cksum | tr -c '0-9' '-'`, 'sh', path, project],
|
|
293
|
+
{ encoding: 'utf-8' },
|
|
294
|
+
);
|
|
295
|
+
const marker = join(dir, `${OLDER_CLEO_MARKER_PREFIX}${key}`);
|
|
296
|
+
writeFileSync(marker, '');
|
|
297
|
+
const old = new Date(Date.now() - 10_000);
|
|
298
|
+
utimesSync(path, old, old);
|
|
299
|
+
expect(probeHeavyHookCli(project, { env: probeEnv() }).state).toBe('older');
|
|
300
|
+
// A marker older than the binary (CLEO was upgraded since) is ignored.
|
|
301
|
+
utimesSync(marker, old, new Date(Date.now() - 20_000));
|
|
302
|
+
expect(probeHeavyHookCli(project, { env: probeEnv() }).state).toBe('current');
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
describe('every hook file stays out of git (T13124, gh#1805)', () => {
|
|
307
|
+
const git = (...args: string[]) =>
|
|
308
|
+
execFileSync('git', ['-C', project, ...args], { encoding: 'utf-8' });
|
|
309
|
+
const untracked = () =>
|
|
310
|
+
git('status', '--porcelain', '--untracked-files=all')
|
|
311
|
+
.split('\n')
|
|
312
|
+
.filter((l) => l.startsWith('??'))
|
|
313
|
+
.map((l) => l.slice(3));
|
|
314
|
+
|
|
315
|
+
it('excludes the Claude Code, Codex and opencode hook files it writes, and undoes it on off', async () => {
|
|
316
|
+
git('init', '-q');
|
|
317
|
+
useAll();
|
|
318
|
+
await syncProjectHeavyCommandHooks(project, 'rewrite', { env });
|
|
319
|
+
expect(untracked()).toEqual([]);
|
|
320
|
+
const exclude = readFileSync(join(project, '.git', 'info', 'exclude'), 'utf-8');
|
|
321
|
+
for (const line of [
|
|
322
|
+
'/.claude/settings.local.json',
|
|
323
|
+
'/.codex/hooks.json',
|
|
324
|
+
'/.opencode/plugins/cleo-heavy-command.js',
|
|
325
|
+
]) {
|
|
326
|
+
expect(exclude.split('\n')).toContain(line);
|
|
327
|
+
}
|
|
328
|
+
await syncProjectHeavyCommandHooks(project, 'off', { env });
|
|
329
|
+
const after = readFileSync(join(project, '.git', 'info', 'exclude'), 'utf-8');
|
|
330
|
+
expect(after).not.toMatch(/cleo-heavy-command\.js|\.codex\/hooks\.json|settings\.local\.json/);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('flags a hand-written hook file git can see, and the fix excludes it without rewriting it', async () => {
|
|
334
|
+
git('init', '-q');
|
|
335
|
+
mkdirSync(join(project, '.opencode', 'plugins'), { recursive: true });
|
|
336
|
+
const { opencodeHeavyCommandPluginSource } = await import('../heavy-command-hook-install.js');
|
|
337
|
+
writeFileSync(
|
|
338
|
+
join(project, '.opencode', 'plugins', 'cleo-heavy-command.js'),
|
|
339
|
+
opencodeHeavyCommandPluginSource(),
|
|
340
|
+
);
|
|
341
|
+
const before = inspectProjectHeavyCommandHooks(project, 'rewrite', { env }).find(
|
|
342
|
+
(i) => i.provider === 'opencode',
|
|
343
|
+
);
|
|
344
|
+
expect(before?.state).toBe('outdated');
|
|
345
|
+
expect(before?.detail).toMatch(/git sees it as an untracked file/);
|
|
346
|
+
const outcomes = await syncProjectHeavyCommandHooks(project, 'rewrite', { env });
|
|
347
|
+
expect(outcomes.find((o) => o.provider === 'opencode')?.status).toBe('unchanged');
|
|
348
|
+
expect(untracked()).toEqual([]);
|
|
349
|
+
expect(
|
|
350
|
+
inspectProjectHeavyCommandHooks(project, 'rewrite', { env }).find(
|
|
351
|
+
(i) => i.provider === 'opencode',
|
|
352
|
+
)?.state,
|
|
353
|
+
).toBe('installed');
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('never adds an exclude line for a hook file the repository tracks', async () => {
|
|
357
|
+
git('init', '-q');
|
|
358
|
+
mkdirSync(join(project, '.opencode'));
|
|
359
|
+
await syncProjectHeavyCommandHooks(project, 'rewrite', { env, providers: ['opencode'] });
|
|
360
|
+
// Drop CLEO's block, then commit the plugin as a team would.
|
|
361
|
+
writeFileSync(join(project, '.git', 'info', 'exclude'), '');
|
|
362
|
+
git('add', '-f', '.opencode/plugins/cleo-heavy-command.js');
|
|
363
|
+
await syncProjectHeavyCommandHooks(project, 'rewrite', { env, providers: ['opencode'] });
|
|
364
|
+
expect(readFileSync(join(project, '.git', 'info', 'exclude'), 'utf-8')).toBe('');
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("mode off keeps a user's own identical exclude line (only CLEO's marked block goes)", async () => {
|
|
368
|
+
git('init', '-q');
|
|
369
|
+
mkdirSync(join(project, '.opencode', 'plugins'), { recursive: true });
|
|
370
|
+
const exclude = join(project, '.git', 'info', 'exclude');
|
|
371
|
+
writeFileSync(exclude, '# mine\n/.opencode/plugins/cleo-heavy-command.js\n');
|
|
372
|
+
await syncProjectHeavyCommandHooks(project, 'off', { env, providers: ['opencode'] });
|
|
373
|
+
expect(readFileSync(exclude, 'utf-8')).toBe(
|
|
374
|
+
'# mine\n/.opencode/plugins/cleo-heavy-command.js\n',
|
|
375
|
+
);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('mode off cleans a provider no longer in use (a vanished plugin left its exclude block)', async () => {
|
|
379
|
+
git('init', '-q');
|
|
380
|
+
mkdirSync(join(project, '.opencode'));
|
|
381
|
+
await syncProjectHeavyCommandHooks(project, 'rewrite', { env, providers: ['opencode'] });
|
|
382
|
+
const exclude = join(project, '.git', 'info', 'exclude');
|
|
383
|
+
expect(readFileSync(exclude, 'utf-8')).toContain('/.opencode/plugins/cleo-heavy-command.js');
|
|
384
|
+
rmSync(join(project, '.opencode'), { recursive: true });
|
|
385
|
+
expect(detectHeavyHookProvider('opencode', project, env).detected).toBe(false);
|
|
386
|
+
await syncProjectHeavyCommandHooks(project, 'off', { env, providers: ['opencode'] });
|
|
387
|
+
expect(readFileSync(exclude, 'utf-8')).not.toContain('cleo-heavy-command.js');
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('a hook file alone makes its provider in use, so mode off always reaches it', async () => {
|
|
391
|
+
useAll();
|
|
392
|
+
await syncProjectHeavyCommandHooks(project, 'rewrite', { env });
|
|
393
|
+
// Every machine-level signal gone: the hook files still count.
|
|
394
|
+
rmSync(join(home, '.codex'), { recursive: true });
|
|
395
|
+
rmSync(join(home, '.kimi'), { recursive: true });
|
|
396
|
+
for (const provider of ['claude-code', 'codex', 'opencode'] as const) {
|
|
397
|
+
expect(detectHeavyHookProvider(provider, project, env).detected, provider).toBe(true);
|
|
398
|
+
}
|
|
399
|
+
const off = await syncProjectHeavyCommandHooks(project, 'off', { env });
|
|
400
|
+
expect(off.filter((o) => o.status === 'removed').map((o) => o.provider)).toEqual([
|
|
401
|
+
'claude-code',
|
|
402
|
+
'codex',
|
|
403
|
+
'opencode',
|
|
404
|
+
]);
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
describe("Codex's hooks.json is a shared project config (review MED-3)", () => {
|
|
409
|
+
const git = (...args: string[]) =>
|
|
410
|
+
execFileSync('git', ['-C', project, ...args], { encoding: 'utf-8' });
|
|
411
|
+
const hooksFile = () => join(project, '.codex', 'hooks.json');
|
|
412
|
+
const teamHook = {
|
|
413
|
+
hooks: {
|
|
414
|
+
PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'team-lint-hook' }] }],
|
|
415
|
+
},
|
|
416
|
+
};
|
|
417
|
+
const codex = async () =>
|
|
418
|
+
(await syncProjectHeavyCommandHooks(project, 'rewrite', { env, providers: ['codex'] }))[0];
|
|
419
|
+
const inspectCodex = () =>
|
|
420
|
+
inspectProjectHeavyCommandHooks(project, 'rewrite', { env, providers: ['codex'] })[0];
|
|
421
|
+
|
|
422
|
+
beforeEach(() => {
|
|
423
|
+
git('init', '-q');
|
|
424
|
+
mkdirSync(join(home, '.codex'));
|
|
425
|
+
mkdirSync(join(project, '.codex'));
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it('an untracked hooks.json with a team hook: needs consent (with the exact entry), untouched, never excluded', async () => {
|
|
429
|
+
const body = `${JSON.stringify(teamHook, null, 2)}\n`;
|
|
430
|
+
writeFileSync(hooksFile(), body);
|
|
431
|
+
const outcome = await codex();
|
|
432
|
+
expect(outcome?.status).toBe('needs-consent');
|
|
433
|
+
expect(outcome?.reason).toMatch(/is the project's own Codex hook config/);
|
|
434
|
+
expect(outcome?.remedy).toMatch(/add CLEO's PreToolUse entry \(the snippet\)/);
|
|
435
|
+
expect(JSON.parse(outcome?.snippet ?? 'null')).toEqual(heavyCommandHookEntry('codex'));
|
|
436
|
+
expect(readFileSync(hooksFile(), 'utf-8')).toBe(body);
|
|
437
|
+
expect(
|
|
438
|
+
existsSync(join(project, '.git', 'info', 'exclude'))
|
|
439
|
+
? readFileSync(join(project, '.git', 'info', 'exclude'), 'utf-8')
|
|
440
|
+
: '',
|
|
441
|
+
).not.toContain('.codex/hooks.json');
|
|
442
|
+
const inspected = inspectCodex();
|
|
443
|
+
expect(inspected?.state).toBe('needs-consent');
|
|
444
|
+
expect(inspected?.snippet).toBe(outcome?.snippet);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it("an empty hooks.json the user made is theirs too: needs consent, even in the briefing's no-git mode", async () => {
|
|
448
|
+
writeFileSync(hooksFile(), '{}\n');
|
|
449
|
+
expect((await codex())?.status).toBe('needs-consent');
|
|
450
|
+
expect(readFileSync(hooksFile(), 'utf-8')).toBe('{}\n');
|
|
451
|
+
const quick = inspectProjectHeavyCommandHooks(project, 'rewrite', {
|
|
452
|
+
env,
|
|
453
|
+
providers: ['codex'],
|
|
454
|
+
gitChecks: false,
|
|
455
|
+
})[0];
|
|
456
|
+
expect(quick?.state).toBe('needs-consent');
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it('a CLEO-created, untracked hooks.json is refreshed and excluded', async () => {
|
|
460
|
+
rmSync(join(project, '.codex'), { recursive: true });
|
|
461
|
+
expect((await codex())?.status).toBe('installed');
|
|
462
|
+
expect(readFileSync(join(project, '.git', 'info', 'exclude'), 'utf-8')).toContain(
|
|
463
|
+
'/.codex/hooks.json',
|
|
464
|
+
);
|
|
465
|
+
expect((await codex())?.status).toBe('unchanged');
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it('a tracked hooks.json holding only CLEO hooks still needs consent, untouched', async () => {
|
|
469
|
+
rmSync(join(project, '.codex'), { recursive: true });
|
|
470
|
+
await codex();
|
|
471
|
+
git('add', '-f', '.codex/hooks.json');
|
|
472
|
+
const before = readFileSync(hooksFile(), 'utf-8');
|
|
473
|
+
const outcome = await codex();
|
|
474
|
+
expect(outcome?.status).toBe('needs-consent');
|
|
475
|
+
expect(outcome?.reason).toMatch(/is tracked by git/);
|
|
476
|
+
expect(readFileSync(hooksFile(), 'utf-8')).toBe(before);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it.each([
|
|
480
|
+
['empty', ''],
|
|
481
|
+
['a newline', '\n'],
|
|
482
|
+
])("a committed %s hooks.json is the team's: needs consent, never written (review MED-3a)", async (_name, body) => {
|
|
483
|
+
writeFileSync(hooksFile(), body);
|
|
484
|
+
git('add', '-f', '.codex/hooks.json');
|
|
485
|
+
git(
|
|
486
|
+
'-c',
|
|
487
|
+
'user.email=t@example.com',
|
|
488
|
+
'-c',
|
|
489
|
+
'user.name=t',
|
|
490
|
+
'commit',
|
|
491
|
+
'-qm',
|
|
492
|
+
'team',
|
|
493
|
+
'--no-verify',
|
|
494
|
+
);
|
|
495
|
+
const outcome = await codex();
|
|
496
|
+
expect(outcome?.status).toBe('needs-consent');
|
|
497
|
+
expect(readFileSync(hooksFile(), 'utf-8')).toBe(body);
|
|
498
|
+
expect(git('status', '--porcelain', '--', '.codex/hooks.json')).toBe('');
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it("an untracked hooks.json that is not valid JSON is the user's: needs consent, untouched", async () => {
|
|
502
|
+
writeFileSync(hooksFile(), '{ broken');
|
|
503
|
+
expect((await codex())?.status).toBe('needs-consent');
|
|
504
|
+
expect(readFileSync(hooksFile(), 'utf-8')).toBe('{ broken');
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it("mode off leaves CLEO's hook the team committed (review MED-3b)", async () => {
|
|
508
|
+
rmSync(join(project, '.codex'), { recursive: true });
|
|
509
|
+
await codex();
|
|
510
|
+
writeFileSync(join(project, '.git', 'info', 'exclude'), '');
|
|
511
|
+
git('add', '-f', '.codex/hooks.json');
|
|
512
|
+
git(
|
|
513
|
+
'-c',
|
|
514
|
+
'user.email=t@example.com',
|
|
515
|
+
'-c',
|
|
516
|
+
'user.name=t',
|
|
517
|
+
'commit',
|
|
518
|
+
'-qm',
|
|
519
|
+
'adopt cleo hook',
|
|
520
|
+
'--no-verify',
|
|
521
|
+
);
|
|
522
|
+
const committed = readFileSync(hooksFile(), 'utf-8');
|
|
523
|
+
const off = (
|
|
524
|
+
await syncProjectHeavyCommandHooks(project, 'off', { env, providers: ['codex'] })
|
|
525
|
+
)[0];
|
|
526
|
+
expect(off?.status).toBe('needs-consent');
|
|
527
|
+
expect(off?.remedy).toMatch(/team committed CLEO's hook/);
|
|
528
|
+
expect(readFileSync(hooksFile(), 'utf-8')).toBe(committed);
|
|
529
|
+
expect(git('status', '--porcelain', '--', '.codex/hooks.json')).toBe('');
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it("a CLEO-created file that later holds the user's own hook stops being hidden (review MED-3c)", async () => {
|
|
533
|
+
rmSync(join(project, '.codex'), { recursive: true });
|
|
534
|
+
await codex();
|
|
535
|
+
const exclude = join(project, '.git', 'info', 'exclude');
|
|
536
|
+
expect(readFileSync(exclude, 'utf-8')).toContain('/.codex/hooks.json');
|
|
537
|
+
const withTeam = JSON.parse(readFileSync(hooksFile(), 'utf-8')) as typeof teamHook;
|
|
538
|
+
withTeam.hooks.PreToolUse.push(...teamHook.hooks.PreToolUse);
|
|
539
|
+
writeFileSync(hooksFile(), JSON.stringify(withTeam));
|
|
540
|
+
const flagged = inspectCodex();
|
|
541
|
+
expect(flagged?.state).toBe('outdated');
|
|
542
|
+
expect(flagged?.detail).toMatch(/info\/exclude line hides/);
|
|
543
|
+
expect((await codex())?.status).toBe('needs-consent');
|
|
544
|
+
expect(readFileSync(exclude, 'utf-8')).not.toContain('/.codex/hooks.json');
|
|
545
|
+
expect(git('status', '--porcelain', '--untracked-files=all')).toContain('.codex/hooks.json');
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it("flags CLEO's hook left as an uncommitted change in a tracked hooks.json; off removes only CLEO's", async () => {
|
|
549
|
+
// The team's own formatting (4-space indent), which off must give back byte for byte.
|
|
550
|
+
const committedBytes = `${JSON.stringify(teamHook, null, 4)}\n`;
|
|
551
|
+
writeFileSync(hooksFile(), committedBytes);
|
|
552
|
+
git('add', '-f', '.codex/hooks.json');
|
|
553
|
+
git(
|
|
554
|
+
'-c',
|
|
555
|
+
'user.email=t@example.com',
|
|
556
|
+
'-c',
|
|
557
|
+
'user.name=t',
|
|
558
|
+
'commit',
|
|
559
|
+
'-qm',
|
|
560
|
+
'team hooks',
|
|
561
|
+
'--no-verify',
|
|
562
|
+
);
|
|
563
|
+
// An earlier hand install wrote CLEO's hook into the committed team file.
|
|
564
|
+
const withCleo = structuredClone(teamHook);
|
|
565
|
+
withCleo.hooks.PreToolUse.push(
|
|
566
|
+
heavyCommandHookEntry('codex') as (typeof teamHook.hooks.PreToolUse)[number],
|
|
567
|
+
);
|
|
568
|
+
writeFileSync(hooksFile(), JSON.stringify(withCleo));
|
|
569
|
+
const flagged = inspectCodex();
|
|
570
|
+
expect(flagged?.state).toBe('outdated');
|
|
571
|
+
expect(flagged?.detail).toMatch(
|
|
572
|
+
/an uncommitted change: one `git commit -a` ships it to the team/,
|
|
573
|
+
);
|
|
574
|
+
const off = await syncProjectHeavyCommandHooks(project, 'off', { env, providers: ['codex'] });
|
|
575
|
+
expect(off[0]?.status).toBe('removed');
|
|
576
|
+
// Back to HEAD's exact bytes (review LOW-3): no reformatting diff left behind.
|
|
577
|
+
expect(readFileSync(hooksFile(), 'utf-8')).toBe(committedBytes);
|
|
578
|
+
expect(git('status', '--porcelain', '--', '.codex/hooks.json')).toBe('');
|
|
579
|
+
});
|
|
580
|
+
});
|