@cleocode/adapters 2026.9.18 → 2026.9.20

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.
@@ -10,23 +10,23 @@
10
10
  * @task T5240
11
11
  */
12
12
 
13
- import {
14
- copyFileSync,
15
- existsSync,
16
- mkdirSync,
17
- readdirSync,
18
- readFileSync,
19
- writeFileSync,
20
- } from 'node:fs';
13
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
21
14
  import { homedir } from 'node:os';
22
15
  import { dirname, join } from 'node:path';
23
16
  import { fileURLToPath } from 'node:url';
24
- import { ensureProviderInstructionFile } from '@cleocode/caamp';
17
+ import { ensureProviderInstructionFile, updateJsonConfigFile } from '@cleocode/caamp';
25
18
  import type { AdapterInstallProvider, InstallOptions, InstallResult } from '@cleocode/contracts';
26
19
  import {
27
20
  type InstallHookTemplatesResult,
28
21
  installProviderHookTemplates,
29
22
  } from '../shared/hook-template-installer.js';
23
+ import {
24
+ appendHookEntry,
25
+ claudeSettingsPath,
26
+ hasCleoHook,
27
+ hookMap,
28
+ isPlainObject,
29
+ } from './paths.js';
30
30
 
31
31
  /** Resolve Claude Code's config directory, honoring the documented CI override. */
