@cleocode/adapters 2026.9.19 → 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.
@@ -78,12 +78,28 @@ export declare class ClaudeCodeHookProvider implements AdapterHookProvider {
78
78
  /**
79
79
  * Unregister native hooks.
80
80
  *
81
- * Removes CLEO hook entries from `~/.claude/settings.json` by filtering out
82
- * entries containing the `# cleo-hook` marker.
81
+ * Removes CLEO hook entries from Claude Code's `settings.json` by filtering
82
+ * out entries containing the `# cleo-hook` marker. Same locking, atomicity
83
+ * and parse-error rules as {@link registerNativeHooks}.
83
84
  *
84
85
  * @task T164 @task T555
85
86
  */
86
87
  unregisterNativeHooks(): Promise<void>;
88
+ /** The last settings.json failure, or `null` when the last update succeeded. */
89
+ private settingsError;
90
+ /**
91
+ * Why the last settings.json update did not happen, or `null`.
92
+ *
93
+ * @remarks
94
+ * Hook registration stays non-fatal for adapter initialisation, so a
95
+ * refused write (malformed settings.json, lock timeout) is surfaced here and
96
+ * on stderr instead of being swallowed (T12385).
97
+ *
98
+ * @returns The failure message, or `null`
99
+ */
100
+ getSettingsError(): string | null;
101
+ /** Apply `mutate` to settings.json via CAAMP's locked, atomic JSON writer. */
102
+ private updateSettings;
87
103
  /**
88
104
  * Check whether hooks have been registered via `registerNativeHooks`.
89
105
  */
@@ -1 +1 @@
1
- {"version":3,"file":"hooks.d.ts","sourceRoot":"","sources":["../../../src/providers/claude-code/hooks.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAMH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AA8C/D;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,qBAAa,sBAAuB,YAAW,mBAAmB;IAChE,kEAAkE;IAClE,OAAO,CAAC,UAAU,CAAS;IAE3B;;;;;;;;;;;OAWG;IACH,gBAAgB,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI;IAItD,+DAA+D;IAC/D,OAAO,CAAC,UAAU,CAAuB;IAEzC;;;;;;;;;;;;;;;;OAgBG;IACG,mBAAmB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IA6E5D;;;;;;;OAOG;IACG,qBAAqB,IAAI,OAAO,CAAC,IAAI,CAAC;IA2C5C;;OAEG;IACH,YAAY,IAAI,OAAO;IAIvB;;;;OAIG;IACH,aAAa,IAAI,MAAM,GAAG,IAAI;IAI9B;;;;;;;;OAQG;IACH,WAAW,IAAI,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAI/C;;;;;;;;;;OAUG;IACG,2BAA2B,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;IAStD;;;;;;;;;;OAUG;IACG,kBAAkB,IAAI,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC;IASnD;;;;;;;;;;OAUG;IACG,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IAW9D;;;;;;;;;;;;;;;;OAgBG;IACG,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;CA4FpF"}
1
+ {"version":3,"file":"hooks.d.ts","sourceRoot":"","sources":["../../../src/providers/claude-code/hooks.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAMH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAqD/D;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,qBAAa,sBAAuB,YAAW,mBAAmB;IAChE,kEAAkE;IAClE,OAAO,CAAC,UAAU,CAAS;IAE3B;;;;;;;;;;;OAWG;IACH,gBAAgB,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI;IAItD,+DAA+D;IAC/D,OAAO,CAAC,UAAU,CAAuB;IAEzC;;;;;;;;;;;;;;;;OAgBG;IACG,mBAAmB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAuC5D;;;;;;;;OAQG;IACG,qBAAqB,IAAI,OAAO,CAAC,IAAI,CAAC;IAW5C,gFAAgF;IAChF,OAAO,CAAC,aAAa,CAAuB;IAE5C;;;;;;;;;OASG;IACH,gBAAgB,IAAI,MAAM,GAAG,IAAI;IAIjC,8EAA8E;YAChE,cAAc;IAe5B;;OAEG;IACH,YAAY,IAAI,OAAO;IAIvB;;;;OAIG;IACH,aAAa,IAAI,MAAM,GAAG,IAAI;IAI9B;;;;;;;;OAQG;IACH,WAAW,IAAI,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAI/C;;;;;;;;;;OAUG;IACG,2BAA2B,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;IAStD;;;;;;;;;;OAUG;IACG,kBAAkB,IAAI,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC;IASnD;;;;;;;;;;OAUG;IACG,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IAW9D;;;;;;;;;;;;;;;;OAgBG;IACG,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;CA4FpF"}
@@ -1 +1 @@
1
- {"version":3,"file":"install.d.ts","sourceRoot":"","sources":["../../../src/providers/claude-code/install.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAcH,OAAO,KAAK,EAAE,sBAAsB,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAkBjG;;;;;;;;;;;;;GAaG;AACH,qBAAa,yBAA0B,YAAW,sBAAsB;IACtE;;;;;OAKG;IACG,OAAO,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,aAAa,CAAC;IAwC9D;;;;OAIG;IACG,SAAS,IAAI,OAAO,CAAC,IAAI,CAAC;IAEhC;;;;OAIG;IACG,WAAW,IAAI,OAAO,CAAC,OAAO,CAAC;IAkBrC;;;;;;;OAOG;IACG,2BAA2B,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIpE;;;;;;;;OAQG;IACH,OAAO,CAAC,eAAe;IAsBvB;;;;OAIG;IACH,OAAO,CAAC,cAAc;IAkCtB;;;;;;;;;;;;;;;;;;;OAmBG;IACH,OAAO,CAAC,oBAAoB;IAgC5B;;;;;;;;;;;;;OAaG;IACH,OAAO,CAAC,sBAAsB;CAmD/B"}
1
+ {"version":3,"file":"install.d.ts","sourceRoot":"","sources":["../../../src/providers/claude-code/install.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAOH,OAAO,KAAK,EAAE,sBAAsB,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAyBjG;;;;;;;;;;;;;GAaG;AACH,qBAAa,yBAA0B,YAAW,sBAAsB;IACtE;;;;;OAKG;IACG,OAAO,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,aAAa,CAAC;IAwD9D;;;;OAIG;IACG,SAAS,IAAI,OAAO,CAAC,IAAI,CAAC;IAEhC;;;;OAIG;IACG,WAAW,IAAI,OAAO,CAAC,OAAO,CAAC;IAkBrC;;;;;;;OAOG;IACG,2BAA2B,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIpE;;;;;;;;OAQG;IACH,OAAO,CAAC,eAAe;IAsBvB;;;;OAIG;YACW,cAAc;IAwB5B;;;;;;;;;;;;;;;;;;;OAmBG;YACW,oBAAoB;IAgClC;;;;;;;;;;;;;OAaG;YACW,sBAAsB;CAmBrC"}
@@ -29,4 +29,100 @@ export declare class ClaudeCodePathProvider implements AdapterPathProvider {
29
29
  /** Get the path to the provider's memory database, or null if unsupported. */
30
30
  getMemoryDbPath(): string | null;
31
31
  }
32
+ /**
33
+ * Resolve Claude Code's `settings.json`, honouring `CLAUDE_SETTINGS` and
34
+ * `CLAUDE_HOME`.
35
+ *
36
+ * @remarks
37
+ * The single resolver for every adapter write to the settings file (T12385).
38
+ * Hook registration previously hard-coded `~/.claude/settings.json` while the
39
+ * installer honoured `CLAUDE_HOME`, so the two wrote different files whenever
40
+ * the override was set.
41
+ *
42
+ * @returns Absolute path of the settings file
43
+ *
44
+ * @example
45
+ * ```typescript
46
+ * const settingsPath = claudeSettingsPath();
47
+ * ```
48
+ */
49
+ export declare function claudeSettingsPath(): string;
50
+ /**
51
+ * Whether a parsed settings value is a plain JSON object.
52
+ *
53
+ * @param value - Any parsed JSON value
54
+ * @returns `true` for a non-null, non-array object
55
+ *
56
+ * @example
57
+ * ```typescript
58
+ * isPlainObject(settings.enabledPlugins);
59
+ * ```
60
+ */
61
+ export declare function isPlainObject(value: unknown): value is Record<string, unknown>;
62
+ /**
63
+ * Whether a settings.json hook entry carries a CLEO-owned command.
64
+ *
65
+ * @param entry - One element of a `hooks.<Event>` array
66
+ * @param commandIncludes - Extra substring the CLEO command must contain
67
+ * @returns `true` when one of the entry's commands has the `# cleo-hook` marker
68
+ *
69
+ * @example
70
+ * ```typescript
71
+ * isCleoHookEntry({ hooks: [{ type: "command", command: "x # cleo-hook" }] }); // true
72
+ * ```
73
+ */
74
+ export declare function isCleoHookEntry(entry: unknown, commandIncludes?: string): boolean;
75
+ /**
76
+ * Whether a `hooks.<Event>` value holds a CLEO-owned entry.
77
+ *
78
+ * @param entries - The event's value (non-arrays hold none)
79
+ * @param commandIncludes - Extra substring the CLEO command must contain
80
+ * @returns `true` when a CLEO entry is present
81
+ *
82
+ * @example
83
+ * ```typescript
84
+ * hasCleoHook(hookMap(settings).Stop);
85
+ * ```
86
+ */
87
+ export declare function hasCleoHook(entries: unknown, commandIncludes?: string): boolean;
88
+ /**
89
+ * Return the settings object's `hooks` map, creating an empty one if absent.
90
+ *
91
+ * @param settings - Parsed settings.json
92
+ * @returns The live `hooks` object (mutations apply to `settings`)
93
+ * @throws Error when `hooks` exists but is not an object — the caller's write
94
+ * is aborted and the file left untouched
95
+ *
96
+ * @example
97
+ * ```typescript
98
+ * const hooks = hookMap(settings);
99
+ * ```
100
+ */
101
+ export declare function hookMap(settings: Record<string, unknown>): Record<string, unknown>;
102
+ /**
103
+ * Append a hook entry to `hooks[event]`, preserving existing entries.
104
+ *
105
+ * @param hooks - The settings `hooks` map
106
+ * @param event - Native event name (e.g. `Stop`, `PreCompact`)
107
+ * @param entry - Entry to append
108
+ * @throws Error when `hooks[event]` exists but is not an array
109
+ *
110
+ * @example
111
+ * ```typescript
112
+ * appendHookEntry(hookMap(settings), "Stop", { matcher: "", hooks: [] });
113
+ * ```
114
+ */
115
+ export declare function appendHookEntry(hooks: Record<string, unknown>, event: string, entry: Record<string, unknown>): void;
116
+ /**
117
+ * Remove every CLEO-owned entry from every event array in `hooks`.
118
+ *
119
+ * @param hooks - The settings `hooks` map
120
+ * @returns Whether anything was removed
121
+ *
122
+ * @example
123
+ * ```typescript
124
+ * const changed = removeCleoHookEntries(hookMap(settings));
125
+ * ```
126
+ */
127
+ export declare function removeCleoHookEntries(hooks: Record<string, unknown>): boolean;
32
128
  //# sourceMappingURL=paths.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"paths.d.ts","sourceRoot":"","sources":["../../../src/providers/claude-code/paths.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAE/D;;;;;;;;;;;;GAYG;AACH,qBAAa,sBAAuB,YAAW,mBAAmB;IAChE,uDAAuD;IACvD,cAAc,IAAI,MAAM;IAIxB,4EAA4E;IAC5E,eAAe,IAAI,MAAM,GAAG,IAAI;IAIhC,4EAA4E;IAC5E,kBAAkB,IAAI,MAAM,GAAG,IAAI;IAInC,8EAA8E;IAC9E,eAAe,IAAI,MAAM,GAAG,IAAI;CAGjC"}
1
+ {"version":3,"file":"paths.d.ts","sourceRoot":"","sources":["../../../src/providers/claude-code/paths.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAE/D;;;;;;;;;;;;GAYG;AACH,qBAAa,sBAAuB,YAAW,mBAAmB;IAChE,uDAAuD;IACvD,cAAc,IAAI,MAAM;IAIxB,4EAA4E;IAC5E,eAAe,IAAI,MAAM,GAAG,IAAI;IAIhC,4EAA4E;IAC5E,kBAAkB,IAAI,MAAM,GAAG,IAAI;IAInC,8EAA8E;IAC9E,eAAe,IAAI,MAAM,GAAG,IAAI;CAGjC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,kBAAkB,IAAI,MAAM,CAI3C;AAKD;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAE9E;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,EAAE,eAAe,CAAC,EAAE,MAAM,GAAG,OAAO,CASjF;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,OAAO,EAAE,eAAe,CAAC,EAAE,MAAM,GAAG,OAAO,CAE/E;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,OAAO,CAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAWlF;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,eAAe,CAC7B,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC7B,IAAI,CAMN;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAW7E"}
@@ -60,33 +60,22 @@ export declare class CursorInstallProvider implements AdapterInstallProvider {
60
60
  */
61
61
  ensureInstructionReferences(projectDir: string): Promise<void>;
62
62
  /**
63
- * Update instruction files with CLEO @-references.
63
+ * Update Cursor's auxiliary rule files through the CAAMP writer.
64
64
  *
65
- * Handles both legacy (.cursorrules) and modern (.cursor/rules/cleo.mdc) formats.
65
+ * @remarks
66
+ * ADR-064 / T12385: both files are written by CAAMP's
67
+ * `ensureProviderRuleFile` — locked, atomic, with references taken from the
68
+ * provider registry — instead of raw `writeFileSync` with a local copy of
69
+ * the references.
70
+ *
71
+ * - Legacy `.cursorrules` is only updated when it already exists; CLEO's
72
+ * references live in a CAAMP block and user text is preserved.
73
+ * - Modern `.cursor/rules/cleo.mdc` is CLEO-owned: MDC frontmatter followed
74
+ * by the CAAMP block.
66
75
  *
67
76
  * @returns true if any file was created or modified
68
77
  */
69
78
  private updateInstructionFiles;
70
- /**
71
- * Update legacy .cursorrules file with @-references.
72
- *
73
- * References are sourced from the CAAMP provider registry (T1919) rather than
74
- * a local constant, keeping this adapter in sync with the single source of truth.
75
- * Only modifies the file if it already exists (does not create it).
76
- *
77
- * @returns true if the file was modified
78
- */
79
- private updateLegacyRules;
80
- /**
81
- * Create or update .cursor/rules/cleo.mdc with CLEO references.
82
- *
83
- * MDC (Markdown Component) format is Cursor's modern rule file format.
84
- * Each .mdc file in .cursor/rules/ is loaded as a rule set.
85
- * References are sourced from the CAAMP provider registry (T1919).
86
- *
87
- * @returns true if the file was created or modified
88
- */
89
- private updateModernRules;
90
79
  /**
91
80
  * Get list of instruction files that were updated.
92
81
  */
@@ -1 +1 @@
1
- {"version":3,"file":"install.d.ts","sourceRoot":"","sources":["../../../src/providers/cursor/install.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAKH,OAAO,KAAK,EAAE,sBAAsB,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAOjG;;;;;;;;;;;;;GAaG;AACH,qBAAa,qBAAsB,YAAW,sBAAsB;IAClE;;;;;OAKG;IACG,OAAO,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,aAAa,CAAC;IA+B9D;;;;OAIG;IACG,SAAS,IAAI,OAAO,CAAC,IAAI,CAAC;IAEhC;;;;OAIG;IACG,WAAW,IAAI,OAAO,CAAC,OAAO,CAAC;IA2BrC;;;;;;;OAOG;IACG,2BAA2B,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAKpE;;;;;;OAMG;IACH,OAAO,CAAC,sBAAsB;IAgB9B;;;;;;;;OAQG;IACH,OAAO,CAAC,iBAAiB;IAuBzB;;;;;;;;OAQG;IACH,OAAO,CAAC,iBAAiB;IA+BzB;;OAEG;IACH,OAAO,CAAC,kBAAkB;IAS1B;;;;;;;;;;;;;;;;;OAiBG;IACH,OAAO,CAAC,oBAAoB;IA8B5B;;;;;;;;;;;;OAYG;IACH,OAAO,CAAC,sBAAsB;CAyC/B"}
1
+ {"version":3,"file":"install.d.ts","sourceRoot":"","sources":["../../../src/providers/cursor/install.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAUH,OAAO,KAAK,EAAE,sBAAsB,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAsBjG;;;;;;;;;;;;;GAaG;AACH,qBAAa,qBAAsB,YAAW,sBAAsB;IAClE;;;;;OAKG;IACG,OAAO,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,aAAa,CAAC;IAuC9D;;;;OAIG;IACG,SAAS,IAAI,OAAO,CAAC,IAAI,CAAC;IAEhC;;;;OAIG;IACG,WAAW,IAAI,OAAO,CAAC,OAAO,CAAC;IA2BrC;;;;;;;OAOG;IACG,2BAA2B,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAKpE;;;;;;;;;;;;;;;OAeG;YACW,sBAAsB;IAcpC;;OAEG;IACH,OAAO,CAAC,kBAAkB;IAS1B;;;;;;;;;;;;;;;;;OAiBG;YACW,oBAAoB;IA8BlC;;;;;;;;;;;;OAYG;YACW,sBAAsB;CA+BrC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cleocode/adapters",
3
- "version": "2026.9.19",
3
+ "version": "2026.9.20",
4
4
  "description": "Unified provider adapters for CLEO (Claude Code, OpenCode, Cursor)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -15,9 +15,9 @@
15
15
  "@ai-sdk/anthropic": "^3.0.69",
16
16
  "@ai-sdk/openai": "^2.0.53",
17
17
  "ai": "^6.0.168",
18
- "@cleocode/caamp": "2026.9.19",
19
- "@cleocode/paths": "2026.9.19",
20
- "@cleocode/contracts": "2026.9.19"
18
+ "@cleocode/caamp": "2026.9.20",
19
+ "@cleocode/contracts": "2026.9.20",
20
+ "@cleocode/paths": "2026.9.20"
21
21
  },
22
22
  "license": "MIT",
23
23
  "engines": {
@@ -30,7 +30,7 @@
30
30
  "devDependencies": {
31
31
  "@types/node": "^24.3.0",
32
32
  "vitest": "^4.1.4",
33
- "@cleocode/playbooks": "2026.9.19"
33
+ "@cleocode/playbooks": "2026.9.20"
34
34
  },
35
35
  "repository": {
36
36
  "type": "git",
@@ -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
  });
@@ -16,11 +16,18 @@
16
16
  * @epic T134
17
17
  */
18
18
 
19
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
19
+ import { existsSync } from 'node:fs';
20
20
  import { readdir, readFile } from 'node:fs/promises';
21
- import { homedir } from 'node:os';
22
21
  import { join } from 'node:path';
22
+ import { updateJsonConfigFile } from '@cleocode/caamp';
23
23
  import type { AdapterHookProvider } from '@cleocode/contracts';
24
+ import {
25
+ appendHookEntry,
26
+ claudeSettingsPath,
27
+ hasCleoHook,
28
+ hookMap,
29
+ removeCleoHookEntries,
30
+ } from './paths.js';
24
31
 
25
32
  /** CAAMP provider identifier for Claude Code. */
26
33
  const PROVIDER_ID = 'claude-code' as const;
@@ -133,56 +140,23 @@ export class ClaudeCodeHookProvider implements AdapterHookProvider {
133
140
  this.projectDir = projectDir;
134
141
  this.registered = true;
135
142
 
136
- // Write CLEO hook entries to ~/.claude/settings.json (idempotent)
137
- try {
138
- const home = homedir();
139
- const settingsPath = join(home, '.claude', 'settings.json');
140
-
141
- let settings: Record<string, unknown> = {};
142
- if (existsSync(settingsPath)) {
143
- try {
144
- settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
145
- } catch {
146
- // Start fresh if settings.json is corrupt
147
- }
148
- }
149
-
150
- const hooks = (settings.hooks ?? {}) as Record<string, unknown[]>;
151
-
152
- // Check if CLEO hooks already registered (look for our marker comment in commands)
153
- const alreadyRegistered = Object.values(hooks).some(
154
- (entries) =>
155
- Array.isArray(entries) &&
156
- entries.some(
157
- (e) =>
158
- typeof e === 'object' &&
159
- e !== null &&
160
- Array.isArray((e as Record<string, unknown>).hooks) &&
161
- ((e as Record<string, unknown>).hooks as Array<Record<string, string>>).some(
162
- (h) => typeof h.command === 'string' && h.command.includes('# cleo-hook'),
163
- ),
164
- ),
165
- );
166
-
167
- if (alreadyRegistered) {
168
- return; // Already wired — idempotent
143
+ // Write CLEO hook entries to settings.json (idempotent). T12385: locked,
144
+ // atomic, and a malformed file is reported and left untouched — never
145
+ // replaced with an object holding only CLEO's entries.
146
+ await this.updateSettings((settings) => {
147
+ const hooks = hookMap(settings);
148
+ if (Object.values(hooks).some((entries) => hasCleoHook(entries))) {
149
+ return false; // Already wired — idempotent
169
150
  }
170
151
 
171
152
  // Register Stop hook → triggers cleo session end (LLM extraction, reflector, consolidation)
172
- if (!hooks.Stop) hooks.Stop = [];
173
- (hooks.Stop as unknown[]).push({
153
+ appendHookEntry(hooks, 'Stop', {
174
154
  matcher: '',
175
- hooks: [
176
- {
177
- type: 'command',
178
- command: `cleo session end --quiet # cleo-hook`,
179
- },
180
- ],
155
+ hooks: [{ type: 'command', command: `cleo session end --quiet # cleo-hook` }],
181
156
  });
182
157
 
183
158
  // Register PostToolUse hook → brain observation for file writes + NEXUS post-check (T625)
184
- if (!hooks.PostToolUse) hooks.PostToolUse = [];
185
- (hooks.PostToolUse as unknown[]).push({
159
+ appendHookEntry(hooks, 'PostToolUse', {
186
160
  matcher: 'Write|Edit',
187
161
  hooks: [
188
162
  {
@@ -197,20 +171,16 @@ export class ClaudeCodeHookProvider implements AdapterHookProvider {
197
171
  },
198
172
  ],
199
173
  });
200
-
201
- settings.hooks = hooks;
202
- mkdirSync(join(home, '.claude'), { recursive: true });
203
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
204
- } catch {
205
- // Settings write failure is non-fatal — hooks can be registered manually
206
- }
174
+ return true;
175
+ });
207
176
  }
208
177
 
209
178
  /**
210
179
  * Unregister native hooks.
211
180
  *
212
- * Removes CLEO hook entries from `~/.claude/settings.json` by filtering out
213
- * entries containing the `# cleo-hook` marker.
181
+ * Removes CLEO hook entries from Claude Code's `settings.json` by filtering
182
+ * out entries containing the `# cleo-hook` marker. Same locking, atomicity
183
+ * and parse-error rules as {@link registerNativeHooks}.
214
184
  *
215
185
  * @task T164 @task T555
216
186
  */
@@ -218,42 +188,43 @@ export class ClaudeCodeHookProvider implements AdapterHookProvider {
218
188
  this.registered = false;
219
189
  this.projectDir = null;
220
190
 
221
- try {
222
- const home = homedir();
223
- const settingsPath = join(home, '.claude', 'settings.json');
224
- if (!existsSync(settingsPath)) return;
225
-
226
- const settings = JSON.parse(readFileSync(settingsPath, 'utf-8')) as Record<string, unknown>;
227
- const hooks = settings.hooks as Record<string, unknown[]> | undefined;
228
- if (!hooks) return;
229
-
230
- // Filter out entries with the cleo-hook marker
231
- let changed = false;
232
- for (const [event, entries] of Object.entries(hooks)) {
233
- if (!Array.isArray(entries)) continue;
234
- const filtered = entries.filter(
235
- (e) =>
236
- !(
237
- typeof e === 'object' &&
238
- e !== null &&
239
- Array.isArray((e as Record<string, unknown>).hooks) &&
240
- ((e as Record<string, unknown>).hooks as Array<Record<string, string>>).some(
241
- (h) => typeof h.command === 'string' && h.command.includes('# cleo-hook'),
242
- )
243
- ),
244
- );
245
- if (filtered.length !== entries.length) {
246
- hooks[event] = filtered;
247
- changed = true;
248
- }
249
- }
191
+ if (!existsSync(claudeSettingsPath())) return;
192
+ await this.updateSettings((settings) => {
193
+ if (settings.hooks === undefined) return false;
194
+ return removeCleoHookEntries(hookMap(settings));
195
+ });
196
+ }
250
197
 
251
- if (changed) {
252
- settings.hooks = hooks;
253
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
254
- }
255
- } catch {
256
- // Cleanup failure is non-fatal
198
+ /** The last settings.json failure, or `null` when the last update succeeded. */
199
+ private settingsError: string | null = null;
200
+
201
+ /**
202
+ * Why the last settings.json update did not happen, or `null`.
203
+ *
204
+ * @remarks
205
+ * Hook registration stays non-fatal for adapter initialisation, so a
206
+ * refused write (malformed settings.json, lock timeout) is surfaced here and
207
+ * on stderr instead of being swallowed (T12385).
208
+ *
209
+ * @returns The failure message, or `null`
210
+ */
211
+ getSettingsError(): string | null {
212
+ return this.settingsError;
213
+ }
214
+
215
+ /** Apply `mutate` to settings.json via CAAMP's locked, atomic JSON writer. */
216
+ private async updateSettings(
217
+ mutate: (settings: Record<string, unknown>) => boolean,
218
+ ): Promise<void> {
219
+ const settingsPath = claudeSettingsPath();
220
+ try {
221
+ await updateJsonConfigFile(settingsPath, mutate);
222
+ this.settingsError = null;
223
+ } catch (err) {
224
+ this.settingsError = err instanceof Error ? err.message : String(err);
225
+ process.stderr.write(
226
+ `[cleo:claude-code] hooks not written to ${settingsPath}: ${this.settingsError}\n`,
227
+ );
257
228
  }
258
229
  }
259
230