@cleocode/adapters 2026.9.19 → 2026.9.21

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 (71) hide show
  1. package/dist/cant-context.d.ts +7 -5
  2. package/dist/cant-context.d.ts.map +1 -1
  3. package/dist/index.js +748 -594
  4. package/dist/index.js.map +3 -3
  5. package/dist/providers/claude-code/adapter.d.ts.map +1 -1
  6. package/dist/providers/claude-code/hooks.d.ts +18 -2
  7. package/dist/providers/claude-code/hooks.d.ts.map +1 -1
  8. package/dist/providers/claude-code/install.d.ts.map +1 -1
  9. package/dist/providers/claude-code/paths.d.ts +96 -0
  10. package/dist/providers/claude-code/paths.d.ts.map +1 -1
  11. package/dist/providers/claude-code/spawn.d.ts +2 -2
  12. package/dist/providers/claude-code/spawn.d.ts.map +1 -1
  13. package/dist/providers/claude-sdk/spawn.d.ts +1 -1
  14. package/dist/providers/codex/adapter.d.ts.map +1 -1
  15. package/dist/providers/codex/spawn.d.ts +1 -1
  16. package/dist/providers/codex/spawn.d.ts.map +1 -1
  17. package/dist/providers/cursor/install.d.ts +11 -22
  18. package/dist/providers/cursor/install.d.ts.map +1 -1
  19. package/dist/providers/gemini-cli/adapter.d.ts.map +1 -1
  20. package/dist/providers/gemini-cli/spawn.d.ts +1 -1
  21. package/dist/providers/gemini-cli/spawn.d.ts.map +1 -1
  22. package/dist/providers/kimi/adapter.d.ts.map +1 -1
  23. package/dist/providers/kimi/spawn.d.ts.map +1 -1
  24. package/dist/providers/opencode/adapter.d.ts.map +1 -1
  25. package/dist/providers/opencode/spawn.d.ts +3 -2
  26. package/dist/providers/opencode/spawn.d.ts.map +1 -1
  27. package/dist/providers/pi/adapter.d.ts +1 -1
  28. package/dist/providers/pi/adapter.d.ts.map +1 -1
  29. package/dist/providers/pi/spawn.d.ts +3 -3
  30. package/dist/providers/pi/spawn.d.ts.map +1 -1
  31. package/dist/providers/shared/agent-spawn-wrapper.d.ts +5 -0
  32. package/dist/providers/shared/agent-spawn-wrapper.d.ts.map +1 -1
  33. package/dist/providers/shared/cli-spawn.d.ts +28 -0
  34. package/dist/providers/shared/cli-spawn.d.ts.map +1 -0
  35. package/dist/providers/shared/credentials.d.ts +2 -2
  36. package/dist/providers/shared/credentials.d.ts.map +1 -1
  37. package/dist/providers/shared/prompt-file.d.ts +32 -0
  38. package/dist/providers/shared/prompt-file.d.ts.map +1 -0
  39. package/package.json +5 -5
  40. package/src/__tests__/agent-spawn-wrapper-win32.test.ts +42 -0
  41. package/src/__tests__/cant-context.test.ts +89 -37
  42. package/src/__tests__/cli-spawn-win32.test.ts +105 -0
  43. package/src/__tests__/spawn-prompt-file.test.ts +37 -0
  44. package/src/cant-context.ts +11 -15
  45. package/src/providers/claude-code/__tests__/adapter.test.ts +1 -0
  46. package/src/providers/claude-code/__tests__/hooks-install.test.ts +63 -2
  47. package/src/providers/claude-code/adapter.ts +6 -11
  48. package/src/providers/claude-code/hooks.ts +60 -89
  49. package/src/providers/claude-code/install.ts +70 -96
  50. package/src/providers/claude-code/paths.ts +155 -0
  51. package/src/providers/claude-code/spawn.ts +11 -25
  52. package/src/providers/claude-sdk/spawn.ts +1 -1
  53. package/src/providers/codex/adapter.ts +6 -11
  54. package/src/providers/codex/spawn.ts +17 -31
  55. package/src/providers/cursor/__tests__/adapter.test.ts +7 -0
  56. package/src/providers/cursor/__tests__/hooks-install.test.ts +36 -0
  57. package/src/providers/cursor/install.ts +86 -133
  58. package/src/providers/gemini-cli/adapter.ts +6 -11
  59. package/src/providers/gemini-cli/spawn.ts +13 -18
  60. package/src/providers/kimi/adapter.ts +6 -11
  61. package/src/providers/kimi/spawn.ts +3 -11
  62. package/src/providers/opencode/__tests__/spawn-prompt-delivery.test.ts +123 -0
  63. package/src/providers/opencode/adapter.ts +6 -11
  64. package/src/providers/opencode/spawn.ts +23 -25
  65. package/src/providers/pi/adapter.ts +8 -18
  66. package/src/providers/pi/spawn.ts +15 -33
  67. package/src/providers/shared/__tests__/credentials-cleo-home.test.ts +42 -0
  68. package/src/providers/shared/agent-spawn-wrapper.ts +22 -2
  69. package/src/providers/shared/cli-spawn.ts +41 -0
  70. package/src/providers/shared/credentials.ts +6 -5
  71. package/src/providers/shared/prompt-file.ts +44 -0
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Tests cover:
5
5
  * - discoverCantFiles: finds .cant files, handles missing dirs
