@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.
@@ -16,9 +16,14 @@
16
16
  * @task T1013
17
17
  */
18
18
 
19
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
19
+ import { existsSync, readFileSync } from 'node:fs';
20
20
  import { join } from 'node:path';
21
- import { ensureProviderInstructionFile } from '@cleocode/caamp';
21
+ import {
22
+ type EnsureProviderRuleFileResult,
23
+ ensureProviderInstructionFile,
24
+ ensureProviderRuleFile,
25
+ updateJsonConfigFile,
26
+ } from '@cleocode/caamp';
22
27
  import type { AdapterInstallProvider, InstallOptions, InstallResult } from '@cleocode/contracts';
23
28
  import {
24
29
  type InstallHookTemplatesResult,
@@ -26,6 +31,21 @@ import {
26
31
  } from '../shared/hook-template-installer.js';
27
32
  import { getCleoTemplatesTildePath } from '../shared/paths.js';
28
33
 
34
+ /** MDC frontmatter for the CLEO-owned `.cursor/rules/cleo.mdc` rule file. */
35
+ const CLEO_MDC_FRONTMATTER = [
36
+ '---',
37
+ 'description: CLEO task management protocol references',
38
+ 'globs: "**/*"',
39
+ 'alwaysApply: true',
40
+ '---',
41
+ '',
42
+ ].join('\n');
43
+
44
+ /** Whether a parsed JSON value is a plain object. */
45
+ function isRecord(value: unknown): value is Record<string, unknown> {
46
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
47
+ }
48
+
29
49
  /**
30
50
  * Install provider for Cursor.
31
51
  *
@@ -57,7 +77,7 @@ export class CursorInstallProvider implements AdapterInstallProvider {
57
77
  // ensureProviderInstructionFile handles the primary AGENTS.md (registry instructFile);
58
78
  // updateInstructionFiles also manages cursor-specific MDC + legacy .cursorrules formats.
59
79
  const instructionResult = await ensureProviderInstructionFile('cursor', projectDir, {});
60
- const cursorFilesUpdated = this.updateInstructionFiles(projectDir);
80
+ const cursorFilesUpdated = await this.updateInstructionFiles(projectDir);
61
81
  instructionFileUpdated = instructionResult.action !== 'intact' || cursorFilesUpdated;
62
82
  if (instructionFileUpdated) {
63
83
  details.instructionFiles = this.getUpdatedFileList(projectDir);
@@ -65,13 +85,21 @@ export class CursorInstallProvider implements AdapterInstallProvider {
65
85
 
66
86
  // Step 2 (T1013): Install PreCompact hook templates + wire the handler
67
87
  // command into .cursor/hooks.json's `preCompact` event.
68
- const hookResult = this.installHookTemplates(projectDir);
69
- if (hookResult) {
70
- details.hookTemplates = hookResult;
88
+ // T12385: a malformed hooks.json aborts the write and is reported; it is
89
+ // never reset to `{}`.
90
+ let hooksError: string | null = null;
91
+ try {
92
+ const hookResult = await this.installHookTemplates(projectDir);
93
+ if (hookResult) {
94
+ details.hookTemplates = hookResult;
95
+ }
96
+ } catch (err) {
97
+ hooksError = err instanceof Error ? err.message : String(err);
98
+ details.hooksError = hooksError;
71
99
  }
72
100
 
73
101
  return {
74
- success: true,
102
+ success: hooksError === null,
75
103
  installedAt,
76
104
  instructionFileUpdated,
77
105
  details,
@@ -127,102 +155,37 @@ export class CursorInstallProvider implements AdapterInstallProvider {
127
155
  */
128
156
  async ensureInstructionReferences(projectDir: string): Promise<void> {
129
157
  await ensureProviderInstructionFile('cursor', projectDir, {});
130
- this.updateInstructionFiles(projectDir);
158
+ await this.updateInstructionFiles(projectDir);
131
159
  }
132
160
 
133
161
  /**
134
- * Update instruction files with CLEO @-references.
135
- *
136
- * Handles both legacy (.cursorrules) and modern (.cursor/rules/cleo.mdc) formats.
162
+ * Update Cursor's auxiliary rule files through the CAAMP writer.
137
163
  *
138
- * @returns true if any file was created or modified
139
- */
140
- private updateInstructionFiles(projectDir: string): boolean {
141
- let updated = false;
142
-
143
- // Update legacy .cursorrules if it exists
144
- if (this.updateLegacyRules(projectDir)) {
145
- updated = true;
146
- }
147
-
148
- // Create/update modern .cursor/rules/cleo.mdc
149
- if (this.updateModernRules(projectDir)) {
150
- updated = true;
151
- }
152
-
153
- return updated;
154
- }
155
-
156
- /**
157
- * Update legacy .cursorrules file with @-references.
158
- *
159
- * References are sourced from the CAAMP provider registry (T1919) rather than
160
- * a local constant, keeping this adapter in sync with the single source of truth.
161
- * Only modifies the file if it already exists (does not create it).
162
- *
163
- * @returns true if the file was modified
164
- */
165
- private updateLegacyRules(projectDir: string): boolean {
166
- const rulesPath = join(projectDir, '.cursorrules');
167
- if (!existsSync(rulesPath)) {
168
- return false;
169
- }
170
-
171
- let content = readFileSync(rulesPath, 'utf-8');
172
- const cursorRefs = [
173
- `@${getCleoTemplatesTildePath()}/CLEO-INJECTION.md`,
174
- '@.cleo/memory-bridge.md',
175
- ];
176
- const missingRefs = cursorRefs.filter((ref) => !content.includes(ref));
177
-
178
- if (missingRefs.length === 0) {
179
- return false;
180
- }
181
-
182
- const separator = content.endsWith('\n') ? '' : '\n';
183
- content = content + separator + missingRefs.join('\n') + '\n';
184
- writeFileSync(rulesPath, content, 'utf-8');
185
- return true;
186
- }
187
-
188
- /**
189
- * Create or update .cursor/rules/cleo.mdc with CLEO references.
164
+ * @remarks
165
+ * ADR-064 / T12385: both files are written by CAAMP's
166
+ * `ensureProviderRuleFile` — locked, atomic, with references taken from the
167
+ * provider registry — instead of raw `writeFileSync` with a local copy of
168
+ * the references.
190
169
  *
191
- * MDC (Markdown Component) format is Cursor's modern rule file format.
192
- * Each .mdc file in .cursor/rules/ is loaded as a rule set.
193
- * References are sourced from the CAAMP provider registry (T1919).
170
+ * - Legacy `.cursorrules` is only updated when it already exists; CLEO's
171
+ * references live in a CAAMP block and user text is preserved.
172
+ * - Modern `.cursor/rules/cleo.mdc` is CLEO-owned: MDC frontmatter followed
173
+ * by the CAAMP block.
194
174
  *
195
- * @returns true if the file was created or modified
175
+ * @returns true if any file was created or modified
196
176
  */
197
- private updateModernRules(projectDir: string): boolean {
198
- const rulesDir = join(projectDir, '.cursor', 'rules');
199
- const mdcPath = join(rulesDir, 'cleo.mdc');
200
-
201
- const cursorRefs = [
202
- `@${getCleoTemplatesTildePath()}/CLEO-INJECTION.md`,
203
- '@.cleo/memory-bridge.md',
177
+ private async updateInstructionFiles(projectDir: string): Promise<boolean> {
178
+ const results: Array<EnsureProviderRuleFileResult | null> = [
179
+ await ensureProviderRuleFile('cursor', projectDir, {
180
+ relativePath: '.cursorrules',
181
+ onlyIfExists: true,
182
+ }),
183
+ await ensureProviderRuleFile('cursor', projectDir, {
184
+ relativePath: join('.cursor', 'rules', 'cleo.mdc'),
185
+ preamble: CLEO_MDC_FRONTMATTER,
186
+ }),
204
187
  ];
205
- const expectedContent = [
206
- '---',
207
- 'description: CLEO task management protocol references',
208
- 'globs: "**/*"',
209
- 'alwaysApply: true',
210
- '---',
211
- '',
212
- ...cursorRefs,
213
- '',
214
- ].join('\n');
215
-
216
- if (existsSync(mdcPath)) {
217
- const existing = readFileSync(mdcPath, 'utf-8');
218
- if (existing === expectedContent) {
219
- return false;
220
- }
221
- }
222
-
223
- mkdirSync(rulesDir, { recursive: true });
224
- writeFileSync(mdcPath, expectedContent, 'utf-8');
225
- return true;
188
+ return results.some((r) => r !== null && r.action !== 'intact');
226
189
  }
227
190
 
228
191
  /**
@@ -255,10 +218,10 @@ export class CursorInstallProvider implements AdapterInstallProvider {
255
218
  *
256
219
  * @task T1013
257
220
  */
258
- private installHookTemplates(projectDir: string): {
221
+ private async installHookTemplates(projectDir: string): Promise<{
259
222
  templates: InstallHookTemplatesResult;
260
223
  hooksJsonEntryAdded: boolean;
261
- } | null {
224
+ } | null> {
262
225
  const hooksDir = join(projectDir, '.cursor', 'hooks');
263
226
 
264
227
  // Template copy is best-effort so missing/locked filesystems (CI sandboxes,
@@ -273,7 +236,7 @@ export class CursorInstallProvider implements AdapterInstallProvider {
273
236
  return null;
274
237
  }
275
238
 
276
- const hooksJsonEntryAdded = this.registerPreCompactHook(
239
+ const hooksJsonEntryAdded = await this.registerPreCompactHook(
277
240
  projectDir,
278
241
  join(hooksDir, 'precompact.sh'),
279
242
  );
@@ -298,45 +261,35 @@ export class CursorInstallProvider implements AdapterInstallProvider {
298
261
  *
299
262
  * @task T1013
300
263
  */
301
- private registerPreCompactHook(projectDir: string, shimPath: string): boolean {
264
+ private async registerPreCompactHook(projectDir: string, shimPath: string): Promise<boolean> {
302
265
  const hooksJsonPath = join(projectDir, '.cursor', 'hooks.json');
303
-
304
- let config: Record<string, unknown> = {};
305
- if (existsSync(hooksJsonPath)) {
306
- try {
307
- config = JSON.parse(readFileSync(hooksJsonPath, 'utf-8'));
308
- } catch {
309
- // Start fresh on corrupt config.
266
+ return updateJsonConfigFile(hooksJsonPath, (config) => {
267
+ const hooks = config.hooks ?? {};
268
+ if (!isRecord(hooks)) {
269
+ throw new Error('.cursor/hooks.json "hooks" is not an object; not modifying it');
270
+ }
271
+ const entries = hooks.preCompact ?? [];
272
+ if (!Array.isArray(entries)) {
273
+ throw new Error('.cursor/hooks.json "hooks.preCompact" is not an array; not modifying it');
310
274
  }
311
- }
312
-
313
- const hooks = (config.hooks as Record<string, unknown[]> | undefined) ?? {};
314
- const entries = (hooks.preCompact as unknown[] | undefined) ?? [];
315
-
316
- const alreadyWired = entries.some(
317
- (entry) =>
318
- typeof entry === 'object' &&
319
- entry !== null &&
320
- typeof (entry as Record<string, unknown>).command === 'string' &&
321
- ((entry as Record<string, unknown>).command as string).includes('# cleo-hook') &&
322
- ((entry as Record<string, unknown>).command as string).includes('precompact.sh'),
323
- );
324
275
 
325
- if (alreadyWired) {
326
- return false;
327
- }
276
+ const alreadyWired = entries.some(
277
+ (entry) =>
278
+ isRecord(entry) &&
279
+ typeof entry.command === 'string' &&
280
+ entry.command.includes('# cleo-hook') &&
281
+ entry.command.includes('precompact.sh'),
282
+ );
283
+ if (alreadyWired) {
284
+ return false;
285
+ }
328
286
 
329
- entries.push({
330
- type: 'command',
331
- command: `"${shimPath}" # cleo-hook`,
332
- timeout: 30,
287
+ hooks.preCompact = [
288
+ ...entries,
289
+ { type: 'command', command: `"${shimPath}" # cleo-hook`, timeout: 30 },
290
+ ];
291
+ config.hooks = hooks;
292
+ return true;
333
293
  });
334
-
335
- hooks.preCompact = entries;
336
- config.hooks = hooks;
337
-
338
- mkdirSync(join(projectDir, '.cursor'), { recursive: true });
339
- writeFileSync(hooksJsonPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
340
- return true;
341
294
  }
342
295
  }