@cleocode/adapters 2026.10.3 → 2026.10.5-rc.ci.672
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 +2095 -1269
- package/dist/index.js.map +3 -3
- package/dist/providers/claude-code/adapter.d.ts.map +1 -1
- package/dist/providers/claude-code/hooks.d.ts +43 -17
- package/dist/providers/claude-code/hooks.d.ts.map +1 -1
- package/dist/providers/claude-code/install.d.ts +42 -14
- package/dist/providers/claude-code/install.d.ts.map +1 -1
- package/dist/providers/claude-code/paths.d.ts +77 -0
- package/dist/providers/claude-code/paths.d.ts.map +1 -1
- package/dist/providers/codex/install.d.ts.map +1 -1
- package/dist/providers/cursor/install.d.ts.map +1 -1
- package/dist/providers/gemini-cli/install.d.ts.map +1 -1
- package/dist/providers/kimi/install.d.ts.map +1 -1
- package/dist/providers/openai-sdk/install.d.ts.map +1 -1
- package/dist/providers/opencode/install.d.ts.map +1 -1
- package/dist/providers/pi/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/dist/providers/shared/project-instruction-file.d.ts +30 -0
- package/dist/providers/shared/project-instruction-file.d.ts.map +1 -0
- package/package.json +9 -5
- package/src/__tests__/claude-code-adapter.test.ts +11 -6
- package/src/heavy-command-hook.ts +21 -0
- package/src/index.ts +12 -0
- package/src/providers/claude-code/__tests__/hooks-install.test.ts +20 -17
- package/src/providers/claude-code/__tests__/no-user-global-write.test.ts +293 -0
- package/src/providers/claude-code/adapter.ts +3 -1
- package/src/providers/claude-code/hooks.ts +138 -51
- package/src/providers/claude-code/install.ts +123 -52
- package/src/providers/claude-code/paths.ts +159 -1
- package/src/providers/codex/install.ts +7 -6
- package/src/providers/cursor/install.ts +26 -12
- package/src/providers/gemini-cli/install.ts +4 -2
- package/src/providers/kimi/install.ts +4 -2
- package/src/providers/openai-sdk/install.ts +5 -2
- package/src/providers/opencode/install.ts +4 -2
- package/src/providers/pi/install.ts +4 -2
- package/src/providers/shared/__tests__/heavy-command-hook-delivery.test.ts +580 -0
- package/src/providers/shared/__tests__/heavy-command-hook-install.test.ts +20 -0
- package/src/providers/shared/heavy-command-hook-delivery.ts +620 -0
- package/src/providers/shared/heavy-command-hook-install.ts +401 -32
- package/src/providers/shared/project-instruction-file.ts +47 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Claude Code adapter never writes the user-global Claude config (T13227).
|
|
3
|
+
*
|
|
4
|
+
* `ClaudeCodeAdapter.initialize` (native hooks), the install provider (plugin
|
|
5
|
+
* + PreCompact hook templates) and `dispose` (hook removal) all write the
|
|
6
|
+
* PROJECT's `.claude/` only. Every case runs in a sandbox: `HOME`,
|
|
7
|
+
* `CLAUDE_HOME` and `CLAUDE_SETTINGS` point into a throwaway dir, and the
|
|
8
|
+
* user-global dir there is snapshotted (bytes + mtime of every file) before
|
|
9
|
+
* and compared after. The real `~/.claude` is never read.
|
|
10
|
+
*
|
|
11
|
+
* @task T13227
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { execFileSync } from 'node:child_process';
|
|
15
|
+
import {
|
|
16
|
+
existsSync,
|
|
17
|
+
mkdirSync,
|
|
18
|
+
mkdtempSync,
|
|
19
|
+
readdirSync,
|
|
20
|
+
readFileSync,
|
|
21
|
+
rmSync,
|
|
22
|
+
statSync,
|
|
23
|
+
writeFileSync,
|
|
24
|
+
} from 'node:fs';
|
|
25
|
+
import { tmpdir } from 'node:os';
|
|
26
|
+
import { join } from 'node:path';
|
|
27
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
28
|
+
|
|
29
|
+
// initialize() asks the `cleo` binary for the spawn mode; never run a real one.
|
|
30
|
+
vi.mock('node:child_process', async (importOriginal) => {
|
|
31
|
+
const actual = await importOriginal<typeof import('node:child_process')>();
|
|
32
|
+
return {
|
|
33
|
+
...actual,
|
|
34
|
+
execFile: (...args: unknown[]) => {
|
|
35
|
+
const callback = args[args.length - 1];
|
|
36
|
+
if (typeof callback === 'function') callback(new Error('cleo not available in test'));
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
import { ClaudeCodeAdapter } from '../adapter.js';
|
|
42
|
+
import { ClaudeCodeHookProvider, NATIVE_STOP_HOOK_COMMAND } from '../hooks.js';
|
|
43
|
+
import { ClaudeCodeInstallProvider } from '../install.js';
|
|
44
|
+
|
|
45
|
+
/** Every file under `dir`, as path → bytes + mtime. */
|
|
46
|
+
function snapshot(dir: string): Record<string, string> {
|
|
47
|
+
const out: Record<string, string> = {};
|
|
48
|
+
if (!existsSync(dir)) return out;
|
|
49
|
+
for (const name of readdirSync(dir, { recursive: true, encoding: 'utf-8' })) {
|
|
50
|
+
const path = join(dir, name);
|
|
51
|
+
const st = statSync(path);
|
|
52
|
+
out[name] = st.isDirectory()
|
|
53
|
+
? `dir@${st.mtimeMs}`
|
|
54
|
+
: `${readFileSync(path, 'base64')}@${st.mtimeMs}`;
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** What a user's own global Claude config holds. */
|
|
60
|
+
const USER_SETTINGS = JSON.stringify(
|
|
61
|
+
{
|
|
62
|
+
model: 'opus',
|
|
63
|
+
enabledPlugins: { 'other@market': true },
|
|
64
|
+
hooks: { Stop: [{ matcher: '', hooks: [{ type: 'command', command: 'my-stop.sh' }] }] },
|
|
65
|
+
},
|
|
66
|
+
null,
|
|
67
|
+
2,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const SAVED = ['HOME', 'USERPROFILE', 'CLAUDE_HOME', 'CLAUDE_SETTINGS'] as const;
|
|
71
|
+
let saved: Record<string, string | undefined>;
|
|
72
|
+
let root: string;
|
|
73
|
+
let home: string;
|
|
74
|
+
let claudeHome: string;
|
|
75
|
+
let projectDir: string;
|
|
76
|
+
|
|
77
|
+
beforeEach(() => {
|
|
78
|
+
saved = Object.fromEntries(SAVED.map((k) => [k, process.env[k]]));
|
|
79
|
+
root = mkdtempSync(join(tmpdir(), 'cleo-claude-global-'));
|
|
80
|
+
home = join(root, 'home');
|
|
81
|
+
claudeHome = join(home, '.claude');
|
|
82
|
+
projectDir = join(root, 'project');
|
|
83
|
+
mkdirSync(join(claudeHome, 'hooks'), { recursive: true });
|
|
84
|
+
mkdirSync(projectDir, { recursive: true });
|
|
85
|
+
writeFileSync(join(claudeHome, 'settings.json'), USER_SETTINGS);
|
|
86
|
+
writeFileSync(join(claudeHome, 'hooks', 'mine.sh'), '#!/bin/sh\n');
|
|
87
|
+
execFileSync('git', ['init', '-q', projectDir]);
|
|
88
|
+
process.env.HOME = home;
|
|
89
|
+
process.env.USERPROFILE = home;
|
|
90
|
+
process.env.CLAUDE_HOME = claudeHome;
|
|
91
|
+
delete process.env.CLAUDE_SETTINGS;
|
|
92
|
+
mkdirSync(join(home, '.cleo', 'templates'), { recursive: true });
|
|
93
|
+
writeFileSync(join(home, '.cleo', 'templates', 'CLEO-INJECTION.md'), 'Fixture protocol.');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
afterEach(() => {
|
|
97
|
+
for (const k of SAVED) {
|
|
98
|
+
const v = saved[k];
|
|
99
|
+
if (v === undefined) delete process.env[k];
|
|
100
|
+
else process.env[k] = v;
|
|
101
|
+
}
|
|
102
|
+
rmSync(root, { recursive: true, force: true });
|
|
103
|
+
vi.restoreAllMocks();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
/** Run every adapter writer for `dir`: initialize, install, dispose. */
|
|
107
|
+
async function runAllWriters(dir: string): Promise<{
|
|
108
|
+
adapter: ClaudeCodeAdapter;
|
|
109
|
+
install: Awaited<ReturnType<ClaudeCodeInstallProvider['install']>>;
|
|
110
|
+
}> {
|
|
111
|
+
const adapter = new ClaudeCodeAdapter();
|
|
112
|
+
await adapter.initialize(dir);
|
|
113
|
+
const install = await new ClaudeCodeInstallProvider().install({ projectDir: dir });
|
|
114
|
+
await adapter.dispose();
|
|
115
|
+
return { adapter, install };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
describe('Claude Code adapter never writes the user-global Claude config (T13227)', () => {
|
|
119
|
+
it('initialize + install + dispose leave CLAUDE_HOME byte-identical and write the project', async () => {
|
|
120
|
+
const before = snapshot(claudeHome);
|
|
121
|
+
const adapter = new ClaudeCodeAdapter();
|
|
122
|
+
|
|
123
|
+
await adapter.initialize(projectDir);
|
|
124
|
+
const local = join(projectDir, '.claude', 'settings.local.json');
|
|
125
|
+
const afterInit = JSON.parse(readFileSync(local, 'utf-8')) as {
|
|
126
|
+
hooks: Record<string, Array<{ hooks: Array<{ command: string }> }>>;
|
|
127
|
+
};
|
|
128
|
+
expect(afterInit.hooks.Stop?.[0]?.hooks[0]?.command).toBe(NATIVE_STOP_HOOK_COMMAND);
|
|
129
|
+
expect(afterInit.hooks.PostToolUse?.[0]?.hooks).toHaveLength(2);
|
|
130
|
+
|
|
131
|
+
const result = await new ClaudeCodeInstallProvider().install({ projectDir });
|
|
132
|
+
expect(result.success).toBe(true);
|
|
133
|
+
const afterInstall = JSON.parse(readFileSync(local, 'utf-8')) as {
|
|
134
|
+
enabledPlugins: Record<string, boolean>;
|
|
135
|
+
hooks: Record<string, unknown[]>;
|
|
136
|
+
};
|
|
137
|
+
expect(afterInstall.enabledPlugins['cleo@cleocode']).toBe(true);
|
|
138
|
+
expect(afterInstall.hooks.PreCompact).toHaveLength(1);
|
|
139
|
+
expect(existsSync(join(projectDir, '.claude', 'hooks', 'precompact-safestop.sh'))).toBe(true);
|
|
140
|
+
|
|
141
|
+
await adapter.dispose();
|
|
142
|
+
|
|
143
|
+
expect(snapshot(claudeHome)).toEqual(before);
|
|
144
|
+
expect(readFileSync(join(claudeHome, 'settings.json'), 'utf-8')).toBe(USER_SETTINGS);
|
|
145
|
+
// The per-machine files are kept out of git.
|
|
146
|
+
const exclude = readFileSync(join(projectDir, '.git', 'info', 'exclude'), 'utf-8');
|
|
147
|
+
expect(exclude).toContain('/.claude/settings.local.json');
|
|
148
|
+
expect(exclude).toContain('/.claude/hooks/precompact-safestop.sh');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('a project that is the home directory is refused: nothing written, steps skipped', async () => {
|
|
152
|
+
const before = snapshot(claudeHome);
|
|
153
|
+
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
154
|
+
|
|
155
|
+
const hooks = new ClaudeCodeHookProvider();
|
|
156
|
+
await hooks.registerNativeHooks(home);
|
|
157
|
+
const { install } = await runAllWriters(home);
|
|
158
|
+
|
|
159
|
+
expect(snapshot(claudeHome)).toEqual(before);
|
|
160
|
+
expect(hooks.getSettingsError()).toMatch(
|
|
161
|
+
/refusing to write .*the project is the home directory/,
|
|
162
|
+
);
|
|
163
|
+
expect(install.details?.plugin).toBe('skipped');
|
|
164
|
+
expect(install.details?.hookTemplates).toBe('skipped');
|
|
165
|
+
// ~/CLAUDE.md is loaded into every session under $HOME (#1898 review MED).
|
|
166
|
+
expect(install.details?.instructionFile).toBe('skipped');
|
|
167
|
+
expect(existsSync(join(home, 'CLAUDE.md'))).toBe(false);
|
|
168
|
+
await expect(new ClaudeCodeInstallProvider().ensureInstructionReferences(home)).rejects.toThrow(
|
|
169
|
+
/the project is the home directory/,
|
|
170
|
+
);
|
|
171
|
+
expect(existsSync(join(home, 'CLAUDE.md'))).toBe(false);
|
|
172
|
+
expect(stderr).toHaveBeenCalled();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('the home directory is refused even when CLAUDE_HOME points elsewhere', async () => {
|
|
176
|
+
process.env.CLAUDE_HOME = join(root, 'elsewhere', '.claude');
|
|
177
|
+
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
178
|
+
const before = snapshot(join(home, '.claude'));
|
|
179
|
+
|
|
180
|
+
const { install } = await runAllWriters(home);
|
|
181
|
+
|
|
182
|
+
expect(snapshot(join(home, '.claude'))).toEqual(before);
|
|
183
|
+
expect(install.details?.instructionFile).toBe('skipped');
|
|
184
|
+
expect(existsSync(join(home, 'CLAUDE.md'))).toBe(false);
|
|
185
|
+
expect(install.details?.plugin).toBe('skipped');
|
|
186
|
+
expect(install.details?.commands).toBe('skipped');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('CLAUDE_HOME pointing at the project .claude is refused: nothing written', async () => {
|
|
190
|
+
const projectClaude = join(projectDir, '.claude');
|
|
191
|
+
mkdirSync(projectClaude, { recursive: true });
|
|
192
|
+
writeFileSync(join(projectClaude, 'settings.json'), USER_SETTINGS);
|
|
193
|
+
process.env.CLAUDE_HOME = projectClaude;
|
|
194
|
+
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
195
|
+
const before = snapshot(projectClaude);
|
|
196
|
+
|
|
197
|
+
const { install } = await runAllWriters(projectDir);
|
|
198
|
+
|
|
199
|
+
expect(snapshot(projectClaude)).toEqual(before);
|
|
200
|
+
expect(install.details?.plugin).toBe('skipped');
|
|
201
|
+
expect(install.details?.hookTemplates).toBe('skipped');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('the Claude dir itself as the project is refused, CLAUDE.md included', async () => {
|
|
205
|
+
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
206
|
+
const before = snapshot(claudeHome);
|
|
207
|
+
|
|
208
|
+
const { install } = await runAllWriters(claudeHome);
|
|
209
|
+
|
|
210
|
+
expect(snapshot(claudeHome)).toEqual(before);
|
|
211
|
+
expect(install.details?.instructionFile).toBe('skipped');
|
|
212
|
+
expect(install.details?.commands).toBe('skipped');
|
|
213
|
+
await expect(
|
|
214
|
+
new ClaudeCodeInstallProvider().ensureInstructionReferences(claudeHome),
|
|
215
|
+
).rejects.toThrow(/inside the user-global Claude config dir/);
|
|
216
|
+
expect(existsSync(join(claudeHome, 'CLAUDE.md'))).toBe(false);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('a differently cased spelling of the Claude dir is refused on a case-insensitive volume', async (ctx) => {
|
|
220
|
+
const variant = join(home, '.CLAUDE');
|
|
221
|
+
// Only meaningful where the volume folds case (default APFS / NTFS).
|
|
222
|
+
if (!existsSync(variant)) ctx.skip();
|
|
223
|
+
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
224
|
+
const before = snapshot(claudeHome);
|
|
225
|
+
|
|
226
|
+
const hooks = new ClaudeCodeHookProvider();
|
|
227
|
+
await hooks.registerNativeHooks(variant);
|
|
228
|
+
const { install } = await runAllWriters(variant);
|
|
229
|
+
|
|
230
|
+
expect(snapshot(claudeHome)).toEqual(before);
|
|
231
|
+
expect(hooks.getSettingsError()).toMatch(/inside the user-global Claude config dir/);
|
|
232
|
+
expect(install.details?.instructionFile).toBe('skipped');
|
|
233
|
+
expect(install.details?.plugin).toBe('skipped');
|
|
234
|
+
expect(install.details?.hookTemplates).toBe('skipped');
|
|
235
|
+
expect(install.details?.commands).toBe('skipped');
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('a differently cased spelling of the home directory is refused on a case-insensitive volume', async (ctx) => {
|
|
239
|
+
const variant = home.replace(/home$/, 'HOME');
|
|
240
|
+
if (!existsSync(variant)) ctx.skip();
|
|
241
|
+
process.env.CLAUDE_HOME = join(root, 'elsewhere', '.claude');
|
|
242
|
+
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
243
|
+
const before = snapshot(claudeHome);
|
|
244
|
+
|
|
245
|
+
const { install } = await runAllWriters(variant);
|
|
246
|
+
|
|
247
|
+
expect(snapshot(claudeHome)).toEqual(before);
|
|
248
|
+
expect(install.details?.plugin).toBe('skipped');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('CLAUDE_SETTINGS naming the project settings.local.json is refused: nothing written', async () => {
|
|
252
|
+
const local = join(projectDir, '.claude', 'settings.local.json');
|
|
253
|
+
process.env.CLAUDE_SETTINGS = local;
|
|
254
|
+
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
255
|
+
const before = snapshot(claudeHome);
|
|
256
|
+
|
|
257
|
+
const { install } = await runAllWriters(projectDir);
|
|
258
|
+
|
|
259
|
+
expect(existsSync(local)).toBe(false);
|
|
260
|
+
expect(snapshot(claudeHome)).toEqual(before);
|
|
261
|
+
expect(install.details?.plugin).toBe('skipped');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('dispose removes only the native hook objects; the heavy-command and user hooks stay', async () => {
|
|
265
|
+
const local = join(projectDir, '.claude', 'settings.local.json');
|
|
266
|
+
mkdirSync(join(projectDir, '.claude'), { recursive: true });
|
|
267
|
+
const heavy = {
|
|
268
|
+
matcher: 'Bash',
|
|
269
|
+
hooks: [{ type: 'command', command: 'cleo hook heavy-command # cleo-hook', timeout: 20 }],
|
|
270
|
+
};
|
|
271
|
+
writeFileSync(
|
|
272
|
+
local,
|
|
273
|
+
JSON.stringify({
|
|
274
|
+
hooks: {
|
|
275
|
+
PreToolUse: [heavy],
|
|
276
|
+
Stop: [{ matcher: '', hooks: [{ type: 'command', command: 'my-stop.sh' }] }],
|
|
277
|
+
},
|
|
278
|
+
}),
|
|
279
|
+
);
|
|
280
|
+
const adapter = new ClaudeCodeAdapter();
|
|
281
|
+
|
|
282
|
+
await adapter.initialize(projectDir);
|
|
283
|
+
// Wired beside the heavy-command hook (which is not a native hook).
|
|
284
|
+
expect(readFileSync(local, 'utf-8')).toContain(JSON.stringify(NATIVE_STOP_HOOK_COMMAND));
|
|
285
|
+
await adapter.dispose();
|
|
286
|
+
|
|
287
|
+
const after = JSON.parse(readFileSync(local, 'utf-8')) as { hooks: Record<string, unknown> };
|
|
288
|
+
expect(after.hooks).toEqual({
|
|
289
|
+
PreToolUse: [heavy],
|
|
290
|
+
Stop: [{ matcher: '', hooks: [{ type: 'command', command: 'my-stop.sh' }] }],
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
});
|
|
@@ -159,7 +159,9 @@ export class ClaudeCodeAdapter implements CLEOProviderAdapter {
|
|
|
159
159
|
}
|
|
160
160
|
|
|
161
161
|
// Activate CLEO hook bridge for this project — connects Claude Code
|
|
162
|
-
// native events to CLEO's internal hook dispatch (T555).
|
|
162
|
+
// native events to CLEO's internal hook dispatch (T555). Written to the
|
|
163
|
+
// project's .claude/settings.local.json only, never the user-global
|
|
164
|
+
// ~/.claude/settings.json (T13227).
|
|
163
165
|
await this.hooks.registerNativeHooks(projectDir);
|
|
164
166
|
}
|
|
165
167
|
|
|
@@ -21,13 +21,85 @@ import { readdir, readFile } from 'node:fs/promises';
|
|
|
21
21
|
import { join } from 'node:path';
|
|
22
22
|
import { updateJsonConfigFile } from '@cleocode/caamp';
|
|
23
23
|
import type { AdapterHookProvider } from '@cleocode/contracts';
|
|
24
|
-
import {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
24
|
+
import { excludeLocalSettingsFromGit } from '../shared/heavy-command-hook-install.js';
|
|
25
|
+
import { appendHookEntry, hookMap, isPlainObject, projectClaudeSettingsPath } from './paths.js';
|
|
26
|
+
|
|
27
|
+
/** The `Stop` hook command: end the CLEO session when Claude Code stops. */
|
|
28
|
+
export const NATIVE_STOP_HOOK_COMMAND = 'cleo session end --quiet # cleo-hook';
|
|
29
|
+
|
|
30
|
+
/** The `PostToolUse` (`Write|Edit`) hook command: a brain observation per file write. */
|
|
31
|
+
export const NATIVE_OBSERVE_HOOK_COMMAND =
|
|
32
|
+
'cleo observe "File modified via $TOOL_NAME" --title "tool-use" --quiet # cleo-hook';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The `PostToolUse` (`Write|Edit`) NEXUS post-check: re-index changed files and
|
|
36
|
+
* flag regressions. `$TOOL_INPUT_file_path` is populated by Claude Code for
|
|
37
|
+
* Write/Edit events.
|
|
38
|
+
*/
|
|
39
|
+
export const NATIVE_NEXUS_HOOK_COMMAND =
|
|
40
|
+
'cleo nexus analyze --incremental --json > /dev/null 2>&1 && cleo observe "NEXUS re-indexed after $TOOL_NAME on $TOOL_INPUT_file_path" --title "nexus-post-check" --quiet # cleo-hook';
|
|
41
|
+
|
|
42
|
+
/** Every command {@link ClaudeCodeHookProvider.registerNativeHooks} writes, and alone may remove. */
|
|
43
|
+
const NATIVE_HOOK_COMMANDS: ReadonlySet<string> = new Set([
|
|
44
|
+
NATIVE_STOP_HOOK_COMMAND,
|
|
45
|
+
NATIVE_OBSERVE_HOOK_COMMAND,
|
|
46
|
+
NATIVE_NEXUS_HOOK_COMMAND,
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
/** Whether a hook object is one of the adapter's native hooks (never the heavy-command hook or a user's). */
|
|
50
|
+
function isNativeHookObject(hook: unknown): boolean {
|
|
51
|
+
return (
|
|
52
|
+
isPlainObject(hook) &&
|
|
53
|
+
typeof hook.command === 'string' &&
|
|
54
|
+
NATIVE_HOOK_COMMANDS.has(hook.command)
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Whether any event of a `hooks` map already holds a native hook. */
|
|
59
|
+
function hasNativeHooks(hooks: Record<string, unknown>): boolean {
|
|
60
|
+
return Object.values(hooks).some(
|
|
61
|
+
(entries) =>
|
|
62
|
+
Array.isArray(entries) &&
|
|
63
|
+
entries.some(
|
|
64
|
+
(entry) =>
|
|
65
|
+
isPlainObject(entry) &&
|
|
66
|
+
Array.isArray(entry.hooks) &&
|
|
67
|
+
entry.hooks.some(isNativeHookObject),
|
|
68
|
+
),
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Remove the native hook objects from every event, then any entry left with
|
|
74
|
+
* no hooks and any event left with no entries. Other hooks sharing an entry
|
|
75
|
+
* (the heavy-command hook, the user's own) stay.
|
|
76
|
+
*/
|
|
77
|
+
function removeNativeHooks(hooks: Record<string, unknown>): boolean {
|
|
78
|
+
let changed = false;
|
|
79
|
+
for (const [event, entries] of Object.entries(hooks)) {
|
|
80
|
+
if (!Array.isArray(entries)) continue;
|
|
81
|
+
const kept: unknown[] = [];
|
|
82
|
+
let eventChanged = false;
|
|
83
|
+
for (const entry of entries) {
|
|
84
|
+
if (
|
|
85
|
+
!isPlainObject(entry) ||
|
|
86
|
+
!Array.isArray(entry.hooks) ||
|
|
87
|
+
!entry.hooks.some(isNativeHookObject)
|
|
88
|
+
) {
|
|
89
|
+
kept.push(entry);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
eventChanged = true;
|
|
93
|
+
const rest = entry.hooks.filter((hook) => !isNativeHookObject(hook));
|
|
94
|
+
if (rest.length > 0) kept.push({ ...entry, hooks: rest });
|
|
95
|
+
}
|
|
96
|
+
if (!eventChanged) continue;
|
|
97
|
+
changed = true;
|
|
98
|
+
if (kept.length === 0) delete hooks[event];
|
|
99
|
+
else hooks[event] = kept;
|
|
100
|
+
}
|
|
101
|
+
return changed;
|
|
102
|
+
}
|
|
31
103
|
|
|
32
104
|
/** CAAMP provider identifier for Claude Code. */
|
|
33
105
|
const PROVIDER_ID = 'claude-code' as const;
|
|
@@ -76,8 +148,10 @@ const CLAUDE_CODE_EVENT_MAP: Record<string, string> = {
|
|
|
76
148
|
/**
|
|
77
149
|
* Hook provider for Claude Code.
|
|
78
150
|
*
|
|
79
|
-
* Claude Code
|
|
80
|
-
*
|
|
151
|
+
* CLEO registers its Claude Code hooks in the project's per-machine
|
|
152
|
+
* `<project>/.claude/settings.local.json`, never in the user-global
|
|
153
|
+
* `~/.claude/settings.json` (T13227). Supported handler types: command, http,
|
|
154
|
+
* prompt, agent.
|
|
81
155
|
*
|
|
82
156
|
* Event mapping is based on `getProviderHookProfile('claude-code')` from
|
|
83
157
|
* CAAMP 1.9.1. Async accessors (`getSupportedCanonicalEvents`,
|
|
@@ -122,76 +196,79 @@ export class ClaudeCodeHookProvider implements AdapterHookProvider {
|
|
|
122
196
|
/**
|
|
123
197
|
* Register native hooks for a project.
|
|
124
198
|
*
|
|
125
|
-
* Writes CLEO hook entries to
|
|
126
|
-
*
|
|
127
|
-
*
|
|
199
|
+
* Writes CLEO hook entries to the project's per-machine
|
|
200
|
+
* `<project>/.claude/settings.local.json` so that Claude Code's native event
|
|
201
|
+
* system calls cleo CLI commands when events fire in this project. This
|
|
202
|
+
* bridges Claude Code's event loop to CLEO's internal hook dispatch.
|
|
203
|
+
* CLEO never writes the user-global `~/.claude/settings.json` (T13227): a
|
|
204
|
+
* project that is the home directory, or whose settings file resolves into
|
|
205
|
+
* the user-global Claude config, is refused and nothing is written. When
|
|
206
|
+
* git does not ignore `settings.local.json` yet, a marked `info/exclude`
|
|
207
|
+
* block keeps it out of git (the T12983 heavy-command rule).
|
|
128
208
|
*
|
|
129
|
-
* Idempotent: skips writing if
|
|
209
|
+
* Idempotent: skips writing if the native hooks are already present.
|
|
130
210
|
*
|
|
131
211
|
* Hook entries registered:
|
|
132
212
|
* - `Stop` → `cleo session end --quiet` (triggers LLM extraction, reflector, consolidation)
|
|
133
213
|
* - `PostToolUse` (Write|Edit) → brain observation for file modifications
|
|
134
|
-
*
|
|
214
|
+
* and the NEXUS post-check
|
|
135
215
|
*
|
|
136
|
-
* @param projectDir - Project
|
|
137
|
-
* @task T164 @task T555
|
|
216
|
+
* @param projectDir - Project whose Claude Code settings receive the hooks
|
|
217
|
+
* @task T164 @task T555 @task T13227
|
|
138
218
|
*/
|
|
139
219
|
async registerNativeHooks(projectDir: string): Promise<void> {
|
|
140
220
|
this.projectDir = projectDir;
|
|
141
221
|
this.registered = true;
|
|
142
222
|
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
|
|
146
|
-
await this.updateSettings((settings) => {
|
|
223
|
+
// T12385: locked, atomic, and a malformed file is reported and left
|
|
224
|
+
// untouched — never replaced with an object holding only CLEO's entries.
|
|
225
|
+
const wrote = await this.updateSettings(projectDir, (settings) => {
|
|
147
226
|
const hooks = hookMap(settings);
|
|
148
|
-
if (
|
|
149
|
-
return false; // Already wired — idempotent
|
|
150
|
-
}
|
|
227
|
+
if (hasNativeHooks(hooks)) return false; // Already wired — idempotent
|
|
151
228
|
|
|
152
|
-
// Register Stop hook → triggers cleo session end (LLM extraction, reflector, consolidation)
|
|
153
229
|
appendHookEntry(hooks, 'Stop', {
|
|
154
230
|
matcher: '',
|
|
155
|
-
hooks: [{ type: 'command', command:
|
|
231
|
+
hooks: [{ type: 'command', command: NATIVE_STOP_HOOK_COMMAND }],
|
|
156
232
|
});
|
|
157
|
-
|
|
158
|
-
// Register PostToolUse hook → brain observation for file writes + NEXUS post-check (T625)
|
|
159
233
|
appendHookEntry(hooks, 'PostToolUse', {
|
|
160
234
|
matcher: 'Write|Edit',
|
|
161
235
|
hooks: [
|
|
162
|
-
{
|
|
163
|
-
|
|
164
|
-
command: `cleo observe "File modified via $TOOL_NAME" --title "tool-use" --quiet # cleo-hook`,
|
|
165
|
-
},
|
|
166
|
-
{
|
|
167
|
-
// NEXUS post-modification check: re-index changed files and flag regressions.
|
|
168
|
-
// $TOOL_INPUT_file_path is populated by Claude Code for Write/Edit events.
|
|
169
|
-
type: 'command',
|
|
170
|
-
command: `cleo nexus analyze --incremental --json > /dev/null 2>&1 && cleo observe "NEXUS re-indexed after $TOOL_NAME on $TOOL_INPUT_file_path" --title "nexus-post-check" --quiet # cleo-hook`,
|
|
171
|
-
},
|
|
236
|
+
{ type: 'command', command: NATIVE_OBSERVE_HOOK_COMMAND },
|
|
237
|
+
{ type: 'command', command: NATIVE_NEXUS_HOOK_COMMAND },
|
|
172
238
|
],
|
|
173
239
|
});
|
|
174
240
|
return true;
|
|
175
241
|
});
|
|
242
|
+
if (wrote) excludeLocalSettingsFromGit(projectDir);
|
|
176
243
|
}
|
|
177
244
|
|
|
178
245
|
/**
|
|
179
246
|
* Unregister native hooks.
|
|
180
247
|
*
|
|
181
|
-
* Removes
|
|
182
|
-
*
|
|
183
|
-
* and
|
|
248
|
+
* Removes the hook objects {@link registerNativeHooks} wrote from the
|
|
249
|
+
* project's `settings.local.json`, and nothing else: the heavy-command hook
|
|
250
|
+
* and the user's own hooks stay, and an entry or event is dropped only when
|
|
251
|
+
* left empty. Same locking, atomicity, parse-error and project-scope rules
|
|
252
|
+
* as {@link registerNativeHooks}.
|
|
184
253
|
*
|
|
185
|
-
* @task T164 @task T555
|
|
254
|
+
* @task T164 @task T555 @task T13227
|
|
186
255
|
*/
|
|
187
256
|
async unregisterNativeHooks(): Promise<void> {
|
|
257
|
+
const projectDir = this.projectDir;
|
|
188
258
|
this.registered = false;
|
|
189
259
|
this.projectDir = null;
|
|
260
|
+
if (projectDir === null) return;
|
|
190
261
|
|
|
191
|
-
|
|
192
|
-
|
|
262
|
+
let settingsPath: string;
|
|
263
|
+
try {
|
|
264
|
+
settingsPath = projectClaudeSettingsPath(projectDir);
|
|
265
|
+
} catch {
|
|
266
|
+
return; // Never written there, so nothing to remove.
|
|
267
|
+
}
|
|
268
|
+
if (!existsSync(settingsPath)) return;
|
|
269
|
+
await this.updateSettings(projectDir, (settings) => {
|
|
193
270
|
if (settings.hooks === undefined) return false;
|
|
194
|
-
return
|
|
271
|
+
return removeNativeHooks(hookMap(settings));
|
|
195
272
|
});
|
|
196
273
|
}
|
|
197
274
|
|
|
@@ -199,12 +276,13 @@ export class ClaudeCodeHookProvider implements AdapterHookProvider {
|
|
|
199
276
|
private settingsError: string | null = null;
|
|
200
277
|
|
|
201
278
|
/**
|
|
202
|
-
* Why the last settings
|
|
279
|
+
* Why the last settings update did not happen, or `null`.
|
|
203
280
|
*
|
|
204
281
|
* @remarks
|
|
205
282
|
* Hook registration stays non-fatal for adapter initialisation, so a
|
|
206
|
-
* refused write (malformed settings
|
|
207
|
-
* on stderr instead of being swallowed
|
|
283
|
+
* refused write (malformed settings file, lock timeout, a user-global
|
|
284
|
+
* target) is surfaced here and on stderr instead of being swallowed
|
|
285
|
+
* (T12385, T13227).
|
|
208
286
|
*
|
|
209
287
|
* @returns The failure message, or `null`
|
|
210
288
|
*/
|
|
@@ -212,19 +290,28 @@ export class ClaudeCodeHookProvider implements AdapterHookProvider {
|
|
|
212
290
|
return this.settingsError;
|
|
213
291
|
}
|
|
214
292
|
|
|
215
|
-
/**
|
|
293
|
+
/**
|
|
294
|
+
* Apply `mutate` to the project's `settings.local.json` via CAAMP's locked,
|
|
295
|
+
* atomic JSON writer.
|
|
296
|
+
*
|
|
297
|
+
* @returns whether the file was written.
|
|
298
|
+
*/
|
|
216
299
|
private async updateSettings(
|
|
300
|
+
projectDir: string,
|
|
217
301
|
mutate: (settings: Record<string, unknown>) => boolean,
|
|
218
|
-
): Promise<
|
|
219
|
-
|
|
302
|
+
): Promise<boolean> {
|
|
303
|
+
let settingsPath = `${projectDir}/.claude/settings.local.json`;
|
|
220
304
|
try {
|
|
221
|
-
|
|
305
|
+
settingsPath = projectClaudeSettingsPath(projectDir);
|
|
306
|
+
const wrote = await updateJsonConfigFile(settingsPath, mutate);
|
|
222
307
|
this.settingsError = null;
|
|
308
|
+
return wrote;
|
|
223
309
|
} catch (err) {
|
|
224
310
|
this.settingsError = err instanceof Error ? err.message : String(err);
|
|
225
311
|
process.stderr.write(
|
|
226
312
|
`[cleo:claude-code] hooks not written to ${settingsPath}: ${this.settingsError}\n`,
|
|
227
313
|
);
|
|
314
|
+
return false;
|
|
228
315
|
}
|
|
229
316
|
}
|
|
230
317
|
|