6
- * - resolveThreeTierPaths: XDG-compliant paths with env var overrides
6
+ * - resolveThreeTierPaths: tiers resolve through @cleocode/paths on every platform
7
7
  * - discoverCantFilesMultiTier: 3-tier merge with override semantics
8
8
  * - readMemoryBridge: reads file, handles missing/empty
9
9
  * - buildMemoryBridgeBlock: wraps content in labeled section
@@ -14,9 +14,10 @@
14
14
  */
15
15
 
16
16
  import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
17
- import { tmpdir } from 'node:os';
17
+ import { homedir, tmpdir } from 'node:os';
18
18
  import { join } from 'node:path';
19
- import { afterEach, beforeEach, describe, expect, it } from 'vitest';
19
+ import { getCleoHome } from '@cleocode/paths';
20
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
20
21
 
21
22
  import {
22
23
  buildCantEnrichedPrompt,
@@ -24,6 +25,7 @@ import {
24
25
  buildMentalModelInjection,
25
26
  discoverCantFiles,
26
27
  discoverCantFilesMultiTier,
28
+ readIdentityFile,
27
29
  readMemoryBridge,
28
30
  resolveThreeTierPaths,
29
31
  } from '../cant-context.js';
@@ -68,33 +70,81 @@ describe('discoverCantFiles', () => {
68
70
  // ---------------------------------------------------------------------------
69
71
 
70
72
  describe('resolveThreeTierPaths', () => {
73
+ const PLATFORM = Object.getOwnPropertyDescriptor(process, 'platform');
74
+
75
+ function stubPlatform(platform: NodeJS.Platform): void {
76
+ Object.defineProperty(process, 'platform', { value: platform, configurable: true });
77
+ }
78
+
79
+ beforeEach(() => {
80
+ vi.stubEnv('CLEO_HOME', undefined);
81
+ vi.stubEnv('CLEO_CONFIG_HOME', undefined);
82
+ });
83
+
84
+ afterEach(() => {
85
+ if (PLATFORM) Object.defineProperty(process, 'platform', PLATFORM);
86
+ vi.unstubAllEnvs();
87
+ });
88
+
71
89
  it('returns project tier pointing to .cleo/cant/', () => {
72
90
  const paths = resolveThreeTierPaths('/my/project');
73
91
  expect(paths.project).toBe(join('/my/project', '.cleo', 'cant'));
74
92
  });
75
93
 
76
- it('respects XDG_DATA_HOME for global tier', () => {
77
- const originalXdg = process.env['XDG_DATA_HOME'];
78
- process.env['XDG_DATA_HOME'] = '/custom/data';
79
- try {
80
- const paths = resolveThreeTierPaths('/my/project');
81
- expect(paths.global).toBe(join('/custom/data', 'cleo', 'cant'));
82
- } finally {
83
- if (originalXdg) process.env['XDG_DATA_HOME'] = originalXdg;
84
- else delete process.env['XDG_DATA_HOME'];
85
- }
86
- });
87
-
88
- it('respects XDG_CONFIG_HOME for user tier', () => {
89
- const originalXdg = process.env['XDG_CONFIG_HOME'];
90
- process.env['XDG_CONFIG_HOME'] = '/custom/config';
91
- try {
92
- const paths = resolveThreeTierPaths('/my/project');
93
- expect(paths.user).toBe(join('/custom/config', 'cleo', 'cant'));
94
- } finally {
95
- if (originalXdg) process.env['XDG_CONFIG_HOME'] = originalXdg;
96
- else delete process.env['XDG_CONFIG_HOME'];
97
- }
94
+ it('global tier is <getCleoHome()>/cant and user tier is <CLEO config>/cant', () => {
95
+ vi.stubEnv('CLEO_HOME', '/opt/cleo-data');
96
+ vi.stubEnv('CLEO_CONFIG_HOME', '/opt/cleo-config');
97
+ const paths = resolveThreeTierPaths('/my/project');
98
+ expect(paths.global).toBe(join('/opt/cleo-data', 'cant'));
99
+ expect(paths.user).toBe(join('/opt/cleo-config', 'cant'));
100
+ });
101
+
102
+ it('macOS: global and user tiers are under ~/Library, not the XDG dirs (T12602)', () => {
103
+ stubPlatform('darwin');
104
+ vi.stubEnv('XDG_DATA_HOME', '/custom/data');
105
+ vi.stubEnv('XDG_CONFIG_HOME', '/custom/config');
106
+ const paths = resolveThreeTierPaths('/my/project');
107
+ expect(paths.global).toBe(join(getCleoHome(), 'cant'));
108
+ expect(paths.global).toBe(join(homedir(), 'Library', 'Application Support', 'cleo', 'cant'));
109
+ expect(paths.user).toBe(join(homedir(), 'Library', 'Preferences', 'cleo', 'cant'));
110
+ });
111
+
112
+ it('Windows: global tier is under %LOCALAPPDATA% (T12602)', () => {
113
+ stubPlatform('win32');
114
+ vi.stubEnv('LOCALAPPDATA', 'C:\\Users\\me\\AppData\\Local');
115
+ vi.stubEnv('XDG_DATA_HOME', '/custom/data');
116
+ const paths = resolveThreeTierPaths('/my/project');
117
+ expect(paths.global).toBe(join(getCleoHome(), 'cant'));
118
+ expect(paths.global.startsWith('C:\\Users\\me\\AppData\\Local')).toBe(true);
119
+ });
120
+
121
+ it('Linux: honours XDG_DATA_HOME and XDG_CONFIG_HOME', () => {
122
+ stubPlatform('linux');
123
+ vi.stubEnv('XDG_DATA_HOME', '/custom/data');
124
+ vi.stubEnv('XDG_CONFIG_HOME', '/custom/config');
125
+ const paths = resolveThreeTierPaths('/my/project');
126
+ expect(paths.global).toBe(join('/custom/data', 'cleo', 'cant'));
127
+ expect(paths.user).toBe(join('/custom/config', 'cleo', 'cant'));
128
+ });
129
+ });
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // readIdentityFile — global tier (T12602)
133
+ // ---------------------------------------------------------------------------
134
+
135
+ describe('readIdentityFile', () => {
136
+ afterEach(() => {
137
+ vi.unstubAllEnvs();
138
+ });
139
+
140
+ it('reads the global CLEOOS-IDENTITY.md from getCleoHome(), not $XDG_DATA_HOME/cleo', () => {
141
+ const cleoHome = join(tempDir, 'cleo-home');
142
+ mkdirSync(cleoHome, { recursive: true });
143
+ writeFileSync(join(cleoHome, 'CLEOOS-IDENTITY.md'), 'global identity');
144
+ vi.stubEnv('CLEO_HOME', cleoHome);
145
+ vi.stubEnv('XDG_DATA_HOME', join(tempDir, 'empty-xdg'));
146
+
147
+ expect(readIdentityFile(join(tempDir, 'project'))).toBe('global identity');
98
148
  });
99
149
  });
100
150
 
@@ -103,22 +153,24 @@ describe('resolveThreeTierPaths', () => {
103
153
  // ---------------------------------------------------------------------------
104
154
 
105
155
  describe('discoverCantFilesMultiTier', () => {
106
- let origXdgData: string | undefined;
107
- let origXdgConfig: string | undefined;
108
-
109
156
  beforeEach(() => {
110
- // Override XDG paths so global/user tiers point to empty temp subdirs
111
- origXdgData = process.env['XDG_DATA_HOME'];
112
- origXdgConfig = process.env['XDG_CONFIG_HOME'];
113
- process.env['XDG_DATA_HOME'] = join(tempDir, 'xdg-data');
114
- process.env['XDG_CONFIG_HOME'] = join(tempDir, 'xdg-config');
157
+ // Point the global/user tiers at empty temp subdirs on every platform.
158
+ vi.stubEnv('CLEO_HOME', join(tempDir, 'cleo-home'));
159
+ vi.stubEnv('CLEO_CONFIG_HOME', join(tempDir, 'cleo-config'));
115
160
  });
116
161
 
117
162
  afterEach(() => {
118
- if (origXdgData) process.env['XDG_DATA_HOME'] = origXdgData;
119
- else delete process.env['XDG_DATA_HOME'];
120
- if (origXdgConfig) process.env['XDG_CONFIG_HOME'] = origXdgConfig;
121
- else delete process.env['XDG_CONFIG_HOME'];
163
+ vi.unstubAllEnvs();
164
+ });
165
+
166
+ it('discovers files from the global tier under getCleoHome() (T12602)', () => {
167
+ const globalCant = join(tempDir, 'cleo-home', 'cant');
168
+ mkdirSync(globalCant, { recursive: true });
169
+ writeFileSync(join(globalCant, 'global.cant'), 'team: global');
170
+
171
+ const result = discoverCantFilesMultiTier(tempDir);
172
+ expect(result.stats.global).toBe(1);
173
+ expect(result.files).toEqual([join(globalCant, 'global.cant')]);
122
174
  });
123
175
 
124
176
  it('discovers files from project tier', () => {
@@ -0,0 +1,105 @@
1
+ /**
2
+ * T12618 — provider CLIs are spawned through their resolved path on win32,
3
+ * with npm `.cmd` shims routed through cmd.exe. Node refuses to spawn a
4
+ * `.cmd` directly and its bare-name lookup never finds one, so before this
5
+ * every adapter spawn failed on Windows.
6
+ *
7
+ * Platform and PATH are injected; the real resolver runs with a stubbed
8
+ * filesystem predicate so the test is host-independent.
9
+ */
10
+
11
+ import type { ChildProcess } from 'node:child_process';
12
+ import { EventEmitter } from 'node:events';
13
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
14
+
15
+ const PRESENT = new Set(['C:\\npm\\claude.cmd', 'C:\\npm\\codex.cmd', 'C:\\bin\\pi.exe']);
16
+
17
+ vi.mock('@cleocode/paths', async (importOriginal) => {
18
+ const actual = await importOriginal<typeof import('@cleocode/paths')>();
19
+ return {
20
+ ...actual,
21
+ resolveSpawnInvocation: (
22
+ command: string,
23
+ args: readonly string[],
24
+ opts: import('@cleocode/paths').ExecPathOptions = {},
25
+ ) =>
26
+ actual.resolveSpawnInvocation(command, args, {
27
+ ...opts,
28
+ isExecutable: (p) => PRESENT.has(p),
29
+ }),
30
+ };
31
+ });
32
+ vi.mock('node:child_process', { spy: true });
33
+
34
+ import { spawn } from 'node:child_process';
35
+ import {
36
+ _forceSystemdRunAvailable,
37
+ buildAgentSpawnArgs,
38
+ } from '../providers/shared/agent-spawn-wrapper.js';
39
+ import { spawnCli } from '../providers/shared/cli-spawn.js';
40
+
41
+ const realPlatform = process.platform;
42
+ function setPlatform(value: NodeJS.Platform): void {
43
+ Object.defineProperty(process, 'platform', { value, configurable: true });
44
+ }
45
+
46
+ beforeEach(() => {
47
+ vi.stubEnv('PATH', 'C:\\npm;C:\\bin');
48
+ vi.stubEnv('PATHEXT', '.COM;.EXE;.BAT;.CMD');
49
+ vi.stubEnv('ComSpec', 'C:\\Windows\\system32\\cmd.exe');
50
+ vi.mocked(spawn).mockReturnValue(new EventEmitter() as ChildProcess);
51
+ });
52
+ afterEach(() => {
53
+ setPlatform(realPlatform);
54
+ vi.unstubAllEnvs();
55
+ vi.mocked(spawn).mockReset();
56
+ _forceSystemdRunAvailable(false);
57
+ });
58
+
59
+ describe('buildAgentSpawnArgs on win32 (claude-code)', () => {
60
+ it('routes the claude.cmd shim through cmd.exe with verbatim quoting', () => {
61
+ setPlatform('win32');
62
+ _forceSystemdRunAvailable(false);
63
+ const built = buildAgentSpawnArgs('claude', ['--print', 'C:\\Temp\\p q.txt'], 'T1');
64
+ expect(built.command).toBe('C:\\Windows\\system32\\cmd.exe');
65
+ expect(built.windowsVerbatimArguments).toBe(true);
66
+ expect(built.args.slice(0, 3)).toEqual(['/d', '/s', '/c']);
67
+ expect(built.args[3]).toContain('C:\\npm\\claude.cmd');
68
+ });
69
+
70
+ it('POSIX keeps the sh/ulimit wrapper and never sets the verbatim flag', () => {
71
+ setPlatform('linux');
72
+ _forceSystemdRunAvailable(false);
73
+ const built = buildAgentSpawnArgs('claude', ['--print'], 'T1');
74
+ expect(built.command).toBe('sh');
75
+ expect(built.windowsVerbatimArguments).toBeUndefined();
76
+ });
77
+ });
78
+
79
+ describe('spawnCli (codex, gemini, opencode, pi)', () => {
80
+ it('.cmd on win32: spawns cmd.exe with windowsVerbatimArguments', () => {
81
+ setPlatform('win32');
82
+ spawnCli('codex', ['--full-auto', 'a & b'], { detached: true, stdio: 'ignore' });
83
+ const [file, args, opts] = vi.mocked(spawn).mock.calls[0] ?? [];
84
+ expect(file).toBe('C:\\Windows\\system32\\cmd.exe');
85
+ expect(args?.[3]).toContain('C:\\npm\\codex.cmd');
86
+ expect(args?.[3]).not.toMatch(/[^^]&/);
87
+ expect(opts).toMatchObject({ detached: true, windowsVerbatimArguments: true });
88
+ });
89
+
90
+ it('.exe on win32: spawns the absolute path directly', () => {
91
+ setPlatform('win32');
92
+ spawnCli('pi', ['prompt.txt'], { stdio: 'ignore' });
93
+ expect(vi.mocked(spawn).mock.calls[0]).toEqual([
94
+ 'C:\\bin\\pi.exe',
95
+ ['prompt.txt'],
96
+ { stdio: 'ignore' },
97
+ ]);
98
+ });
99
+
100
+ it('POSIX: unchanged bare-name spawn', () => {
101
+ setPlatform('darwin');
102
+ spawnCli('codex', ['x'], { stdio: 'ignore' });
103
+ expect(vi.mocked(spawn).mock.calls[0]).toEqual(['codex', ['x'], { stdio: 'ignore' }]);
104
+ });
105
+ });
@@ -0,0 +1,37 @@
1
+ /**
2
+ * T12606 — spawn adapters write prompts under os.tmpdir(), never `/tmp`.
3
+ *
4
+ * The claude-code, codex and pi adapters wrote `/tmp/<provider>-spawn-<id>.txt`.
5
+ * Windows has no `/tmp`, so every spawn failed there; macOS ignored `$TMPDIR`.
6
+ */
7
+
8
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { dirname, relative } from 'node:path';
11
+ import { describe, expect, it } from 'vitest';
12
+ import { removeSpawnPromptFile, writeSpawnPromptFile } from '../providers/shared/prompt-file.js';
13
+
14
+ describe('writeSpawnPromptFile (T12606)', () => {
15
+ it('writes under a fresh mkdtemp dir in os.tmpdir() and removes the dir', async () => {
16
+ const file = await writeSpawnPromptFile('claude-spawn', 'hello');
17
+ try {
18
+ expect(readFileSync(file, 'utf-8')).toBe('hello');
19
+ const rel = relative(realpathSync(tmpdir()), realpathSync(dirname(file)));
20
+ expect(rel.startsWith('cleo-claude-spawn-')).toBe(true);
21
+ // The dir's parent IS os.tmpdir() (not a hard-coded /tmp), whatever TMPDIR is.
22
+ expect(realpathSync(dirname(dirname(file)))).toBe(realpathSync(tmpdir()));
23
+ } finally {
24
+ await removeSpawnPromptFile(file);
25
+ }
26
+ expect(existsSync(dirname(file))).toBe(false);
27
+ });
28
+
29
+ it('gives concurrent spawns distinct directories', async () => {
30
+ const [a, b] = await Promise.all([
31
+ writeSpawnPromptFile('codex-spawn', 'a'),
32
+ writeSpawnPromptFile('codex-spawn', 'b'),
33
+ ]);
34
+ expect(dirname(a)).not.toBe(dirname(b));
35
+ await Promise.all([removeSpawnPromptFile(a), removeSpawnPromptFile(b)]);
36
+ });
37
+ });
@@ -23,9 +23,9 @@
23
23
 
24
24
  import { execFile } from 'node:child_process';
25
25
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
26
- import { homedir } from 'node:os';
27
26
  import { basename, join } from 'node:path';
28
27
  import { promisify } from 'node:util';
28
+ import { getCleoHome, getCleoPlatformPaths } from '@cleocode/paths';
29
29
 
30
30
  const execFileAsync = promisify(execFile);
31
31
 
@@ -175,10 +175,12 @@ export function discoverCantFiles(dir: string): string[] {
175
175
  }
176
176
 
177
177
  /**
178
- * Resolve XDG-compliant paths for the 3-tier CANT hierarchy.
178
+ * Resolve the platform paths for the 3-tier CANT hierarchy.
179
179
  *
180
- * Respects `XDG_DATA_HOME` and `XDG_CONFIG_HOME` environment variables.
181
- * Falls back to XDG defaults (`~/.local/share/` and `~/.config/`).
180
+ * The global tier lives under `getCleoHome()` and the user tier under the
181
+ * CLEO config dir, both from `@cleocode/paths` — on Linux that is
182
+ * `$XDG_DATA_HOME/cleo/cant` and `$XDG_CONFIG_HOME/cleo/cant`; on macOS
183
+ * `~/Library/Application Support/cleo/cant` and `~/Library/Preferences/cleo/cant`.
182
184
  *
183
185
  * @param projectDir - The project root directory (for the project tier).
184
186
  * @returns An object with `global`, `user`, and `project` CANT directory paths.
@@ -188,13 +190,9 @@ export function resolveThreeTierPaths(projectDir: string): {
188
190
  user: string;
189
191
  project: string;
190
192
  } {
191
- const home = homedir();
192
- const xdgData = process.env['XDG_DATA_HOME'] ?? join(home, '.local', 'share');
193
- const xdgConfig = process.env['XDG_CONFIG_HOME'] ?? join(home, '.config');
194
-
195
193
  return {
196
- global: join(xdgData, 'cleo', 'cant'),
197
- user: join(xdgConfig, 'cleo', 'cant'),
194
+ global: join(getCleoHome(), 'cant'),
195
+ user: join(getCleoPlatformPaths().config, 'cant'),
198
196
  project: join(projectDir, '.cleo', 'cant'),
199
197
  };
200
198
  }
@@ -377,11 +375,11 @@ async function fetchMentalModelInjection(agentName: string, projectRoot: string)
377
375
  // ---------------------------------------------------------------------------
378
376
 
379
377
  /**
380
- * Read CLEOOS-IDENTITY.md from the project or global XDG location.
378
+ * Read CLEOOS-IDENTITY.md from the project or the global CLEO home.
381
379
  *
382
380
  * Search order:
383
381
  * 1. `<projectDir>/.cleo/CLEOOS-IDENTITY.md` (project-level, deployed by init)
384
- * 2. `$XDG_DATA_HOME/cleo/CLEOOS-IDENTITY.md` (global XDG default)
382
+ * 2. `<getCleoHome()>/CLEOOS-IDENTITY.md` (global)
385
383
  *
386
384
  * @param projectDir - The project root directory.
387
385
  * @returns The identity file content, or null if not found.
@@ -397,9 +395,7 @@ export function readIdentityFile(projectDir: string): string | null {
397
395
  }
398
396
  }
399
397
 
400
- const home = homedir();
401
- const xdgData = process.env['XDG_DATA_HOME'] ?? join(home, '.local', 'share');
402
- const globalPath = join(xdgData, 'cleo', 'CLEOOS-IDENTITY.md');
398
+ const globalPath = join(getCleoHome(), 'CLEOOS-IDENTITY.md');
403
399
  if (existsSync(globalPath)) {
404
400
  try {
405
401
  const content = readFileSync(globalPath, 'utf-8');
@@ -62,6 +62,7 @@ vi.mock('@cleocode/caamp', () => ({
62
62
  action: 'added',
63
63
  providerId: 'claude-code',
64
64
  }),
65
+ updateJsonConfigFile: vi.fn().mockResolvedValue(false),
65
66
  }));
66
67
 
67
68
  describe('ClaudeCodeAdapter', () => {
@@ -17,10 +17,11 @@
17
17
  * @epic T1000
18
18
  */
19
19
 
20
- import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
20
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
21
21
  import { tmpdir } from 'node:os';
22
22
  import { join } from 'node:path';
23
- import { afterEach, beforeEach, describe, expect, it } from 'vitest';
23
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
24
+ import { ClaudeCodeHookProvider } from '../hooks.js';
24
25
  import { ClaudeCodeInstallProvider } from '../install.js';
25
26
 
26
27
  describe('ClaudeCodeInstallProvider — PreCompact hook templates', () => {
@@ -129,4 +130,64 @@ describe('ClaudeCodeInstallProvider — PreCompact hook templates', () => {
129
130
  };
130
131
  expect((settings.hooks?.PreCompact ?? []).length).toBe(1);
131
132
  });
133
+
134
+ // T12385 — a malformed settings.json used to be replaced with an object
135
+ // holding only CLEO's entries (the parse error was caught and the writer
136
+ // "started fresh"). It must now be reported and left byte-identical.
137
+ describe('T12385 — malformed settings.json is never rewritten', () => {
138
+ const malformed = '{\n "permissions": { "allow": ["Bash(ls)"] },\n "model": "opus",\n';
139
+
140
+ it('install reports the parse error and leaves the file byte-identical', async () => {
141
+ const settingsPath = join(fakeHome, '.claude', 'settings.json');
142
+ mkdirSync(join(fakeHome, '.claude'), { recursive: true });
143
+ writeFileSync(settingsPath, malformed, 'utf-8');
144
+
145
+ const result = await new ClaudeCodeInstallProvider().install({ projectDir });
146
+
147
+ expect(readFileSync(settingsPath, 'utf-8')).toBe(malformed);
148
+ expect(result.success).toBe(false);
149
+ const errors = (result.details?.settingsErrors ?? []) as string[];
150
+ expect(errors.length).toBeGreaterThan(0);
151
+ expect(errors[0]).toMatch(/not a valid JSON object/);
152
+ expect(existsSync(`${settingsPath}.lock`)).toBe(false);
153
+ });
154
+
155
+ it('hook registration reports the parse error and leaves the file byte-identical', async () => {
156
+ const settingsPath = join(fakeHome, '.claude', 'settings.json');
157
+ mkdirSync(join(fakeHome, '.claude'), { recursive: true });
158
+ writeFileSync(settingsPath, malformed, 'utf-8');
159
+ const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
160
+
161
+ const hooks = new ClaudeCodeHookProvider();
162
+ await hooks.registerNativeHooks(projectDir);
163
+ await hooks.unregisterNativeHooks();
164
+
165
+ stderr.mockRestore();
166
+ expect(readFileSync(settingsPath, 'utf-8')).toBe(malformed);
167
+ expect(hooks.getSettingsError()).toMatch(/not a valid JSON object/);
168
+ });
169
+
170
+ it('preserves unrelated user settings when it does write', async () => {
171
+ const settingsPath = join(fakeHome, '.claude', 'settings.json');
172
+ mkdirSync(join(fakeHome, '.claude'), { recursive: true });
173
+ writeFileSync(
174
+ settingsPath,
175
+ JSON.stringify({ model: 'opus', hooks: { Stop: [{ matcher: 'x', hooks: [] }] } }),
176
+ 'utf-8',
177
+ );
178
+
179
+ await new ClaudeCodeHookProvider().registerNativeHooks(projectDir);
180
+ await new ClaudeCodeInstallProvider().install({ projectDir });
181
+
182
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf-8')) as {
183
+ model?: string;
184
+ enabledPlugins?: Record<string, boolean>;
185
+ hooks?: Record<string, unknown[]>;
186
+ };
187
+ expect(settings.model).toBe('opus');
188
+ expect(settings.enabledPlugins?.['cleo@cleocode']).toBe(true);
189
+ expect(settings.hooks?.Stop).toHaveLength(2);
190
+ expect(settings.hooks?.PreCompact).toHaveLength(1);
191
+ });
192
+ });
132
193
  });
@@ -7,17 +7,16 @@
7
7
  * @task T5240
8
8
  */
9
9
 
10
- import { exec } from 'node:child_process';
11
10
  import { existsSync } from 'node:fs';
12
11
  import { homedir } from 'node:os';
13
12
  import { join } from 'node:path';
14
- import { promisify } from 'node:util';
15
13
  import type {
16
14
  AdapterCapabilities,
17
15
  AdapterHealthStatus,
18
16
  AdapterSpawnProvider,
19
17
  CLEOProviderAdapter,
20
18
  } from '@cleocode/contracts';
19
+ import { findOnPath } from '@cleocode/paths';
21
20
  import { ClaudeSDKSpawnProvider } from '../claude-sdk/spawn.js';
22
21
  import { ClaudeCodeContextMonitorProvider } from './context-monitor.js';
23
22
  import { ClaudeCodeHookProvider } from './hooks.js';
@@ -27,8 +26,6 @@ import { ClaudeCodeSpawnProvider } from './spawn.js';
27
26
  import { ClaudeCodeTaskSyncProvider } from './task-sync.js';
28
27
  import { ClaudeCodeTransportProvider } from './transport.js';
29
28
 
30
- const execAsync = promisify(exec);
31
-
32
29
  /**
33
30
  * CLEO provider adapter for Anthropic Claude Code CLI.
34
31
  *
@@ -202,13 +199,11 @@ export class ClaudeCodeAdapter implements CLEOProviderAdapter {
202
199
 
203
200
  // Check Claude CLI availability
204
201
  let cliAvailable = false;
205
- try {
206
- const { stdout } = await execAsync('which claude');
207
- cliAvailable = stdout.trim().length > 0;
208
- details.cliPath = stdout.trim();
209
- } catch {
210
- details.cliAvailable = false;
211
- }
202
+ // In-process PATH/PATHEXT lookup: there is no `which` on Windows (T12604).
203
+ const resolvedCli = findOnPath('claude');
204
+ cliAvailable = resolvedCli !== null;
205
+ if (resolvedCli) details.cliPath = resolvedCli;
206
+ else details.cliAvailable = false;
212
207
 
213
208
  // Check for Claude Code config directory
214
209
  const claudeConfigDir = join(homedir(), '.claude');