@cleocode/adapters 2026.10.4 → 2026.10.5

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.
Files changed (37) hide show
  1. package/dist/index.js +1608 -1380
  2. package/dist/index.js.map +3 -3
  3. package/dist/providers/claude-code/adapter.d.ts.map +1 -1
  4. package/dist/providers/claude-code/hooks.d.ts +43 -17
  5. package/dist/providers/claude-code/hooks.d.ts.map +1 -1
  6. package/dist/providers/claude-code/install.d.ts +42 -14
  7. package/dist/providers/claude-code/install.d.ts.map +1 -1
  8. package/dist/providers/claude-code/paths.d.ts +77 -0
  9. package/dist/providers/claude-code/paths.d.ts.map +1 -1
  10. package/dist/providers/codex/install.d.ts.map +1 -1
  11. package/dist/providers/cursor/install.d.ts.map +1 -1
  12. package/dist/providers/gemini-cli/install.d.ts.map +1 -1
  13. package/dist/providers/kimi/install.d.ts.map +1 -1
  14. package/dist/providers/openai-sdk/install.d.ts.map +1 -1
  15. package/dist/providers/opencode/install.d.ts.map +1 -1
  16. package/dist/providers/pi/install.d.ts.map +1 -1
  17. package/dist/providers/shared/heavy-command-hook-install.d.ts.map +1 -1
  18. package/dist/providers/shared/project-instruction-file.d.ts +30 -0
  19. package/dist/providers/shared/project-instruction-file.d.ts.map +1 -0
  20. package/package.json +5 -5
  21. package/src/__tests__/claude-code-adapter.test.ts +11 -6
  22. package/src/providers/claude-code/__tests__/hooks-install.test.ts +20 -17
  23. package/src/providers/claude-code/__tests__/no-user-global-write.test.ts +293 -0
  24. package/src/providers/claude-code/adapter.ts +3 -1
  25. package/src/providers/claude-code/hooks.ts +138 -51
  26. package/src/providers/claude-code/install.ts +123 -52
  27. package/src/providers/claude-code/paths.ts +159 -1
  28. package/src/providers/codex/install.ts +4 -2
  29. package/src/providers/cursor/install.ts +26 -12
  30. package/src/providers/gemini-cli/install.ts +4 -2
  31. package/src/providers/kimi/install.ts +4 -2
  32. package/src/providers/openai-sdk/install.ts +5 -2
  33. package/src/providers/opencode/install.ts +4 -2
  34. package/src/providers/pi/install.ts +4 -2
  35. package/src/providers/shared/__tests__/heavy-command-hook-install.test.ts +20 -0
  36. package/src/providers/shared/heavy-command-hook-install.ts +6 -1
  37. 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 ~/.claude/settings.json
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
- /** Resolve Claude Code's config directory, honoring the documented CI override. */
39
- function getClaudeConfigDir(): string {
40
- return process.env['CLAUDE_HOME'] ?? join(homedir(), '.claude');
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 ~/.claude/settings.json
57
- * 4. Installing PreCompact hook templates
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 unified
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
- const instructionResult = await ensureProviderInstructionFile('claude-code', projectDir, {});
82
- instructionFileUpdated = instructionResult.action !== 'intact';
83
- if (instructionFileUpdated) {
84
- details.instructionFile = instructionResult.filePath;
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
- const commandsInstalled = this.installCommands(projectDir);
89
- if (commandsInstalled.length > 0) {
90
- details.commands = commandsInstalled;
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 ~/.claude/settings.json
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
- settingsErrors.push(err instanceof Error ? err.message : String(err));
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 + wire the handler
108
- // command into ~/.claude/settings.json's `PreCompact` event.
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
- settingsErrors.push(err instanceof Error ? err.message : String(err));
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 ~/.claude/settings.json.
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
- // Check ~/.claude/settings.json for plugin registration
162
- const settingsPath = claudeSettingsPath();
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
- const plugins = settings.enabledPlugins as Record<string, boolean> | undefined;
167
- if (plugins && plugins['cleo@cleocode'] === true) {
168
- return true;
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 = join(projectDir, '.claude', 'commands');
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 ~/.claude/settings.json.
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 = 'cleo@cleocode';
228
- const wrote = await updateJsonConfigFile(claudeSettingsPath(), (settings) => {
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
- return wrote ? `Enabled ${pluginKey} in ~/.claude/settings.json` : null;
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 `~/.claude/hooks/`:
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 `~/.claude/settings.json` so Claude
260
- * Code runs the hook when auto-compact fires (at 95% context).
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.json hook entry.
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 claudeDir = getClaudeConfigDir();
275
- const hooksDir = join(claudeDir, 'hooks');
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
- // 2. Wire the PreCompact event in ~/.claude/settings.json.
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 `~/.claude/settings.json`.
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(claudeSettingsPath(), (settings) => {
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,
@@ -17,6 +17,7 @@ import { ensureProviderInstructionFile } from '@cleocode/caamp';
17
17
  import type { AdapterInstallProvider, InstallOptions, InstallResult } from '@cleocode/contracts';
18
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 ensureProviderInstructionFile('codex', projectDir, {
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;
@@ -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 ensureProviderInstructionFile('cursor', projectDir, {});
80
- const cursorFilesUpdated = await this.updateInstructionFiles(projectDir);
81
- instructionFileUpdated = instructionResult.action !== 'intact' || cursorFilesUpdated;
82
- if (instructionFileUpdated) {
83
- details.instructionFiles = this.getUpdatedFileList(projectDir);
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
- try {
92
- const hookResult = await this.installHookTemplates(projectDir);
93
- if (hookResult) {
94
- details.hookTemplates = hookResult;
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 ensureProviderInstructionFile('gemini-cli', projectDir, {
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,