@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.
- package/dist/index.js +270 -289
- package/dist/index.js.map +2 -2
- package/dist/providers/claude-code/hooks.d.ts +18 -2
- package/dist/providers/claude-code/hooks.d.ts.map +1 -1
- package/dist/providers/claude-code/install.d.ts.map +1 -1
- package/dist/providers/claude-code/paths.d.ts +96 -0
- package/dist/providers/claude-code/paths.d.ts.map +1 -1
- package/dist/providers/cursor/install.d.ts +11 -22
- package/dist/providers/cursor/install.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/providers/claude-code/__tests__/adapter.test.ts +1 -0
- package/src/providers/claude-code/__tests__/hooks-install.test.ts +63 -2
- package/src/providers/claude-code/hooks.ts +60 -89
- package/src/providers/claude-code/install.ts +70 -96
- package/src/providers/claude-code/paths.ts +155 -0
- package/src/providers/cursor/__tests__/adapter.test.ts +7 -0
- package/src/providers/cursor/__tests__/hooks-install.test.ts +36 -0
- package/src/providers/cursor/install.ts +86 -133
|
@@ -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
|
|
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;
|
|
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;
|
|
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
|
|
63
|
+
* Update Cursor's auxiliary rule files through the CAAMP writer.
|
|
64
64
|
*
|
|
65
|
-
*
|
|
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;
|
|
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.
|
|
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
|
-
"@cleocode/
|
|
20
|
-
"@cleocode/
|
|
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.
|
|
33
|
+
"@cleocode/playbooks": "2026.9.20"
|
|
34
34
|
},
|
|
35
35
|
"repository": {
|
|
36
36
|
"type": "git",
|
|
@@ -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
|
|
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
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
|