@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
|
@@ -3,23 +3,32 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Handles CLEO installation into Claude Code environments:
|
|
5
5
|
* - Ensures CLAUDE.md has CLEO @-references
|
|
6
|
-
* - Manages plugin registration in
|
|
6
|
+
* - Manages plugin registration in the PROJECT's `.claude/settings.local.json`
|
|
7
|
+
* - Installs the PreCompact hook templates in the PROJECT's `.claude/hooks/`
|
|
7
8
|
* - Installs the heavy-command `PreToolUse` hook in the PROJECT's
|
|
8
9
|
* `.claude/settings.local.json` (T12983)
|
|
9
10
|
*
|
|
11
|
+
* Project-level only (T13227): CLEO never writes the user-global
|
|
12
|
+
* `~/.claude/settings.json` or `~/.claude/hooks/` (`CLAUDE_HOME`,
|
|
13
|
+
* `CLAUDE_SETTINGS`). A project that is the home directory, or whose
|
|
14
|
+
* `.claude/` resolves into the user-global Claude config, gets those steps
|
|
15
|
+
* reported as `skipped` and nothing is written there.
|
|
16
|
+
*
|
|
10
17
|
* Migrated from src/core/install/claude-plugin.ts
|
|
11
18
|
*
|
|
12
19
|
* @task T5240
|
|
13
20
|
* @task T12983
|
|
21
|
+
* @task T13227
|
|
14
22
|
*/
|
|
15
23
|
|
|
16
24
|
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
|
|
17
|
-
import { homedir } from 'node:os';
|
|
18
25
|
import { dirname, join, resolve } from 'node:path';
|
|
19
26
|
import { fileURLToPath } from 'node:url';
|
|
20
27
|
import { ensureProviderInstructionFile, updateJsonConfigFile } from '@cleocode/caamp';
|
|
21
28
|
import type { AdapterInstallProvider, InstallOptions, InstallResult } from '@cleocode/contracts';
|
|
22
29
|
import {
|
|
30
|
+
excludeHookFileFromGit,
|
|
31
|
+
excludeLocalSettingsFromGit,
|
|
23
32
|
isUserHomeDir,
|
|
24
33
|
syncClaudeCodeHeavyCommandHook,
|
|
25
34
|
} from '../shared/heavy-command-hook-install.js';
|
|
@@ -29,16 +38,25 @@ import {
|
|
|
29
38
|
} from '../shared/hook-template-installer.js';
|
|
30
39
|
import {
|
|
31
40
|
appendHookEntry,
|
|
41
|
+
assertProjectInstructionScope,
|
|
32
42
|
claudeSettingsPath,
|
|
33
43
|
hasCleoHook,
|
|
34
44
|
hookMap,
|
|
35
45
|
isPlainObject,
|
|
46
|
+
projectClaudeCommandsDir,
|
|
47
|
+
projectClaudeHooksDir,
|
|
48
|
+
projectClaudeSettingsPath,
|
|
49
|
+
UserGlobalClaudeConfigError,
|
|
36
50
|
} from './paths.js';
|
|
37
51
|
|
|
38
|
-
/**
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
52
|
+
/** The plugin the install enables in the project's Claude settings. */
|
|
53
|
+
const CLEO_PLUGIN_KEY = 'cleo@cleocode';
|
|
54
|
+
|
|
55
|
+
/** The PreCompact hook scripts the install copies, relative to the project. */
|
|
56
|
+
const PROJECT_HOOK_FILES = [
|
|
57
|
+
'.claude/hooks/precompact-safestop.sh',
|
|
58
|
+
'.claude/hooks/cleo-precompact-core.sh',
|
|
59
|
+
] as const;
|
|
42
60
|
|
|
43
61
|
/** Resolve the commands directory bundled with this adapter. */
|
|
44
62
|
function getAdapterCommandsDir(): string {
|
|
@@ -52,19 +70,25 @@ function getAdapterCommandsDir(): string {
|
|
|
52
70
|
*
|
|
53
71
|
* Manages CLEO's integration with Claude Code by:
|
|
54
72
|
* 1. Ensuring CLAUDE.md contains @-references to CLEO instruction files
|
|
55
|
-
* 2. Installing adapter-provided commands to .claude/commands/
|
|
56
|
-
* 3. Registering the brain observation plugin in
|
|
57
|
-
*
|
|
73
|
+
* 2. Installing adapter-provided commands to the project's .claude/commands/
|
|
74
|
+
* 3. Registering the brain observation plugin in the project's
|
|
75
|
+
* `.claude/settings.local.json`
|
|
76
|
+
* 4. Installing PreCompact hook templates in the project's `.claude/hooks/`
|
|
77
|
+
* and wiring them into the project's `.claude/settings.local.json`
|
|
58
78
|
* 5. Installing the heavy-command `PreToolUse` hook in the project's
|
|
59
79
|
* `.claude/settings.local.json`, when `options.heavyCommandHook` is set
|
|
60
80
|
*
|
|
61
81
|
* @remarks
|
|
62
82
|
* Installation is idempotent -- running install multiple times on the same
|
|
63
83
|
* project produces the same result. The provider disables the legacy
|
|
64
|
-
* `claude-mem\@thedotmack` plugin if present and enables the
|
|
65
|
-
* `cleo\@cleocode` plugin instead.
|
|
84
|
+
* `claude-mem\@thedotmack` plugin for the project if present and enables the
|
|
85
|
+
* unified `cleo\@cleocode` plugin instead. Nothing is written to the
|
|
86
|
+
* user-global Claude config (T13227).
|
|
66
87
|
*/
|
|
67
88
|
export class ClaudeCodeInstallProvider implements AdapterInstallProvider {
|
|
89
|
+
/** The project the last {@link install} ran for; {@link isInstalled} reads its settings. */
|
|
90
|
+
private projectDir: string | null = null;
|
|
91
|
+
|
|
68
92
|
/**
|
|
69
93
|
* Install CLEO into a Claude Code project.
|
|
70
94
|
*
|
|
@@ -73,46 +97,64 @@ export class ClaudeCodeInstallProvider implements AdapterInstallProvider {
|
|
|
73
97
|
*/
|
|
74
98
|
async install(options: InstallOptions): Promise<InstallResult> {
|
|
75
99
|
const { projectDir } = options;
|
|
100
|
+
this.projectDir = projectDir;
|
|
76
101
|
const installedAt = new Date().toISOString();
|
|
77
102
|
let instructionFileUpdated = false;
|
|
78
103
|
const details: Record<string, unknown> = {};
|
|
79
104
|
|
|
80
|
-
// Step 1: Ensure CLAUDE.md has @-references via CAAMP canonical API (T1919)
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
105
|
+
// Step 1: Ensure CLAUDE.md has @-references via CAAMP canonical API (T1919).
|
|
106
|
+
// Never the user-global ~/.claude/CLAUDE.md (T13227).
|
|
107
|
+
try {
|
|
108
|
+
assertProjectInstructionScope(projectDir);
|
|
109
|
+
const instructionResult = await ensureProviderInstructionFile('claude-code', projectDir, {});
|
|
110
|
+
instructionFileUpdated = instructionResult.action !== 'intact';
|
|
111
|
+
if (instructionFileUpdated) {
|
|
112
|
+
details.instructionFile = instructionResult.filePath;
|
|
113
|
+
}
|
|
114
|
+
} catch (err) {
|
|
115
|
+
if (!(err instanceof UserGlobalClaudeConfigError)) throw err;
|
|
116
|
+
details.instructionFile = 'skipped';
|
|
85
117
|
}
|
|
86
118
|
|
|
87
|
-
// Step 2: Install adapter-provided commands to .claude/commands/
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
119
|
+
// Step 2: Install adapter-provided commands to .claude/commands/ — never
|
|
120
|
+
// into the user-global Claude dir (T13227).
|
|
121
|
+
try {
|
|
122
|
+
const commandsInstalled = this.installCommands(projectDir);
|
|
123
|
+
if (commandsInstalled.length > 0) {
|
|
124
|
+
details.commands = commandsInstalled;
|
|
125
|
+
}
|
|
126
|
+
} catch (err) {
|
|
127
|
+
if (!(err instanceof UserGlobalClaudeConfigError)) throw err;
|
|
128
|
+
details.commands = 'skipped';
|
|
91
129
|
}
|
|
92
130
|
|
|
93
131
|
// T12385: settings.json writes are locked + atomic, and a malformed file
|
|
94
132
|
// aborts the write and is reported here — it is never reset to `{}`.
|
|
95
133
|
const settingsErrors: string[] = [];
|
|
96
134
|
|
|
97
|
-
// Step 3: Register plugin in
|
|
135
|
+
// Step 3: Register plugin in the project's .claude/settings.local.json.
|
|
136
|
+
// A user-global target (T13227) is skipped, never written.
|
|
98
137
|
try {
|
|
99
|
-
const pluginResult = await this.registerPlugin();
|
|
138
|
+
const pluginResult = await this.registerPlugin(projectDir);
|
|
100
139
|
if (pluginResult) {
|
|
101
140
|
details.plugin = pluginResult;
|
|
102
141
|
}
|
|
103
142
|
} catch (err) {
|
|
104
|
-
|
|
143
|
+
if (err instanceof UserGlobalClaudeConfigError) details.plugin = 'skipped';
|
|
144
|
+
else settingsErrors.push(err instanceof Error ? err.message : String(err));
|
|
105
145
|
}
|
|
106
146
|
|
|
107
|
-
// Step 4 (T1013): Install PreCompact hook templates
|
|
108
|
-
// command into
|
|
147
|
+
// Step 4 (T1013): Install PreCompact hook templates into the project's
|
|
148
|
+
// .claude/hooks/ + wire the handler command into the project's
|
|
149
|
+
// .claude/settings.local.json `PreCompact` event (T13227).
|
|
109
150
|
try {
|
|
110
|
-
const hookResult = await this.installHookTemplates();
|
|
151
|
+
const hookResult = await this.installHookTemplates(projectDir);
|
|
111
152
|
if (hookResult) {
|
|
112
153
|
details.hookTemplates = hookResult;
|
|
113
154
|
}
|
|
114
155
|
} catch (err) {
|
|
115
|
-
|
|
156
|
+
if (err instanceof UserGlobalClaudeConfigError) details.hookTemplates = 'skipped';
|
|
157
|
+
else settingsErrors.push(err instanceof Error ? err.message : String(err));
|
|
116
158
|
}
|
|
117
159
|
|
|
118
160
|
// Step 5 (T12983): route heavy shell commands through `cleo run`.
|
|
@@ -155,17 +197,25 @@ export class ClaudeCodeInstallProvider implements AdapterInstallProvider {
|
|
|
155
197
|
/**
|
|
156
198
|
* Check whether CLEO is installed in the current environment.
|
|
157
199
|
*
|
|
158
|
-
* Checks for plugin enabled in
|
|
200
|
+
* Checks for the plugin enabled in the project's
|
|
201
|
+
* `.claude/settings.local.json` — the project of the last {@link install},
|
|
202
|
+
* else the working directory (T13227).
|
|
159
203
|
*/
|
|
160
204
|
async isInstalled(): Promise<boolean> {
|
|
161
|
-
|
|
162
|
-
|
|
205
|
+
let settingsPath: string;
|
|
206
|
+
try {
|
|
207
|
+
settingsPath = projectClaudeSettingsPath(this.projectDir ?? process.cwd());
|
|
208
|
+
} catch {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
163
211
|
if (existsSync(settingsPath)) {
|
|
164
212
|
try {
|
|
165
|
-
const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
213
|
+
const settings: unknown = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
|
214
|
+
if (isPlainObject(settings)) {
|
|
215
|
+
const plugins = settings.enabledPlugins;
|
|
216
|
+
if (isPlainObject(plugins) && plugins[CLEO_PLUGIN_KEY] === true) {
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
169
219
|
}
|
|
170
220
|
} catch {
|
|
171
221
|
// Fall through
|
|
@@ -182,8 +232,11 @@ export class ClaudeCodeInstallProvider implements AdapterInstallProvider {
|
|
|
182
232
|
* and default references from the provider registry (T1919).
|
|
183
233
|
*
|
|
184
234
|
* @param projectDir - Project root directory
|
|
235
|
+
* @throws {@link UserGlobalClaudeConfigError} when the project root is inside
|
|
236
|
+
* the user-global Claude config dir (T13227); nothing is written.
|
|
185
237
|
*/
|
|
186
238
|
async ensureInstructionReferences(projectDir: string): Promise<void> {
|
|
239
|
+
assertProjectInstructionScope(projectDir);
|
|
187
240
|
await ensureProviderInstructionFile('claude-code', projectDir, {});
|
|
188
241
|
}
|
|
189
242
|
|
|
@@ -195,6 +248,7 @@ export class ClaudeCodeInstallProvider implements AdapterInstallProvider {
|
|
|
195
248
|
*
|
|
196
249
|
* @param projectDir - Project root directory
|
|
197
250
|
* @returns Array of installed command filenames
|
|
251
|
+
* @throws {@link UserGlobalClaudeConfigError} when the target is user-global
|
|
198
252
|
*/
|
|
199
253
|
private installCommands(projectDir: string): string[] {
|
|
200
254
|
const adapterCommandsDir = getAdapterCommandsDir();
|
|
@@ -202,7 +256,7 @@ export class ClaudeCodeInstallProvider implements AdapterInstallProvider {
|
|
|
202
256
|
return [];
|
|
203
257
|
}
|
|
204
258
|
|
|
205
|
-
const targetDir =
|
|
259
|
+
const targetDir = projectClaudeCommandsDir(projectDir);
|
|
206
260
|
mkdirSync(targetDir, { recursive: true });
|
|
207
261
|
|
|
208
262
|
const installed: string[] = [];
|
|
@@ -219,13 +273,17 @@ export class ClaudeCodeInstallProvider implements AdapterInstallProvider {
|
|
|
219
273
|
}
|
|
220
274
|
|
|
221
275
|
/**
|
|
222
|
-
* Register the CLEO brain plugin in
|
|
276
|
+
* Register the CLEO brain plugin in the project's
|
|
277
|
+
* `.claude/settings.local.json` (T13227: never the user-global settings).
|
|
223
278
|
*
|
|
279
|
+
* @param projectDir - Project root directory
|
|
224
280
|
* @returns Description of what was registered, or null if no change needed
|
|
281
|
+
* @throws {@link UserGlobalClaudeConfigError} when the target is user-global
|
|
225
282
|
*/
|
|
226
|
-
private async registerPlugin(): Promise<string | null> {
|
|
227
|
-
const pluginKey =
|
|
228
|
-
const
|
|
283
|
+
private async registerPlugin(projectDir: string): Promise<string | null> {
|
|
284
|
+
const pluginKey = CLEO_PLUGIN_KEY;
|
|
285
|
+
const settingsPath = projectClaudeSettingsPath(projectDir);
|
|
286
|
+
const wrote = await updateJsonConfigFile(settingsPath, (settings) => {
|
|
229
287
|
const enabledPlugins = settings.enabledPlugins ?? {};
|
|
230
288
|
if (!isPlainObject(enabledPlugins)) {
|
|
231
289
|
throw new Error('settings.json "enabledPlugins" is not an object; not modifying it');
|
|
@@ -244,35 +302,42 @@ export class ClaudeCodeInstallProvider implements AdapterInstallProvider {
|
|
|
244
302
|
return true;
|
|
245
303
|
});
|
|
246
304
|
|
|
247
|
-
|
|
305
|
+
if (!wrote) return null;
|
|
306
|
+
excludeLocalSettingsFromGit(projectDir);
|
|
307
|
+
return `Enabled ${pluginKey} in ${settingsPath}`;
|
|
248
308
|
}
|
|
249
309
|
|
|
250
310
|
/**
|
|
251
311
|
* Install the CLEO PreCompact hook templates for Claude Code (T1013).
|
|
252
312
|
*
|
|
253
|
-
* Writes two files to
|
|
313
|
+
* Writes two files to the project's `.claude/hooks/`:
|
|
254
314
|
* 1. `cleo-precompact-core.sh` — universal CLEO safestop helper (shared
|
|
255
315
|
* across all providers; sourced by the provider-specific shim).
|
|
256
316
|
* 2. `precompact-safestop.sh` — Claude-Code-flavoured wrapper that invokes
|
|
257
317
|
* `cleo memory precompact-flush` and `cleo safestop`.
|
|
258
318
|
*
|
|
259
|
-
* Also registers a `PreCompact` entry in
|
|
260
|
-
* Code runs the hook when
|
|
319
|
+
* Also registers a `PreCompact` entry in the project's
|
|
320
|
+
* `.claude/settings.local.json` so Claude Code runs the hook when
|
|
321
|
+
* auto-compact fires (at 95% context). Files git does not ignore yet are
|
|
322
|
+
* kept out of it with a marked `info/exclude` block. Nothing is written to
|
|
323
|
+
* the user-global Claude config (T13227).
|
|
261
324
|
*
|
|
262
325
|
* Idempotent: subsequent installs skip unchanged files and do not duplicate
|
|
263
|
-
* the settings
|
|
326
|
+
* the settings hook entry.
|
|
264
327
|
*
|
|
328
|
+
* @param projectDir - Project root directory
|
|
265
329
|
* @returns Install summary (paths written + config change description), or
|
|
266
330
|
* `null` when no change was required.
|
|
331
|
+
* @throws {@link UserGlobalClaudeConfigError} when the target is user-global
|
|
267
332
|
*
|
|
268
|
-
* @task T1013
|
|
333
|
+
* @task T1013 @task T13227
|
|
269
334
|
*/
|
|
270
|
-
private async installHookTemplates(): Promise<{
|
|
335
|
+
private async installHookTemplates(projectDir: string): Promise<{
|
|
271
336
|
templates: InstallHookTemplatesResult;
|
|
272
337
|
settingsEntryAdded: boolean;
|
|
273
338
|
} | null> {
|
|
274
|
-
const
|
|
275
|
-
const
|
|
339
|
+
const hooksDir = projectClaudeHooksDir(projectDir);
|
|
340
|
+
const settingsPath = projectClaudeSettingsPath(projectDir);
|
|
276
341
|
|
|
277
342
|
// 1. Copy the bash templates next to each other so `source $SCRIPT_DIR/...` works.
|
|
278
343
|
// Template copy is best-effort so missing/locked filesystems (CI sandboxes,
|
|
@@ -287,10 +352,14 @@ export class ClaudeCodeInstallProvider implements AdapterInstallProvider {
|
|
|
287
352
|
return null;
|
|
288
353
|
}
|
|
289
354
|
|
|
290
|
-
|
|
355
|
+
for (const file of PROJECT_HOOK_FILES) excludeHookFileFromGit(projectDir, file);
|
|
356
|
+
|
|
357
|
+
// 2. Wire the PreCompact event in the project's settings.local.json.
|
|
291
358
|
const settingsEntryAdded = await this.registerPreCompactHook(
|
|
359
|
+
settingsPath,
|
|
292
360
|
join(hooksDir, 'precompact-safestop.sh'),
|
|
293
361
|
);
|
|
362
|
+
if (settingsEntryAdded) excludeLocalSettingsFromGit(projectDir);
|
|
294
363
|
|
|
295
364
|
if (templates.installedFiles.length === 0 && !settingsEntryAdded) {
|
|
296
365
|
return null;
|
|
@@ -300,21 +369,23 @@ export class ClaudeCodeInstallProvider implements AdapterInstallProvider {
|
|
|
300
369
|
}
|
|
301
370
|
|
|
302
371
|
/**
|
|
303
|
-
* Register the PreCompact hook command in
|
|
372
|
+
* Register the PreCompact hook command in the project's
|
|
373
|
+
* `.claude/settings.local.json`.
|
|
304
374
|
*
|
|
305
375
|
* The Claude Code native event name for the canonical `PreCompact` event is
|
|
306
376
|
* `PreCompact` (identity mapping — see `hook-mappings.json`). The entry is
|
|
307
377
|
* tagged with `# cleo-hook` so the uninstall flow can identify and remove
|
|
308
378
|
* our additions without touching user-authored hooks.
|
|
309
379
|
*
|
|
380
|
+
* @param settingsPath - The project settings file (already scope-checked).
|
|
310
381
|
* @param shimPath - Absolute path to the installed `precompact-safestop.sh`.
|
|
311
382
|
* @returns `true` when a new hook entry was written, `false` when an
|
|
312
383
|
* equivalent entry was already present.
|
|
313
384
|
*
|
|
314
385
|
* @task T1013
|
|
315
386
|
*/
|
|
316
|
-
private async registerPreCompactHook(shimPath: string): Promise<boolean> {
|
|
317
|
-
return updateJsonConfigFile(
|
|
387
|
+
private async registerPreCompactHook(settingsPath: string, shimPath: string): Promise<boolean> {
|
|
388
|
+
return updateJsonConfigFile(settingsPath, (settings) => {
|
|
318
389
|
const hooks = hookMap(settings);
|
|
319
390
|
if (hasCleoHook(hooks.PreCompact, 'precompact-safestop.sh')) {
|
|
320
391
|
return false;
|
|
@@ -6,9 +6,11 @@
|
|
|
6
6
|
* @task T5240
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { realpathSync } from 'node:fs';
|
|
9
10
|
import { homedir } from 'node:os';
|
|
10
|
-
import { join } from 'node:path';
|
|
11
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
11
12
|
import type { AdapterPathProvider } from '@cleocode/contracts';
|
|
13
|
+
import { CLAUDE_LOCAL_SETTINGS, isUserHomeDir } from '../shared/heavy-command-hook-install.js';
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
16
|
* Path provider for Anthropic Claude Code CLI.
|
|
@@ -68,6 +70,162 @@ export function claudeSettingsPath(): string {
|
|
|
68
70
|
);
|
|
69
71
|
}
|
|
70
72
|
|
|
73
|
+
/** Volumes on these platforms are case-insensitive by default (APFS, NTFS). */
|
|
74
|
+
const CASE_INSENSITIVE_DEFAULT = process.platform === 'darwin' || process.platform === 'win32';
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* `path` with every existing leading part resolved through symlinks, so two
|
|
78
|
+
* spellings of one location compare equal even before the leaf exists. Uses
|
|
79
|
+
* the native realpath, which returns the case stored on disk, so `~/.Claude`
|
|
80
|
+
* on a case-insensitive volume canonicalizes to `~/.claude` (T13227 review).
|
|
81
|
+
*/
|
|
82
|
+
function canonicalPath(path: string): string {
|
|
83
|
+
const abs = resolve(path);
|
|
84
|
+
try {
|
|
85
|
+
return realpathSync.native(abs);
|
|
86
|
+
} catch {
|
|
87
|
+
const parent = dirname(abs);
|
|
88
|
+
return parent === abs ? abs : join(canonicalPath(parent), basename(abs));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Fold case where volumes are case-insensitive by default, for the parts of
|
|
94
|
+
* a path that do not exist yet (the native realpath cannot fix their case).
|
|
95
|
+
* On a case-sensitive volume there this only refuses more, never less.
|
|
96
|
+
*/
|
|
97
|
+
function comparable(path: string): string {
|
|
98
|
+
return CASE_INSENSITIVE_DEFAULT ? path.toLowerCase() : path;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Whether `path` is `dir` or lies inside it (both canonical). */
|
|
102
|
+
function isAtOrInside(path: string, dir: string): boolean {
|
|
103
|
+
const rel = relative(comparable(dir), comparable(path));
|
|
104
|
+
return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The adapter was asked to write somewhere that is, or lies inside, the
|
|
109
|
+
* user-global Claude Code config (T13227). CLEO never writes there — adding
|
|
110
|
+
* and removing entries alike — so nothing is written and the caller reports
|
|
111
|
+
* the step as skipped.
|
|
112
|
+
*/
|
|
113
|
+
export class UserGlobalClaudeConfigError extends Error {
|
|
114
|
+
/** The path that was refused. */
|
|
115
|
+
readonly path: string;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @param path - the refused target.
|
|
119
|
+
* @param why - which user-global location it hits.
|
|
120
|
+
*/
|
|
121
|
+
constructor(path: string, why: string) {
|
|
122
|
+
super(`refusing to write ${path}: ${why}; CLEO only writes project-level Claude Code config`);
|
|
123
|
+
this.name = 'UserGlobalClaudeConfigError';
|
|
124
|
+
this.path = path;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Refuse `target` when it is user-global Claude Code config: the settings
|
|
130
|
+
* file {@link claudeSettingsPath} resolves (`CLAUDE_SETTINGS`), anything in
|
|
131
|
+
* the Claude config dir (`CLAUDE_HOME`, else `~/.claude`), or anything when
|
|
132
|
+
* the project is the home directory (its `.claude/` IS the user-global dir,
|
|
133
|
+
* and its `CLAUDE.md` is loaded into every session under it).
|
|
134
|
+
*
|
|
135
|
+
* @param projectDir - the project root the write is for.
|
|
136
|
+
* @param target - the file or directory about to be written.
|
|
137
|
+
* @returns `target`, absolute, when it is project-level.
|
|
138
|
+
* @throws {@link UserGlobalClaudeConfigError} otherwise; nothing is written.
|
|
139
|
+
*/
|
|
140
|
+
function assertProjectScoped(projectDir: string, target: string): string {
|
|
141
|
+
const abs = resolve(target);
|
|
142
|
+
if (isUserHomeDir(projectDir)) {
|
|
143
|
+
throw new UserGlobalClaudeConfigError(abs, 'the project is the home directory');
|
|
144
|
+
}
|
|
145
|
+
const canonical = canonicalPath(abs);
|
|
146
|
+
if (comparable(canonical) === comparable(canonicalPath(claudeSettingsPath()))) {
|
|
147
|
+
throw new UserGlobalClaudeConfigError(abs, 'it is the user-global Claude settings file');
|
|
148
|
+
}
|
|
149
|
+
if (isAtOrInside(canonical, canonicalPath(new ClaudeCodePathProvider().getProviderDir()))) {
|
|
150
|
+
throw new UserGlobalClaudeConfigError(abs, 'it is inside the user-global Claude config dir');
|
|
151
|
+
}
|
|
152
|
+
return abs;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The project's per-machine Claude Code settings file,
|
|
157
|
+
* `<project>/.claude/settings.local.json` — the only settings file the
|
|
158
|
+
* adapter's hook and plugin writers touch (T13227, the T12983/T13124 rule:
|
|
159
|
+
* project-level only, never the user-global `~/.claude/settings.json`).
|
|
160
|
+
*
|
|
161
|
+
* @param projectDir - the project root.
|
|
162
|
+
* @returns the absolute settings path.
|
|
163
|
+
* @throws {@link UserGlobalClaudeConfigError} when it would be user-global.
|
|
164
|
+
*
|
|
165
|
+
* @example
|
|
166
|
+
* ```typescript
|
|
167
|
+
* const target = projectClaudeSettingsPath('/repo'); // /repo/.claude/settings.local.json
|
|
168
|
+
* ```
|
|
169
|
+
*/
|
|
170
|
+
export function projectClaudeSettingsPath(projectDir: string): string {
|
|
171
|
+
return assertProjectScoped(projectDir, join(projectDir, CLAUDE_LOCAL_SETTINGS));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The project's Claude Code hook-script dir, `<project>/.claude/hooks`, where
|
|
176
|
+
* the PreCompact templates are copied (T13227).
|
|
177
|
+
*
|
|
178
|
+
* @param projectDir - the project root.
|
|
179
|
+
* @returns the absolute hooks dir.
|
|
180
|
+
* @throws {@link UserGlobalClaudeConfigError} when it would be user-global.
|
|
181
|
+
*
|
|
182
|
+
* @example
|
|
183
|
+
* ```typescript
|
|
184
|
+
* const dir = projectClaudeHooksDir('/repo'); // /repo/.claude/hooks
|
|
185
|
+
* ```
|
|
186
|
+
*/
|
|
187
|
+
export function projectClaudeHooksDir(projectDir: string): string {
|
|
188
|
+
return assertProjectScoped(projectDir, join(projectDir, '.claude', 'hooks'));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Refuse a project whose `CLAUDE.md` would be user-global in effect (T13227
|
|
193
|
+
* review): a root inside the user-global Claude config dir (in any spelling),
|
|
194
|
+
* where it is Claude Code's user-global memory file `~/.claude/CLAUDE.md`, or
|
|
195
|
+
* the home directory itself — Claude Code loads `CLAUDE.md` from the working
|
|
196
|
+
* directory up through every ancestor, so `~/CLAUDE.md` reaches every session
|
|
197
|
+
* under `$HOME`.
|
|
198
|
+
*
|
|
199
|
+
* @param projectDir - the project root.
|
|
200
|
+
* @throws {@link UserGlobalClaudeConfigError} when the project root is the
|
|
201
|
+
* home directory or inside the user-global Claude config dir.
|
|
202
|
+
*
|
|
203
|
+
* @example
|
|
204
|
+
* ```typescript
|
|
205
|
+
* assertProjectInstructionScope('/repo'); // ok
|
|
206
|
+
* ```
|
|
207
|
+
*/
|
|
208
|
+
export function assertProjectInstructionScope(projectDir: string): void {
|
|
209
|
+
assertProjectScoped(projectDir, join(projectDir, 'CLAUDE.md'));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The project's Claude Code commands dir, `<project>/.claude/commands`, where
|
|
214
|
+
* the adapter's slash commands are copied (T13227).
|
|
215
|
+
*
|
|
216
|
+
* @param projectDir - the project root.
|
|
217
|
+
* @returns the absolute commands dir.
|
|
218
|
+
* @throws {@link UserGlobalClaudeConfigError} when it would be user-global.
|
|
219
|
+
*
|
|
220
|
+
* @example
|
|
221
|
+
* ```typescript
|
|
222
|
+
* const dir = projectClaudeCommandsDir('/repo'); // /repo/.claude/commands
|
|
223
|
+
* ```
|
|
224
|
+
*/
|
|
225
|
+
export function projectClaudeCommandsDir(projectDir: string): string {
|
|
226
|
+
return assertProjectScoped(projectDir, join(projectDir, '.claude', 'commands'));
|
|
227
|
+
}
|
|
228
|
+
|
|
71
229
|
export {
|
|
72
230
|
appendHookEntry,
|
|
73
231
|
CLEO_HOOK_MARKER,
|
|
@@ -15,8 +15,9 @@
|
|
|
15
15
|
import { join } from 'node:path';
|
|
16
16
|
import { ensureProviderInstructionFile } from '@cleocode/caamp';
|
|
17
17
|
import type { AdapterInstallProvider, InstallOptions, InstallResult } from '@cleocode/contracts';
|
|
18
|
-
import { isUserHomeDir,
|
|
18
|
+
import { isUserHomeDir, syncCodexHeavyCommandHook } from '../shared/heavy-command-hook-install.js';
|
|
19
19
|
import { getCleoTemplatesTildePath } from '../shared/paths.js';
|
|
20
|
+
import { ensureProjectInstructionFile } from '../shared/project-instruction-file.js';
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* Install provider for Codex CLI.
|
|
@@ -50,15 +51,16 @@ export class CodexInstallProvider implements AdapterInstallProvider {
|
|
|
50
51
|
const details: Record<string, unknown> = {};
|
|
51
52
|
|
|
52
53
|
// Ensure AGENTS.md has CLEO @-references via CAAMP canonical API.
|
|
53
|
-
const result = await
|
|
54
|
+
const result = await ensureProjectInstructionFile('codex', projectDir, {
|
|
54
55
|
scope: 'project',
|
|
55
56
|
references: [`@${getCleoTemplatesTildePath()}/CLEO-INJECTION.md`, '@.cleo/memory-bridge.md'],
|
|
56
57
|
});
|
|
57
58
|
|
|
58
|
-
const instructionFileUpdated = result.action !== 'intact';
|
|
59
|
+
const instructionFileUpdated = result !== null && result.action !== 'intact';
|
|
59
60
|
if (instructionFileUpdated) {
|
|
60
61
|
details.instructionFile = join(projectDir, result.instructFile);
|
|
61
62
|
}
|
|
63
|
+
if (result === null) details.instructionFile = 'skipped'; // T13227: $HOME project
|
|
62
64
|
|
|
63
65
|
// T12983: route heavy shell commands through `cleo run`.
|
|
64
66
|
let success = true;
|
|
@@ -66,9 +68,8 @@ export class CodexInstallProvider implements AdapterInstallProvider {
|
|
|
66
68
|
details.heavyCommandHook = 'skipped';
|
|
67
69
|
} else if (options.heavyCommandHook !== undefined) {
|
|
68
70
|
try {
|
|
69
|
-
details.heavyCommandHook = await
|
|
70
|
-
|
|
71
|
-
'codex',
|
|
71
|
+
details.heavyCommandHook = await syncCodexHeavyCommandHook(
|
|
72
|
+
projectDir,
|
|
72
73
|
options.heavyCommandHook,
|
|
73
74
|
);
|
|
74
75
|
} catch (err) {
|
|
@@ -25,11 +25,13 @@ import {
|
|
|
25
25
|
updateJsonConfigFile,
|
|
26
26
|
} from '@cleocode/caamp';
|
|
27
27
|
import type { AdapterInstallProvider, InstallOptions, InstallResult } from '@cleocode/contracts';
|
|
28
|
+
import { isUserHomeDir } from '../shared/heavy-command-hook-install.js';
|
|
28
29
|
import {
|
|
29
30
|
type InstallHookTemplatesResult,
|
|
30
31
|
installProviderHookTemplates,
|
|
31
32
|
} from '../shared/hook-template-installer.js';
|
|
32
33
|
import { getCleoTemplatesTildePath } from '../shared/paths.js';
|
|
34
|
+
import { ensureProjectInstructionFile } from '../shared/project-instruction-file.js';
|
|
33
35
|
|
|
34
36
|
/** MDC frontmatter for the CLEO-owned `.cursor/rules/cleo.mdc` rule file. */
|
|
35
37
|
const CLEO_MDC_FRONTMATTER = [
|
|
@@ -76,11 +78,17 @@ export class CursorInstallProvider implements AdapterInstallProvider {
|
|
|
76
78
|
// Step 1: Ensure instruction files have @-references via CAAMP canonical API (T1919).
|
|
77
79
|
// ensureProviderInstructionFile handles the primary AGENTS.md (registry instructFile);
|
|
78
80
|
// updateInstructionFiles also manages cursor-specific MDC + legacy .cursorrules formats.
|
|
79
|
-
const instructionResult = await
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
details.instructionFiles =
|
|
81
|
+
const instructionResult = await ensureProjectInstructionFile('cursor', projectDir, {});
|
|
82
|
+
if (instructionResult === null) {
|
|
83
|
+
// T13227: a $HOME project gets no instruction files (loaded by every
|
|
84
|
+
// session under it); the Cursor rule files are skipped with it.
|
|
85
|
+
details.instructionFiles = 'skipped';
|
|
86
|
+
} else {
|
|
87
|
+
const cursorFilesUpdated = await this.updateInstructionFiles(projectDir);
|
|
88
|
+
instructionFileUpdated = instructionResult.action !== 'intact' || cursorFilesUpdated;
|
|
89
|
+
if (instructionFileUpdated) {
|
|
90
|
+
details.instructionFiles = this.getUpdatedFileList(projectDir);
|
|
91
|
+
}
|
|
84
92
|
}
|
|
85
93
|
|
|
86
94
|
// Step 2 (T1013): Install PreCompact hook templates + wire the handler
|
|
@@ -88,14 +96,20 @@ export class CursorInstallProvider implements AdapterInstallProvider {
|
|
|
88
96
|
// T12385: a malformed hooks.json aborts the write and is reported; it is
|
|
89
97
|
// never reset to `{}`.
|
|
90
98
|
let hooksError: string | null = null;
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
99
|
+
if (isUserHomeDir(projectDir)) {
|
|
100
|
+
// T13257: at $HOME, .cursor/hooks.json and .cursor/hooks/ ARE Cursor's
|
|
101
|
+
// user-global hooks; CLEO installs them per project only.
|
|
102
|
+
details.hookTemplates = 'skipped';
|
|
103
|
+
} else {
|
|
104
|
+
try {
|
|
105
|
+
const hookResult = await this.installHookTemplates(projectDir);
|
|
106
|
+
if (hookResult) {
|
|
107
|
+
details.hookTemplates = hookResult;
|
|
108
|
+
}
|
|
109
|
+
} catch (err) {
|
|
110
|
+
hooksError = err instanceof Error ? err.message : String(err);
|
|
111
|
+
details.hooksError = hooksError;
|
|
95
112
|
}
|
|
96
|
-
} catch (err) {
|
|
97
|
-
hooksError = err instanceof Error ? err.message : String(err);
|
|
98
|
-
details.hooksError = hooksError;
|
|
99
113
|
}
|
|
100
114
|
|
|
101
115
|
return {
|
|
@@ -14,6 +14,7 @@ import { join } from 'node:path';
|
|
|
14
14
|
import { ensureProviderInstructionFile } from '@cleocode/caamp';
|
|
15
15
|
import type { AdapterInstallProvider, InstallOptions, InstallResult } from '@cleocode/contracts';
|
|
16
16
|
import { getCleoTemplatesTildePath } from '../shared/paths.js';
|
|
17
|
+
import { ensureProjectInstructionFile } from '../shared/project-instruction-file.js';
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Install provider for Gemini CLI.
|
|
@@ -43,15 +44,16 @@ export class GeminiCliInstallProvider implements AdapterInstallProvider {
|
|
|
43
44
|
const installedAt = new Date().toISOString();
|
|
44
45
|
const details: Record<string, unknown> = {};
|
|
45
46
|
|
|
46
|
-
const result = await
|
|
47
|
+
const result = await ensureProjectInstructionFile('gemini-cli', projectDir, {
|
|
47
48
|
references: [`@${getCleoTemplatesTildePath()}/CLEO-INJECTION.md`, '@.cleo/memory-bridge.md'],
|
|
48
49
|
scope: 'project',
|
|
49
50
|
});
|
|
50
51
|
|
|
51
|
-
const instructionFileUpdated = result.action !== 'intact';
|
|
52
|
+
const instructionFileUpdated = result !== null && result.action !== 'intact';
|
|
52
53
|
if (instructionFileUpdated) {
|
|
53
54
|
details.instructionFile = result.filePath;
|
|
54
55
|
}
|
|
56
|
+
if (result === null) details.instructionFile = 'skipped'; // T13227: $HOME project
|
|
55
57
|
|
|
56
58
|
return {
|
|
57
59
|
success: true,
|