@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.
- package/dist/cant-context.d.ts +7 -5
- package/dist/cant-context.d.ts.map +1 -1
- package/dist/index.js +748 -594
- package/dist/index.js.map +3 -3
- package/dist/providers/claude-code/adapter.d.ts.map +1 -1
- 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/claude-code/spawn.d.ts +2 -2
- package/dist/providers/claude-code/spawn.d.ts.map +1 -1
- package/dist/providers/claude-sdk/spawn.d.ts +1 -1
- package/dist/providers/codex/adapter.d.ts.map +1 -1
- package/dist/providers/codex/spawn.d.ts +1 -1
- package/dist/providers/codex/spawn.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/dist/providers/gemini-cli/adapter.d.ts.map +1 -1
- package/dist/providers/gemini-cli/spawn.d.ts +1 -1
- package/dist/providers/gemini-cli/spawn.d.ts.map +1 -1
- package/dist/providers/kimi/adapter.d.ts.map +1 -1
- package/dist/providers/kimi/spawn.d.ts.map +1 -1
- package/dist/providers/opencode/adapter.d.ts.map +1 -1
- package/dist/providers/opencode/spawn.d.ts +3 -2
- package/dist/providers/opencode/spawn.d.ts.map +1 -1
- package/dist/providers/pi/adapter.d.ts +1 -1
- package/dist/providers/pi/adapter.d.ts.map +1 -1
- package/dist/providers/pi/spawn.d.ts +3 -3
- package/dist/providers/pi/spawn.d.ts.map +1 -1
- package/dist/providers/shared/agent-spawn-wrapper.d.ts +5 -0
- package/dist/providers/shared/agent-spawn-wrapper.d.ts.map +1 -1
- package/dist/providers/shared/cli-spawn.d.ts +28 -0
- package/dist/providers/shared/cli-spawn.d.ts.map +1 -0
- package/dist/providers/shared/credentials.d.ts +2 -2
- package/dist/providers/shared/credentials.d.ts.map +1 -1
- package/dist/providers/shared/prompt-file.d.ts +32 -0
- package/dist/providers/shared/prompt-file.d.ts.map +1 -0
- package/package.json +5 -5
- package/src/__tests__/agent-spawn-wrapper-win32.test.ts +42 -0
- package/src/__tests__/cant-context.test.ts +89 -37
- package/src/__tests__/cli-spawn-win32.test.ts +105 -0
- package/src/__tests__/spawn-prompt-file.test.ts +37 -0
- package/src/cant-context.ts +11 -15
- 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/adapter.ts +6 -11
- 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/claude-code/spawn.ts +11 -25
- package/src/providers/claude-sdk/spawn.ts +1 -1
- package/src/providers/codex/adapter.ts +6 -11
- package/src/providers/codex/spawn.ts +17 -31
- 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
- package/src/providers/gemini-cli/adapter.ts +6 -11
- package/src/providers/gemini-cli/spawn.ts +13 -18
- package/src/providers/kimi/adapter.ts +6 -11
- package/src/providers/kimi/spawn.ts +3 -11
- package/src/providers/opencode/__tests__/spawn-prompt-delivery.test.ts +123 -0
- package/src/providers/opencode/adapter.ts +6 -11
- package/src/providers/opencode/spawn.ts +23 -25
- package/src/providers/pi/adapter.ts +8 -18
- package/src/providers/pi/spawn.ts +15 -33
- package/src/providers/shared/__tests__/credentials-cleo-home.test.ts +42 -0
- package/src/providers/shared/agent-spawn-wrapper.ts +22 -2
- package/src/providers/shared/cli-spawn.ts +41 -0
- package/src/providers/shared/credentials.ts +6 -5
- package/src/providers/shared/prompt-file.ts +44 -0
|
@@ -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
|
|
|
@@ -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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
199
|
-
|
|
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
|
-
|
|
211
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
+
}
|
|
@@ -13,9 +13,7 @@
|
|
|
13
13
|
* @task T11998 — per-session scope/pgid suite containment
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import {
|
|
17
|
-
import { unlink, writeFile } from 'node:fs/promises';
|
|
18
|
-
import { promisify } from 'node:util';
|
|
16
|
+
import { spawn as nodeSpawn } from 'node:child_process';
|
|
19
17
|
import type {
|
|
20
18
|
AdapterSpawnProvider,
|
|
21
19
|
AgentSuiteOwnership,
|
|
@@ -23,11 +21,11 @@ import type {
|
|
|
23
21
|
SpawnResult,
|
|
24
22
|
} from '@cleocode/contracts';
|
|
25
23
|
import { getErrorMessage } from '@cleocode/contracts';
|
|
24
|
+
import { findOnPath } from '@cleocode/paths';
|
|
26
25
|
import { buildAgentSpawnArgs } from '../shared/agent-spawn-wrapper.js';
|
|
26
|
+
import { removeSpawnPromptFile, writeSpawnPromptFile } from '../shared/prompt-file.js';
|
|
27
27
|
import { reapAgentSuite } from './suite-reaper.js';
|
|
28
28
|
|
|
29
|
-
const execAsync = promisify(exec);
|
|
30
|
-
|
|
31
29
|
/** Internal tracking entry for a spawned process. */
|
|
32
30
|
interface TrackedProcess {
|
|
33
31
|
pid: number;
|
|
@@ -48,7 +46,7 @@ interface TrackedProcess {
|
|
|
48
46
|
* @remarks
|
|
49
47
|
* The provider uses `--allow-insecure --no-upgrade-check` flags to
|
|
50
48
|
* ensure the Claude CLI starts without interactive prompts. Prompts are
|
|
51
|
-
* written to temporary files under
|
|
49
|
+
* written to temporary files under a private temp directory (`os.tmpdir()`) and cleaned up after the
|
|
52
50
|
* child process exits. Processes are tracked by instance ID in an
|
|
53
51
|
* in-memory map and verified via `kill(pid, 0)` liveness checks.
|
|
54
52
|
*/
|
|
@@ -59,15 +57,10 @@ export class ClaudeCodeSpawnProvider implements AdapterSpawnProvider {
|
|
|
59
57
|
/**
|
|
60
58
|
* Check if the Claude CLI is available in PATH.
|
|
61
59
|
*
|
|
62
|
-
* @returns true if `claude` is found
|
|
60
|
+
* @returns true if `claude` is found on PATH (PATHEXT-aware on Windows)
|
|
63
61
|
*/
|
|
64
62
|
async canSpawn(): Promise<boolean> {
|
|
65
|
-
|
|
66
|
-
await execAsync('which claude');
|
|
67
|
-
return true;
|
|
68
|
-
} catch {
|
|
69
|
-
return false;
|
|
70
|
-
}
|
|
63
|
+
return findOnPath('claude') !== null;
|
|
71
64
|
}
|
|
72
65
|
|
|
73
66
|
/**
|
|
@@ -103,8 +96,7 @@ export class ClaudeCodeSpawnProvider implements AdapterSpawnProvider {
|
|
|
103
96
|
// CANT enrichment unavailable — use raw prompt
|
|
104
97
|
}
|
|
105
98
|
|
|
106
|
-
tmpFile =
|
|
107
|
-
await writeFile(tmpFile, enrichedPrompt, 'utf-8');
|
|
99
|
+
tmpFile = await writeSpawnPromptFile('claude-spawn', enrichedPrompt);
|
|
108
100
|
|
|
109
101
|
// --print: non-interactive batch mode (process prompt, output response, exit)
|
|
110
102
|
// --dangerously-skip-permissions: allow all tool calls without human approval
|
|
@@ -131,6 +123,8 @@ export class ClaudeCodeSpawnProvider implements AdapterSpawnProvider {
|
|
|
131
123
|
const spawnOpts: Parameters<typeof nodeSpawn>[2] = {
|
|
132
124
|
detached: !isSystemd,
|
|
133
125
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
126
|
+
// win32 `.cmd` shim routed through cmd.exe: keep its quoting (T12618).
|
|
127
|
+
...(spawnBuild.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}),
|
|
134
128
|
};
|
|
135
129
|
|
|
136
130
|
if (context.workingDirectory) {
|
|
@@ -177,11 +171,7 @@ export class ClaudeCodeSpawnProvider implements AdapterSpawnProvider {
|
|
|
177
171
|
const capturedTmpFile = tmpFile;
|
|
178
172
|
child.on('exit', async () => {
|
|
179
173
|
this.processMap.delete(instanceId);
|
|
180
|
-
|
|
181
|
-
await unlink(capturedTmpFile);
|
|
182
|
-
} catch {
|
|
183
|
-
// Ignore cleanup errors
|
|
184
|
-
}
|
|
174
|
+
await removeSpawnPromptFile(capturedTmpFile);
|
|
185
175
|
});
|
|
186
176
|
|
|
187
177
|
return {
|
|
@@ -199,11 +189,7 @@ export class ClaudeCodeSpawnProvider implements AdapterSpawnProvider {
|
|
|
199
189
|
console.error(`[ClaudeCodeSpawnProvider] Failed to spawn: ${getErrorMessage(error)}`);
|
|
200
190
|
|
|
201
191
|
if (tmpFile) {
|
|
202
|
-
|
|
203
|
-
await unlink(tmpFile);
|
|
204
|
-
} catch {
|
|
205
|
-
// Ignore cleanup errors
|
|
206
|
-
}
|
|
192
|
+
await removeSpawnPromptFile(tmpFile);
|
|
207
193
|
}
|
|
208
194
|
|
|
209
195
|
return {
|