32
32
  function getClaudeConfigDir(): string {
@@ -80,21 +80,37 @@ export class ClaudeCodeInstallProvider implements AdapterInstallProvider {
80
80
  details.commands = commandsInstalled;
81
81
  }
82
82
 
83
+ // T12385: settings.json writes are locked + atomic, and a malformed file
84
+ // aborts the write and is reported here — it is never reset to `{}`.
85
+ const settingsErrors: string[] = [];
86
+
83
87
  // Step 3: Register plugin in ~/.claude/settings.json
84
- const pluginResult = this.registerPlugin();
85
- if (pluginResult) {
86
- details.plugin = pluginResult;
88
+ try {
89
+ const pluginResult = await this.registerPlugin();
90
+ if (pluginResult) {
91
+ details.plugin = pluginResult;
92
+ }
93
+ } catch (err) {
94
+ settingsErrors.push(err instanceof Error ? err.message : String(err));
87
95
  }
88
96
 
89
97
  // Step 4 (T1013): Install PreCompact hook templates + wire the handler
90
98
  // command into ~/.claude/settings.json's `PreCompact` event.
91
- const hookResult = this.installHookTemplates();
92
- if (hookResult) {
93
- details.hookTemplates = hookResult;
99
+ try {
100
+ const hookResult = await this.installHookTemplates();
101
+ if (hookResult) {
102
+ details.hookTemplates = hookResult;
103
+ }
104
+ } catch (err) {
105
+ settingsErrors.push(err instanceof Error ? err.message : String(err));
106
+ }
107
+
108
+ if (settingsErrors.length > 0) {
109
+ details.settingsErrors = settingsErrors;
94
110
  }
95
111
 
96
112
  return {
97
- success: true,
113
+ success: settingsErrors.length === 0,
98
114
  installedAt,
99
115
  instructionFileUpdated,
100
116
  details,
@@ -115,7 +131,7 @@ export class ClaudeCodeInstallProvider implements AdapterInstallProvider {
115
131
  */
116
132
  async isInstalled(): Promise<boolean> {
117
133
  // Check ~/.claude/settings.json for plugin registration
118
- const settingsPath = join(getClaudeConfigDir(), 'settings.json');
134
+ const settingsPath = claudeSettingsPath();
119
135
  if (existsSync(settingsPath)) {
120
136
  try {
121
137
  const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
@@ -179,38 +195,28 @@ export class ClaudeCodeInstallProvider implements AdapterInstallProvider {
179
195
  *
180
196
  * @returns Description of what was registered, or null if no change needed
181
197
  */
182
- private registerPlugin(): string | null {
183
- const claudeDir = getClaudeConfigDir();
184
- const settingsPath = join(claudeDir, 'settings.json');
185
-
186
- let settings: Record<string, unknown> = {};
187
- if (existsSync(settingsPath)) {
188
- try {
189
- settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
190
- } catch {
191
- // Start fresh
192
- }
193
- }
194
-
195
- const enabledPlugins = (settings.enabledPlugins as Record<string, boolean>) ?? {};
198
+ private async registerPlugin(): Promise<string | null> {
196
199
  const pluginKey = 'cleo@cleocode';
200
+ const wrote = await updateJsonConfigFile(claudeSettingsPath(), (settings) => {
201
+ const enabledPlugins = settings.enabledPlugins ?? {};
202
+ if (!isPlainObject(enabledPlugins)) {
203
+ throw new Error('settings.json "enabledPlugins" is not an object; not modifying it');
204
+ }
205
+ if (enabledPlugins[pluginKey] === true) {
206
+ return false;
207
+ }
197
208
 
198
- if (enabledPlugins[pluginKey] === true) {
199
- return null;
200
- }
201
-
202
- // Disable old claude-mem if present
203
- if (enabledPlugins['claude-mem@thedotmack'] === true) {
204
- enabledPlugins['claude-mem@thedotmack'] = false;
205
- }
206
-
207
- enabledPlugins[pluginKey] = true;
208
- settings.enabledPlugins = enabledPlugins;
209
+ // Disable old claude-mem if present
210
+ if (enabledPlugins['claude-mem@thedotmack'] === true) {
211
+ enabledPlugins['claude-mem@thedotmack'] = false;
212
+ }
209
213
 
210
- mkdirSync(claudeDir, { recursive: true });
211
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
214
+ enabledPlugins[pluginKey] = true;
215
+ settings.enabledPlugins = enabledPlugins;
216
+ return true;
217
+ });
212
218
 
213
- return `Enabled ${pluginKey} in ~/.claude/settings.json`;
219
+ return wrote ? `Enabled ${pluginKey} in ~/.claude/settings.json` : null;
214
220
  }
215
221
 
216
222
  /**
@@ -233,10 +239,10 @@ export class ClaudeCodeInstallProvider implements AdapterInstallProvider {
233
239
  *
234
240
  * @task T1013
235
241
  */
236
- private installHookTemplates(): {
242
+ private async installHookTemplates(): Promise<{
237
243
  templates: InstallHookTemplatesResult;
238
244
  settingsEntryAdded: boolean;
239
- } | null {
245
+ } | null> {
240
246
  const claudeDir = getClaudeConfigDir();
241
247
  const hooksDir = join(claudeDir, 'hooks');
242
248
 
@@ -254,7 +260,7 @@ export class ClaudeCodeInstallProvider implements AdapterInstallProvider {
254
260
  }
255
261
 
256
262
  // 2. Wire the PreCompact event in ~/.claude/settings.json.
257
- const settingsEntryAdded = this.registerPreCompactHook(
263
+ const settingsEntryAdded = await this.registerPreCompactHook(
258
264
  join(hooksDir, 'precompact-safestop.sh'),
259
265
  );
260
266
 
@@ -279,55 +285,23 @@ export class ClaudeCodeInstallProvider implements AdapterInstallProvider {
279
285
  *
280
286
  * @task T1013
281
287
  */
282
- private registerPreCompactHook(shimPath: string): boolean {
283
- const claudeDir = getClaudeConfigDir();
284
- const settingsPath = join(claudeDir, 'settings.json');
285
-
286
- let settings: Record<string, unknown> = {};
287
- if (existsSync(settingsPath)) {
288
- try {
289
- settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
290
- } catch {
291
- // Start fresh on corrupt settings — safer than aborting install.
288
+ private async registerPreCompactHook(shimPath: string): Promise<boolean> {
289
+ return updateJsonConfigFile(claudeSettingsPath(), (settings) => {
290
+ const hooks = hookMap(settings);
291
+ if (hasCleoHook(hooks.PreCompact, 'precompact-safestop.sh')) {
292
+ return false;
292
293
  }
293
- }
294
-
295
- const hooks = (settings.hooks as Record<string, unknown[]> | undefined) ?? {};
296
- const preCompactEntries = (hooks.PreCompact as unknown[] | undefined) ?? [];
297
-
298
- const alreadyWired = preCompactEntries.some(
299
- (entry) =>
300
- typeof entry === 'object' &&
301
- entry !== null &&
302
- Array.isArray((entry as Record<string, unknown>).hooks) &&
303
- ((entry as Record<string, unknown>).hooks as Array<Record<string, unknown>>).some(
304
- (h) =>
305
- typeof h.command === 'string' &&
306
- (h.command as string).includes('# cleo-hook') &&
307
- (h.command as string).includes('precompact-safestop.sh'),
308
- ),
309
- );
310
-
311
- if (alreadyWired) {
312
- return false;
313
- }
314
-
315
- preCompactEntries.push({
316
- matcher: '',
317
- hooks: [
318
- {
319
- type: 'command',
320
- command: `"${shimPath}" # cleo-hook`,
321
- timeout: 30,
322
- },
323
- ],
294
+ appendHookEntry(hooks, 'PreCompact', {
295
+ matcher: '',
296
+ hooks: [
297
+ {
298
+ type: 'command',
299
+ command: `"${shimPath}" # cleo-hook`,
300
+ timeout: 30,
301
+ },
302
+ ],
303
+ });
304
+ return true;
324
305
  });
325
-
326
- hooks.PreCompact = preCompactEntries;
327
- settings.hooks = hooks;
328
-
329
- mkdirSync(claudeDir, { recursive: true });
330
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
331
- return true;
332
306
  }
333
307
  }
@@ -44,3 +44,158 @@ export class ClaudeCodePathProvider implements AdapterPathProvider {
44
44
  return process.env['CLAUDE_MEM_DB'] ?? join(homedir(), '.claude-mem', 'claude-mem.db');
45
45
  }
46
46
  }
47
+
48
+ /**
49
+ * Resolve Claude Code's `settings.json`, honouring `CLAUDE_SETTINGS` and
50
+ * `CLAUDE_HOME`.
51
+ *
52
+ * @remarks
53
+ * The single resolver for every adapter write to the settings file (T12385).
54
+ * Hook registration previously hard-coded `~/.claude/settings.json` while the
55
+ * installer honoured `CLAUDE_HOME`, so the two wrote different files whenever
56
+ * the override was set.
57
+ *
58
+ * @returns Absolute path of the settings file
59
+ *
60
+ * @example
61
+ * ```typescript
62
+ * const settingsPath = claudeSettingsPath();
63
+ * ```
64
+ */
65
+ export function claudeSettingsPath(): string {
66
+ return (
67
+ new ClaudeCodePathProvider().getSettingsPath() ?? join(homedir(), '.claude', 'settings.json')
68
+ );
69
+ }
70
+
71
+ /** Marker CLEO puts in every hook command it owns. */
72
+ const CLEO_HOOK_MARKER = '# cleo-hook';
73
+
74
+ /**
75
+ * Whether a parsed settings value is a plain JSON object.
76
+ *
77
+ * @param value - Any parsed JSON value
78
+ * @returns `true` for a non-null, non-array object
79
+ *
80
+ * @example
81
+ * ```typescript
82
+ * isPlainObject(settings.enabledPlugins);
83
+ * ```
84
+ */
85
+ export function isPlainObject(value: unknown): value is Record<string, unknown> {
86
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
87
+ }
88
+
89
+ /**
90
+ * Whether a settings.json hook entry carries a CLEO-owned command.
91
+ *
92
+ * @param entry - One element of a `hooks.<Event>` array
93
+ * @param commandIncludes - Extra substring the CLEO command must contain
94
+ * @returns `true` when one of the entry's commands has the `# cleo-hook` marker
95
+ *
96
+ * @example
97
+ * ```typescript
98
+ * isCleoHookEntry({ hooks: [{ type: "command", command: "x # cleo-hook" }] }); // true
99
+ * ```
100
+ */
101
+ export function isCleoHookEntry(entry: unknown, commandIncludes?: string): boolean {
102
+ if (!isPlainObject(entry) || !Array.isArray(entry.hooks)) return false;
103
+ return entry.hooks.some(
104
+ (hook) =>
105
+ isPlainObject(hook) &&
106
+ typeof hook.command === 'string' &&
107
+ hook.command.includes(CLEO_HOOK_MARKER) &&
108
+ (commandIncludes === undefined || hook.command.includes(commandIncludes)),
109
+ );
110
+ }
111
+
112
+ /**
113
+ * Whether a `hooks.<Event>` value holds a CLEO-owned entry.
114
+ *
115
+ * @param entries - The event's value (non-arrays hold none)
116
+ * @param commandIncludes - Extra substring the CLEO command must contain
117
+ * @returns `true` when a CLEO entry is present
118
+ *
119
+ * @example
120
+ * ```typescript
121
+ * hasCleoHook(hookMap(settings).Stop);
122
+ * ```
123
+ */
124
+ export function hasCleoHook(entries: unknown, commandIncludes?: string): boolean {
125
+ return Array.isArray(entries) && entries.some((e) => isCleoHookEntry(e, commandIncludes));
126
+ }
127
+
128
+ /**
129
+ * Return the settings object's `hooks` map, creating an empty one if absent.
130
+ *
131
+ * @param settings - Parsed settings.json
132
+ * @returns The live `hooks` object (mutations apply to `settings`)
133
+ * @throws Error when `hooks` exists but is not an object — the caller's write
134
+ * is aborted and the file left untouched
135
+ *
136
+ * @example
137
+ * ```typescript
138
+ * const hooks = hookMap(settings);
139
+ * ```
140
+ */
141
+ export function hookMap(settings: Record<string, unknown>): Record<string, unknown> {
142
+ const existing = settings.hooks;
143
+ if (existing === undefined) {
144
+ const created: Record<string, unknown> = {};
145
+ settings.hooks = created;
146
+ return created;
147
+ }
148
+ if (!isPlainObject(existing)) {
149
+ throw new Error('settings.json "hooks" is not an object; not modifying it');
150
+ }
151
+ return existing;
152
+ }
153
+
154
+ /**
155
+ * Append a hook entry to `hooks[event]`, preserving existing entries.
156
+ *
157
+ * @param hooks - The settings `hooks` map
158
+ * @param event - Native event name (e.g. `Stop`, `PreCompact`)
159
+ * @param entry - Entry to append
160
+ * @throws Error when `hooks[event]` exists but is not an array
161
+ *
162
+ * @example
163
+ * ```typescript
164
+ * appendHookEntry(hookMap(settings), "Stop", { matcher: "", hooks: [] });
165
+ * ```
166
+ */
167
+ export function appendHookEntry(
168
+ hooks: Record<string, unknown>,
169
+ event: string,
170
+ entry: Record<string, unknown>,
171
+ ): void {
172
+ const existing = hooks[event];
173
+ if (existing !== undefined && !Array.isArray(existing)) {
174
+ throw new Error(`settings.json "hooks.${event}" is not an array; not modifying it`);
175
+ }
176
+ hooks[event] = [...(existing ?? []), entry];
177
+ }
178
+
179
+ /**
180
+ * Remove every CLEO-owned entry from every event array in `hooks`.
181
+ *
182
+ * @param hooks - The settings `hooks` map
183
+ * @returns Whether anything was removed
184
+ *
185
+ * @example
186
+ * ```typescript
187
+ * const changed = removeCleoHookEntries(hookMap(settings));
188
+ * ```
189
+ */
190
+ export function removeCleoHookEntries(hooks: Record<string, unknown>): boolean {
191
+ let changed = false;
192
+ for (const [event, entries] of Object.entries(hooks)) {
193
+ if (!Array.isArray(entries)) continue;
194
+ const kept = entries.filter((e) => !isCleoHookEntry(e));
195
+ if (kept.length !== entries.length) {
196
+ hooks[event] = kept;
197
+ changed = true;
198
+ }
199
+ }
200
+ return changed;
201
+ }
@@ -45,6 +45,13 @@ vi.mock('@cleocode/caamp', () => ({
45
45
  action: 'added',
46
46
  providerId: 'cursor',
47
47
  }),
48
+ // T12385: Cursor's rule files and hooks.json go through the CAAMP writers.
49
+ ensureProviderRuleFile: vi.fn().mockResolvedValue({
50
+ filePath: '/tmp/test-project/.cursor/rules/cleo.mdc',
51
+ action: 'created',
52
+ providerId: 'cursor',
53
+ }),
54
+ updateJsonConfigFile: vi.fn().mockResolvedValue(true),
48
55
  }));
49
56
 
50
57
  describe('CursorAdapter', () => {
@@ -95,4 +95,40 @@ describe('CursorInstallProvider — PreCompact hook templates', () => {
95
95
  };
96
96
  expect((config.hooks?.preCompact ?? []).length).toBe(1);
97
97
  });
98
+
99
+ it('T12385: reports a malformed hooks.json and leaves it byte-identical', async () => {
100
+ const hooksJsonPath = join(projectDir, '.cursor', 'hooks.json');
101
+ mkdirSync(join(projectDir, '.cursor'), { recursive: true });
102
+ const malformed = '{ "hooks": { "preCompact": [ ';
103
+ writeFileSync(hooksJsonPath, malformed, 'utf-8');
104
+
105
+ const result = await new CursorInstallProvider().install({ projectDir });
106
+
107
+ expect(readFileSync(hooksJsonPath, 'utf-8')).toBe(malformed);
108
+ expect(result.success).toBe(false);
109
+ expect(String(result.details?.hooksError)).toMatch(/not a valid JSON object/);
110
+ });
111
+
112
+ it('T12385: writes rule files through the CAAMP writer (managed block, registry references)', async () => {
113
+ const legacyPath = join(projectDir, '.cursorrules');
114
+ // A pre-CAAMP .cursorrules with the old bare reference lines appended.
115
+ writeFileSync(
116
+ legacyPath,
117
+ '# Project Rules\nUse TypeScript.\n@~/.cleo/templates/CLEO-INJECTION.md\n@.cleo/memory-bridge.md\n',
118
+ 'utf-8',
119
+ );
120
+
121
+ await new CursorInstallProvider().install({ projectDir });
122
+
123
+ const legacy = readFileSync(legacyPath, 'utf-8');
124
+ expect(legacy).toContain('# Project Rules\nUse TypeScript.');
125
+ expect(legacy).toContain('<!-- CAAMP:START -->');
126
+ expect(legacy.match(/CLEO-INJECTION\.md/g)).toHaveLength(1);
127
+
128
+ const mdc = readFileSync(join(projectDir, '.cursor', 'rules', 'cleo.mdc'), 'utf-8');
129
+ expect(mdc.startsWith('---\n')).toBe(true);
130
+ expect(mdc).toContain('alwaysApply: true');
131
+ expect(mdc).toContain('<!-- CAAMP:START -->');
132
+ expect(mdc).toContain('@.cleo/memory-bridge.md');
133
+ });
98
134
  });