claude-dev-env 8.88.2 → 8.88.4
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/bin/codex-trimmed-sol.mjs +75 -13
- package/bin/codex-trimmed-sol.test.mjs +116 -1
- package/bin/install.mjs +95 -3
- package/bin/install.test.mjs +140 -0
- package/bin/profile-stop-gate.mjs +242 -0
- package/bin/profile-stop-gate.test.mjs +160 -0
- package/docs/agents-guidance/asks.md +1 -1
- package/docs/codex-accounts.md +10 -1
- package/package.json +1 -1
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import { lstatSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import {
|
|
1
|
+
import { lstatSync, mkdirSync, readFileSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { DEFAULT_CODEX_DIRECTORY_NAME } from './install-constants.mjs';
|
|
3
6
|
|
|
4
7
|
export const SOL_PROMPT_DIRECTORY_NAME = 'system-prompts';
|
|
5
8
|
export const SOL_PROMPT_FILE_NAME = 'codex-sol.md';
|
|
@@ -53,34 +56,87 @@ function readCodexConfig(configPath) {
|
|
|
53
56
|
return { isReadable: true, currentText: readFileSync(configPath, 'utf8') };
|
|
54
57
|
}
|
|
55
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Return the Codex home whose folder holds the prompt the config.toml setting names.
|
|
61
|
+
*
|
|
62
|
+
* Account sync links each account home's config.toml to the main Codex
|
|
63
|
+
* home's file: a symbolic link, or a hard link when the account cannot
|
|
64
|
+
* create one. Every home that shares that file shares one setting, so the
|
|
65
|
+
* prompt it names belongs in the main home. Any other home uses its own
|
|
66
|
+
* folder, including a home whose config.toml links into a dotfiles folder.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} codexHome
|
|
69
|
+
* @returns {string}
|
|
70
|
+
*/
|
|
71
|
+
export function codexConfigDirectory(codexHome) {
|
|
72
|
+
const mainCodexHome = join(homedir(), DEFAULT_CODEX_DIRECTORY_NAME);
|
|
73
|
+
return sharesMainConfig(codexHome, mainCodexHome) ? mainCodexHome : codexHome;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function sharesMainConfig(codexHome, mainCodexHome) {
|
|
77
|
+
return isSameFile(join(codexHome, CODEX_CONFIG_FILE_NAME), join(mainCodexHome, CODEX_CONFIG_FILE_NAME));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isSameFile(firstPath, secondPath) {
|
|
81
|
+
try {
|
|
82
|
+
const firstEntry = statSync(firstPath, { bigint: true, throwIfNoEntry: false });
|
|
83
|
+
const secondEntry = statSync(secondPath, { bigint: true, throwIfNoEntry: false });
|
|
84
|
+
return Boolean(firstEntry && secondEntry && firstEntry.dev === secondEntry.dev && firstEntry.ino === secondEntry.ino);
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* True when CODEX_HOME is an account home whose config.toml is the main Codex home's file.
|
|
92
|
+
*
|
|
93
|
+
* Such a home borrows the setting and the prompt from the main home, so
|
|
94
|
+
* uninstalling from it leaves both alone. The homes are compared as folders
|
|
95
|
+
* on disk, so a CODEX_HOME spelled with other letter case or through a link
|
|
96
|
+
* still counts as the main home.
|
|
97
|
+
*
|
|
98
|
+
* @param {string} codexHome
|
|
99
|
+
* @returns {boolean}
|
|
100
|
+
*/
|
|
101
|
+
export function codexConfigIsBorrowed(codexHome) {
|
|
102
|
+
const mainCodexHome = join(homedir(), DEFAULT_CODEX_DIRECTORY_NAME);
|
|
103
|
+
return sharesMainConfig(codexHome, mainCodexHome) && !isSameFile(codexHome, mainCodexHome);
|
|
104
|
+
}
|
|
105
|
+
|
|
56
106
|
/**
|
|
57
107
|
* Return where the installer keeps the Codex copy of the trimmed Sol prompt.
|
|
58
108
|
*
|
|
59
|
-
* The copy
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
109
|
+
* The copy sits in CODEX_HOME, or in the main Codex home when CODEX_HOME is
|
|
110
|
+
* an account home that shares its config.toml, so the file the setting names
|
|
111
|
+
* stays put when any claude-dev-env version, older ones included, prunes or
|
|
112
|
+
* rewrites the managed files under ~/.claude, and every home that shares that
|
|
113
|
+
* config.toml names the same copy. A missing file makes every Codex command
|
|
114
|
+
* fail before it starts.
|
|
63
115
|
*
|
|
64
116
|
* @param {string} codexHome
|
|
65
117
|
* @returns {string}
|
|
66
118
|
*/
|
|
67
119
|
export function codexSolPromptPath(codexHome) {
|
|
68
|
-
return join(codexHome, SOL_PROMPT_FILE_NAME);
|
|
120
|
+
return join(codexConfigDirectory(codexHome), SOL_PROMPT_FILE_NAME);
|
|
69
121
|
}
|
|
70
122
|
|
|
71
123
|
/**
|
|
72
|
-
* Copy the trimmed Sol prompt
|
|
124
|
+
* Copy the trimmed Sol prompt to the folder `codexSolPromptPath` names.
|
|
125
|
+
*
|
|
126
|
+
* An account home that shares the main config.toml also loses the copy an earlier
|
|
127
|
+
* install left in its own folder, since no setting names that copy any more.
|
|
73
128
|
*
|
|
74
129
|
* @param {string} codexHome
|
|
75
130
|
* @param {string} sourcePromptPath The prompt file the package ships.
|
|
76
|
-
* @returns {string|null} The
|
|
131
|
+
* @returns {string|null} The prompt copy's path when this call wrote it, and null when it was already current.
|
|
77
132
|
*/
|
|
78
133
|
export function installCodexSolPrompt(codexHome, sourcePromptPath) {
|
|
134
|
+
if (codexConfigIsBorrowed(codexHome)) rmSync(join(codexHome, SOL_PROMPT_FILE_NAME), { force: true });
|
|
79
135
|
const promptPath = codexSolPromptPath(codexHome);
|
|
80
136
|
const promptText = readFileSync(sourcePromptPath, 'utf8');
|
|
81
137
|
const promptEntry = lstatSync(promptPath, { throwIfNoEntry: false });
|
|
82
138
|
if (promptEntry?.isFile() && readFileSync(promptPath, 'utf8') === promptText) return null;
|
|
83
|
-
mkdirSync(
|
|
139
|
+
mkdirSync(dirname(promptPath), { recursive: true });
|
|
84
140
|
writeFileSync(promptPath, promptText, 'utf8');
|
|
85
141
|
return promptPath;
|
|
86
142
|
}
|
|
@@ -120,14 +176,20 @@ export function writeCodexSolSetting(codexHome, instructionsPath) {
|
|
|
120
176
|
* Remove the package's `model_instructions_file` line from CODEX_HOME/config.toml.
|
|
121
177
|
*
|
|
122
178
|
* The rest of the file stays as the user wrote it. A file that held only the
|
|
123
|
-
* package line is removed. The
|
|
124
|
-
*
|
|
125
|
-
*
|
|
179
|
+
* package line is removed. The prompt copy is removed in every case, because
|
|
180
|
+
* install writes it even when the user's own setting kept the package line
|
|
181
|
+
* out. An account home that shares the main config.toml only drops its own
|
|
182
|
+
* leftover copy: the shared setting and prompt belong to the main home, and
|
|
183
|
+
* other homes still read them.
|
|
126
184
|
*
|
|
127
185
|
* @param {string} codexHome
|
|
128
186
|
* @returns {string|null} The config path when it changed, or null when nothing was removed.
|
|
129
187
|
*/
|
|
130
188
|
export function removeCodexSolSetting(codexHome) {
|
|
189
|
+
if (codexConfigIsBorrowed(codexHome)) {
|
|
190
|
+
rmSync(join(codexHome, SOL_PROMPT_FILE_NAME), { force: true });
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
131
193
|
rmSync(codexSolPromptPath(codexHome), { force: true });
|
|
132
194
|
const configPath = join(codexHome, CODEX_CONFIG_FILE_NAME);
|
|
133
195
|
const { isReadable, currentText } = readCodexConfig(configPath);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import { strict as assert } from 'node:assert';
|
|
3
3
|
import { spawnSync } from 'node:child_process';
|
|
4
|
-
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
@@ -264,3 +264,118 @@ test('uninstall removes the Codex copy of the prompt when the user wrote their o
|
|
|
264
264
|
assert.equal(existsSync(promptPath), false);
|
|
265
265
|
assert.equal(readFileSync(configPath, 'utf8'), userText);
|
|
266
266
|
});
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
function makeLinkedCodexHomes(context) {
|
|
270
|
+
const homeDirectory = makeScratchHome(context);
|
|
271
|
+
const ownerHome = join(homeDirectory, '.codex');
|
|
272
|
+
const linkedHome = join(homeDirectory, '.codex-editor');
|
|
273
|
+
mkdirSync(ownerHome, { recursive: true });
|
|
274
|
+
mkdirSync(linkedHome, { recursive: true });
|
|
275
|
+
writeFileSync(join(ownerHome, CODEX_CONFIG_FILE_NAME), 'approval_policy = "never"\n');
|
|
276
|
+
try {
|
|
277
|
+
symlinkSync(join(ownerHome, CODEX_CONFIG_FILE_NAME), join(linkedHome, CODEX_CONFIG_FILE_NAME), 'file');
|
|
278
|
+
} catch (error) {
|
|
279
|
+
if (error.code === 'EPERM') return null;
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
282
|
+
return { homeDirectory, ownerHome, linkedHome };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
test('a home whose config.toml links to another home names the prompt beside the real file', (context) => {
|
|
286
|
+
const homes = makeLinkedCodexHomes(context);
|
|
287
|
+
if (!homes) return context.skip('this account cannot create symlinks');
|
|
288
|
+
const { homeDirectory, ownerHome, linkedHome } = homes;
|
|
289
|
+
writeFileSync(join(linkedHome, 'codex-sol.md'), 'left by an earlier install\n');
|
|
290
|
+
|
|
291
|
+
assert.equal(installInScratchHome(homeDirectory, [], { CODEX_HOME: ownerHome }).status, 0);
|
|
292
|
+
const linkedInstallation = installInScratchHome(homeDirectory, [], { CODEX_HOME: linkedHome });
|
|
293
|
+
|
|
294
|
+
assert.equal(linkedInstallation.status, 0, linkedInstallation.stderr);
|
|
295
|
+
const configText = readFileSync(join(ownerHome, CODEX_CONFIG_FILE_NAME), 'utf8');
|
|
296
|
+
assert.ok(configText.includes(`model_instructions_file = ${JSON.stringify(join(ownerHome, 'codex-sol.md'))}`), configText);
|
|
297
|
+
assert.equal(readFileSync(join(ownerHome, 'codex-sol.md'), 'utf8'), readFileSync(shippedPromptPath, 'utf8'));
|
|
298
|
+
assert.equal(existsSync(join(linkedHome, 'codex-sol.md')), false);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test('uninstall from a home whose config.toml is linked keeps the shared setting and prompt', (context) => {
|
|
302
|
+
const homes = makeLinkedCodexHomes(context);
|
|
303
|
+
if (!homes) return context.skip('this account cannot create symlinks');
|
|
304
|
+
const { homeDirectory, ownerHome, linkedHome } = homes;
|
|
305
|
+
assert.equal(installInScratchHome(homeDirectory, [], { CODEX_HOME: ownerHome }).status, 0);
|
|
306
|
+
assert.equal(installInScratchHome(homeDirectory, [], { CODEX_HOME: linkedHome }).status, 0);
|
|
307
|
+
|
|
308
|
+
const uninstallation = installInScratchHome(homeDirectory, ['--uninstall'], { CODEX_HOME: linkedHome });
|
|
309
|
+
|
|
310
|
+
assert.equal(uninstallation.status, 0, uninstallation.stderr);
|
|
311
|
+
const configText = readFileSync(join(ownerHome, CODEX_CONFIG_FILE_NAME), 'utf8');
|
|
312
|
+
assert.ok(configText.includes(`model_instructions_file = ${JSON.stringify(join(ownerHome, 'codex-sol.md'))}`), configText);
|
|
313
|
+
assert.equal(existsSync(join(ownerHome, 'codex-sol.md')), true);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test('a home whose config.toml is a hard link to the main home\'s file names the prompt in the main home', (context) => {
|
|
317
|
+
const homeDirectory = makeScratchHome(context);
|
|
318
|
+
const mainHome = join(homeDirectory, '.codex');
|
|
319
|
+
const linkedHome = join(homeDirectory, '.codex-editor');
|
|
320
|
+
mkdirSync(mainHome, { recursive: true });
|
|
321
|
+
mkdirSync(linkedHome, { recursive: true });
|
|
322
|
+
writeFileSync(join(mainHome, CODEX_CONFIG_FILE_NAME), 'approval_policy = "never"\n');
|
|
323
|
+
linkSync(join(mainHome, CODEX_CONFIG_FILE_NAME), join(linkedHome, CODEX_CONFIG_FILE_NAME));
|
|
324
|
+
assert.equal(installInScratchHome(homeDirectory, [], { CODEX_HOME: mainHome }).status, 0);
|
|
325
|
+
|
|
326
|
+
const linkedInstallation = installInScratchHome(homeDirectory, [], { CODEX_HOME: linkedHome });
|
|
327
|
+
assert.equal(linkedInstallation.status, 0, linkedInstallation.stderr);
|
|
328
|
+
const sharedLine = `model_instructions_file = ${JSON.stringify(join(mainHome, 'codex-sol.md'))}`;
|
|
329
|
+
assert.ok(readFileSync(join(mainHome, CODEX_CONFIG_FILE_NAME), 'utf8').includes(sharedLine));
|
|
330
|
+
assert.equal(existsSync(join(linkedHome, 'codex-sol.md')), false);
|
|
331
|
+
|
|
332
|
+
const uninstallation = installInScratchHome(homeDirectory, ['--uninstall'], { CODEX_HOME: linkedHome });
|
|
333
|
+
assert.equal(uninstallation.status, 0, uninstallation.stderr);
|
|
334
|
+
assert.ok(readFileSync(join(mainHome, CODEX_CONFIG_FILE_NAME), 'utf8').includes(sharedLine));
|
|
335
|
+
assert.equal(existsSync(join(mainHome, 'codex-sol.md')), true);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test('a main home whose config.toml links into a dotfiles folder keeps the prompt in CODEX_HOME, and uninstall removes the line', (context) => {
|
|
339
|
+
const homeDirectory = makeScratchHome(context);
|
|
340
|
+
const mainHome = join(homeDirectory, '.codex');
|
|
341
|
+
const dotfilesDirectory = join(homeDirectory, 'dotfiles', 'codex');
|
|
342
|
+
mkdirSync(mainHome, { recursive: true });
|
|
343
|
+
mkdirSync(dotfilesDirectory, { recursive: true });
|
|
344
|
+
writeFileSync(join(dotfilesDirectory, CODEX_CONFIG_FILE_NAME), 'approval_policy = "never"\n');
|
|
345
|
+
try {
|
|
346
|
+
symlinkSync(join(dotfilesDirectory, CODEX_CONFIG_FILE_NAME), join(mainHome, CODEX_CONFIG_FILE_NAME), 'file');
|
|
347
|
+
} catch (error) {
|
|
348
|
+
if (error.code === 'EPERM') return context.skip('this account cannot create symlinks');
|
|
349
|
+
throw error;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
assert.equal(installInScratchHome(homeDirectory, [], { CODEX_HOME: mainHome }).status, 0);
|
|
353
|
+
assert.ok(readFileSync(join(dotfilesDirectory, CODEX_CONFIG_FILE_NAME), 'utf8')
|
|
354
|
+
.includes(`model_instructions_file = ${JSON.stringify(join(mainHome, 'codex-sol.md'))}`));
|
|
355
|
+
assert.equal(existsSync(join(dotfilesDirectory, 'codex-sol.md')), false);
|
|
356
|
+
|
|
357
|
+
assert.equal(installInScratchHome(homeDirectory, ['--uninstall'], { CODEX_HOME: mainHome }).status, 0);
|
|
358
|
+
assert.equal(readFileSync(join(dotfilesDirectory, CODEX_CONFIG_FILE_NAME), 'utf8'), 'approval_policy = "never"\n');
|
|
359
|
+
assert.equal(existsSync(join(mainHome, 'codex-sol.md')), false);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test('CODEX_HOME that reaches the main home through a folder link still uninstalls the line', (context) => {
|
|
363
|
+
const homeDirectory = makeScratchHome(context);
|
|
364
|
+
const mainHome = join(homeDirectory, '.codex');
|
|
365
|
+
const folderLink = join(homeDirectory, 'codex-home-link');
|
|
366
|
+
mkdirSync(mainHome, { recursive: true });
|
|
367
|
+
writeFileSync(join(mainHome, CODEX_CONFIG_FILE_NAME), 'approval_policy = "never"\n');
|
|
368
|
+
try {
|
|
369
|
+
symlinkSync(mainHome, folderLink, 'junction');
|
|
370
|
+
} catch (error) {
|
|
371
|
+
if (error.code === 'EPERM') return context.skip('this account cannot create links');
|
|
372
|
+
throw error;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
assert.equal(installInScratchHome(homeDirectory, [], { CODEX_HOME: folderLink }).status, 0);
|
|
376
|
+
assert.equal(existsSync(join(mainHome, 'codex-sol.md')), true);
|
|
377
|
+
|
|
378
|
+
assert.equal(installInScratchHome(homeDirectory, ['--uninstall'], { CODEX_HOME: folderLink }).status, 0);
|
|
379
|
+
assert.equal(readFileSync(join(mainHome, CODEX_CONFIG_FILE_NAME), 'utf8'), 'approval_policy = "never"\n');
|
|
380
|
+
assert.equal(existsSync(join(mainHome, 'codex-sol.md')), false);
|
|
381
|
+
});
|
package/bin/install.mjs
CHANGED
|
@@ -129,6 +129,13 @@ import {
|
|
|
129
129
|
runWithInstallTransaction,
|
|
130
130
|
} from './install-transaction.mjs';
|
|
131
131
|
import { findShadowingGlobalInstall, shadowingGlobalAdvice } from './global-install-shadow.mjs';
|
|
132
|
+
import {
|
|
133
|
+
addOpenTasksGateToProfiles,
|
|
134
|
+
otherProfileSettingsPaths,
|
|
135
|
+
removeOpenTasksGateFromProfiles,
|
|
136
|
+
splitUnreadableProfileSettings,
|
|
137
|
+
unparsedSettingsMayHoldGate,
|
|
138
|
+
} from './profile-stop-gate.mjs';
|
|
132
139
|
import { pythonExecutableProbeCommand } from './python-probe.mjs';
|
|
133
140
|
|
|
134
141
|
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
@@ -2695,18 +2702,81 @@ function install(selectedGroups, options = {}) {
|
|
|
2695
2702
|
executeInstallPlan(plan);
|
|
2696
2703
|
}
|
|
2697
2704
|
|
|
2705
|
+
/**
|
|
2706
|
+
* List the profile settings paths that get the open-tasks Stop gate.
|
|
2707
|
+
*
|
|
2708
|
+
* Only an install into the main ~/.claude does this, because the gate entry
|
|
2709
|
+
* names the main home's hook script.
|
|
2710
|
+
*
|
|
2711
|
+
* @param {string} mainSettingsPath
|
|
2712
|
+
* @returns {string[]}
|
|
2713
|
+
*/
|
|
2714
|
+
function profileSettingsPathsForStopGate(mainSettingsPath) {
|
|
2715
|
+
if (!pathsAreEquivalent(CLAUDE_HOME, join(homedir(), '.claude'))) return [];
|
|
2716
|
+
return otherProfileSettingsPaths({ homeDirectory: homedir(), environment: process.env, mainSettingsPath });
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2719
|
+
/**
|
|
2720
|
+
* Return the profile settings the gate step can parse, and add an Action needed line for each one it cannot.
|
|
2721
|
+
*
|
|
2722
|
+
* @param {string} mainSettingsPath
|
|
2723
|
+
* @returns {string[]}
|
|
2724
|
+
*/
|
|
2725
|
+
function readableProfileSettingsPathsForStopGate(mainSettingsPath) {
|
|
2726
|
+
const { allReadablePaths, allUnreadable } = splitUnreadableProfileSettings(
|
|
2727
|
+
profileSettingsPathsForStopGate(mainSettingsPath),
|
|
2728
|
+
);
|
|
2729
|
+
for (const { settingsPath, reason } of allUnreadable) {
|
|
2730
|
+
const actionLine = `Profile settings: ${settingsPath} was left unchanged because it is not valid JSON (${reason}).`
|
|
2731
|
+
+ ' Fix the file and run the install again to add the open-tasks Stop gate there.';
|
|
2732
|
+
console.warn(` ${actionLine}`);
|
|
2733
|
+
allActionNeededLines.push(actionLine);
|
|
2734
|
+
}
|
|
2735
|
+
return allReadablePaths;
|
|
2736
|
+
}
|
|
2737
|
+
|
|
2738
|
+
/**
|
|
2739
|
+
* Return the profile settings the uninstall removes the gate from, or the reason it must stop first.
|
|
2740
|
+
*
|
|
2741
|
+
* A profile file that does not parse and may still hold the gate stops the
|
|
2742
|
+
* uninstall before it changes anything. The manifest then stays, so running
|
|
2743
|
+
* --uninstall again after fixing the file removes the gate. A file that does
|
|
2744
|
+
* not parse and does not name the gate has nothing to remove and is left as
|
|
2745
|
+
* it is.
|
|
2746
|
+
*
|
|
2747
|
+
* @param {string} mainSettingsPath
|
|
2748
|
+
* @returns {{ allReadablePaths: string[], blockingReason: string|null }}
|
|
2749
|
+
*/
|
|
2750
|
+
function profileSettingsPathsForGateRemoval(mainSettingsPath) {
|
|
2751
|
+
const { allReadablePaths, allUnreadable } = splitUnreadableProfileSettings(
|
|
2752
|
+
profileSettingsPathsForStopGate(mainSettingsPath),
|
|
2753
|
+
);
|
|
2754
|
+
const allHoldingGate = allUnreadable.filter(
|
|
2755
|
+
({ settingsPath }) => unparsedSettingsMayHoldGate(settingsPath, CLAUDE_HOME),
|
|
2756
|
+
);
|
|
2757
|
+
if (allHoldingGate.length === 0) return { allReadablePaths, blockingReason: null };
|
|
2758
|
+
const fileLines = allHoldingGate.map(({ settingsPath, reason }) => ` ${settingsPath}: not valid JSON (${reason})`);
|
|
2759
|
+
const blockingReason = [
|
|
2760
|
+
'ERROR: uninstall stopped before changing anything. These profile settings files still name the open-tasks Stop gate, and they are not valid JSON, so the gate cannot be removed from them:',
|
|
2761
|
+
...fileLines,
|
|
2762
|
+
'Fix each file and run --uninstall again.',
|
|
2763
|
+
].join('\n');
|
|
2764
|
+
return { allReadablePaths, blockingReason };
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2698
2767
|
function executeInstallPlan(plan) {
|
|
2699
2768
|
const faultPhase = resolveFaultPhaseFromEnvironment(process.env);
|
|
2700
2769
|
const snapshot = capturePriorInstallSnapshot({
|
|
2701
2770
|
managedRoot: CLAUDE_HOME,
|
|
2702
2771
|
manifestFilePath: MANIFEST_FILE,
|
|
2703
2772
|
settingsPath: plan.settingsPath,
|
|
2704
|
-
additionalSettingsPaths: [CODEX_HOOKS_CONFIGURATION_PATH],
|
|
2773
|
+
additionalSettingsPaths: [CODEX_HOOKS_CONFIGURATION_PATH, ...profileSettingsPathsForStopGate(plan.settingsPath)],
|
|
2705
2774
|
priorManifestFiles: [
|
|
2706
2775
|
...(plan.priorManifest.files || []),
|
|
2707
2776
|
join(INSTALL_ROOT_RESOLUTION.codexHomeDirectory, 'AGENTS.md'),
|
|
2708
2777
|
...codexSolSettingSnapshotPaths(INSTALL_ROOT_RESOLUTION.codexHomeDirectory),
|
|
2709
2778
|
codexSolPromptPath(INSTALL_ROOT_RESOLUTION.codexHomeDirectory),
|
|
2779
|
+
join(INSTALL_ROOT_RESOLUTION.codexHomeDirectory, SOL_PROMPT_FILE_NAME),
|
|
2710
2780
|
],
|
|
2711
2781
|
journalParentDirectory: join(CLAUDE_HOME, TRANSACTION_JOURNAL_DIRECTORY_NAME),
|
|
2712
2782
|
});
|
|
@@ -3185,6 +3255,16 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
|
|
|
3185
3255
|
for (const eachLine of describeProfileSettingsOutcome(mergeOutcome, false)) {
|
|
3186
3256
|
console.log(` Profile settings: ${eachLine}`);
|
|
3187
3257
|
}
|
|
3258
|
+
for (const eachOutcome of addOpenTasksGateToProfiles({
|
|
3259
|
+
mainSettingsPath: join(CLAUDE_HOME, SETTINGS_FILE_NAME),
|
|
3260
|
+
claudeHome: CLAUDE_HOME,
|
|
3261
|
+
allProfileSettingsPaths: readableProfileSettingsPathsForStopGate(join(CLAUDE_HOME, SETTINGS_FILE_NAME)),
|
|
3262
|
+
homeDirectory: homedir(),
|
|
3263
|
+
})) {
|
|
3264
|
+
if (eachOutcome.additions.length > 0) {
|
|
3265
|
+
console.log(` Profile settings: open-tasks Stop gate added to ${eachOutcome.settingsPath}`);
|
|
3266
|
+
}
|
|
3267
|
+
}
|
|
3188
3268
|
|
|
3189
3269
|
const agentsHubSource = join(PACKAGE_ROOT, 'AGENTS.md');
|
|
3190
3270
|
if (existsSync(agentsHubSource)) {
|
|
@@ -3466,6 +3546,12 @@ function executeUninstallPlan(plan, helpers = {}) {
|
|
|
3466
3546
|
writeFileSync(plan.settingsPath, JSON.stringify(settings, null, 4) + '\n');
|
|
3467
3547
|
}
|
|
3468
3548
|
}
|
|
3549
|
+
for (const eachChangedPath of removeOpenTasksGateFromProfiles({
|
|
3550
|
+
claudeHome: CLAUDE_HOME,
|
|
3551
|
+
allProfileSettingsPaths: helpers.allProfileSettingsPaths,
|
|
3552
|
+
})) {
|
|
3553
|
+
console.log(` Profile settings: open-tasks Stop gate removed from ${eachChangedPath}`);
|
|
3554
|
+
}
|
|
3469
3555
|
if (!codexRoutingHookExists()) {
|
|
3470
3556
|
pruneManagedHooksFromHostConfiguration(CODEX_HOOKS_CONFIGURATION_PATH, 2);
|
|
3471
3557
|
}
|
|
@@ -3518,18 +3604,24 @@ function uninstall() {
|
|
|
3518
3604
|
if (plan.isNoOp) {
|
|
3519
3605
|
return;
|
|
3520
3606
|
}
|
|
3607
|
+
const gateRemoval = profileSettingsPathsForGateRemoval(plan.settingsPath);
|
|
3608
|
+
if (gateRemoval.blockingReason) {
|
|
3609
|
+
console.error(gateRemoval.blockingReason);
|
|
3610
|
+
process.exit(1);
|
|
3611
|
+
}
|
|
3521
3612
|
|
|
3522
3613
|
const faultPhase = resolveFaultPhaseFromEnvironment(process.env);
|
|
3523
3614
|
const snapshot = capturePriorInstallSnapshot({
|
|
3524
3615
|
managedRoot: CLAUDE_HOME,
|
|
3525
3616
|
manifestFilePath: MANIFEST_FILE,
|
|
3526
3617
|
settingsPath: plan.settingsPath,
|
|
3527
|
-
additionalSettingsPaths: [CODEX_HOOKS_CONFIGURATION_PATH],
|
|
3618
|
+
additionalSettingsPaths: [CODEX_HOOKS_CONFIGURATION_PATH, ...profileSettingsPathsForStopGate(plan.settingsPath)],
|
|
3528
3619
|
priorManifestFiles: [
|
|
3529
3620
|
...plan.removableFiles,
|
|
3530
3621
|
join(INSTALL_ROOT_RESOLUTION.codexHomeDirectory, 'AGENTS.md'),
|
|
3531
3622
|
...codexSolSettingSnapshotPaths(INSTALL_ROOT_RESOLUTION.codexHomeDirectory),
|
|
3532
3623
|
codexSolPromptPath(INSTALL_ROOT_RESOLUTION.codexHomeDirectory),
|
|
3624
|
+
join(INSTALL_ROOT_RESOLUTION.codexHomeDirectory, SOL_PROMPT_FILE_NAME),
|
|
3533
3625
|
],
|
|
3534
3626
|
journalParentDirectory: join(CLAUDE_HOME, TRANSACTION_JOURNAL_DIRECTORY_NAME),
|
|
3535
3627
|
});
|
|
@@ -3539,7 +3631,7 @@ function uninstall() {
|
|
|
3539
3631
|
snapshot,
|
|
3540
3632
|
faultPhase,
|
|
3541
3633
|
runMutations: ({ throwIfFault }) => {
|
|
3542
|
-
executeUninstallPlan(plan, { throwIfFault });
|
|
3634
|
+
executeUninstallPlan(plan, { throwIfFault, allProfileSettingsPaths: gateRemoval.allReadablePaths });
|
|
3543
3635
|
},
|
|
3544
3636
|
});
|
|
3545
3637
|
} catch (mutationError) {
|
package/bin/install.test.mjs
CHANGED
|
@@ -3687,3 +3687,143 @@ test('install prints no Action needed block when every side step finished', (con
|
|
|
3687
3687
|
assert.equal(result.status, 0, result.stderr);
|
|
3688
3688
|
assert.equal(result.stderr.includes('Action needed'), false, result.stderr);
|
|
3689
3689
|
});
|
|
3690
|
+
|
|
3691
|
+
test('install adds the open-tasks Stop gate to every profile with its own settings.json, and uninstall removes it', (context) => {
|
|
3692
|
+
const homeDirectory = mkdtempSync(join(tmpdir(), 'cde-profile-gate-install-'));
|
|
3693
|
+
context.after(() => rmSync(homeDirectory, { recursive: true, force: true }));
|
|
3694
|
+
const profileSettingsPath = join(homeDirectory, '.claude-profiles', 'prefs-only', 'settings.json');
|
|
3695
|
+
mkdirSync(dirname(profileSettingsPath), { recursive: true });
|
|
3696
|
+
writeFileSync(profileSettingsPath, JSON.stringify({ outputStyle: 'plain' }, null, 4));
|
|
3697
|
+
const runInstaller = (argumentsList) => spawnSync(process.execPath, [join(dirname(fileURLToPath(import.meta.url)), 'install.mjs'), ...argumentsList], {
|
|
3698
|
+
cwd: dirname(dirname(fileURLToPath(import.meta.url))),
|
|
3699
|
+
encoding: 'utf8',
|
|
3700
|
+
env: {
|
|
3701
|
+
...process.env,
|
|
3702
|
+
CLAUDE_CONFIG_DIR: undefined,
|
|
3703
|
+
LLM_SETTINGS_PROFILES_ROOT: undefined,
|
|
3704
|
+
CLAUDE_DEV_ENV_INSTALL_FAULT: undefined,
|
|
3705
|
+
CDE_INSTALL_PSTACK: '0',
|
|
3706
|
+
CDE_INSTALL_DEEPSTACK: '0',
|
|
3707
|
+
CDE_INSTALL_USAGE_WRAPUP: '0',
|
|
3708
|
+
CDE_INSTALL_SUBAGENT_MODELS: '0',
|
|
3709
|
+
CDE_INSTALL_DEEPEN_FIRST_PROMPT: '0',
|
|
3710
|
+
HOME: homeDirectory,
|
|
3711
|
+
USERPROFILE: homeDirectory,
|
|
3712
|
+
CODEX_HOME: join(homeDirectory, '.codex'),
|
|
3713
|
+
GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
|
|
3714
|
+
TEMP: homeDirectory,
|
|
3715
|
+
TMP: homeDirectory,
|
|
3716
|
+
},
|
|
3717
|
+
});
|
|
3718
|
+
const profileStopCommands = () => (JSON.parse(readFileSync(profileSettingsPath, 'utf8')).hooks?.Stop ?? [])
|
|
3719
|
+
.flatMap((eachGroup) => eachGroup.hooks.map((eachHook) => eachHook.command));
|
|
3720
|
+
|
|
3721
|
+
const installation = runInstaller([]);
|
|
3722
|
+
assert.equal(installation.status, 0, installation.stderr);
|
|
3723
|
+
assert.equal(profileStopCommands().filter((eachCommand) => eachCommand.includes('open_tasks_stop_gate.py')).length, 1);
|
|
3724
|
+
|
|
3725
|
+
const uninstallation = runInstaller(['--uninstall']);
|
|
3726
|
+
assert.equal(uninstallation.status, 0, uninstallation.stderr);
|
|
3727
|
+
assert.deepEqual(JSON.parse(readFileSync(profileSettingsPath, 'utf8')), { outputStyle: 'plain' });
|
|
3728
|
+
});
|
|
3729
|
+
|
|
3730
|
+
test('a profile settings.json that is not valid JSON is left alone and named under Action needed, and the rest of the install and uninstall go through', (context) => {
|
|
3731
|
+
const homeDirectory = mkdtempSync(join(tmpdir(), 'cde-profile-gate-malformed-'));
|
|
3732
|
+
context.after(() => rmSync(homeDirectory, { recursive: true, force: true }));
|
|
3733
|
+
const brokenSettingsPath = join(homeDirectory, '.claude-profiles', 'broken', 'settings.json');
|
|
3734
|
+
const goodSettingsPath = join(homeDirectory, '.claude-profiles', 'good', 'settings.json');
|
|
3735
|
+
const brokenSettingsText = '{"hooks":{},}\n';
|
|
3736
|
+
mkdirSync(dirname(brokenSettingsPath), { recursive: true });
|
|
3737
|
+
mkdirSync(dirname(goodSettingsPath), { recursive: true });
|
|
3738
|
+
writeFileSync(brokenSettingsPath, brokenSettingsText);
|
|
3739
|
+
writeFileSync(goodSettingsPath, JSON.stringify({ outputStyle: 'plain' }, null, 4));
|
|
3740
|
+
const runInstaller = (argumentsList) => spawnSync(process.execPath, [join(dirname(fileURLToPath(import.meta.url)), 'install.mjs'), ...argumentsList], {
|
|
3741
|
+
cwd: dirname(dirname(fileURLToPath(import.meta.url))),
|
|
3742
|
+
encoding: 'utf8',
|
|
3743
|
+
env: {
|
|
3744
|
+
...process.env,
|
|
3745
|
+
CLAUDE_CONFIG_DIR: undefined,
|
|
3746
|
+
LLM_SETTINGS_PROFILES_ROOT: undefined,
|
|
3747
|
+
CLAUDE_DEV_ENV_INSTALL_FAULT: undefined,
|
|
3748
|
+
CDE_INSTALL_PSTACK: '0',
|
|
3749
|
+
CDE_INSTALL_DEEPSTACK: '0',
|
|
3750
|
+
CDE_INSTALL_USAGE_WRAPUP: '0',
|
|
3751
|
+
CDE_INSTALL_SUBAGENT_MODELS: '0',
|
|
3752
|
+
CDE_INSTALL_DEEPEN_FIRST_PROMPT: '0',
|
|
3753
|
+
HOME: homeDirectory,
|
|
3754
|
+
USERPROFILE: homeDirectory,
|
|
3755
|
+
CODEX_HOME: join(homeDirectory, '.codex'),
|
|
3756
|
+
GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
|
|
3757
|
+
TEMP: homeDirectory,
|
|
3758
|
+
TMP: homeDirectory,
|
|
3759
|
+
},
|
|
3760
|
+
});
|
|
3761
|
+
const goodGateCount = () => (JSON.parse(readFileSync(goodSettingsPath, 'utf8')).hooks?.Stop ?? [])
|
|
3762
|
+
.flatMap((eachGroup) => eachGroup.hooks.map((eachHook) => eachHook.command))
|
|
3763
|
+
.filter((eachCommand) => eachCommand.includes('open_tasks_stop_gate.py')).length;
|
|
3764
|
+
const actionNeededSection = (stderrText) => stderrText.slice(stderrText.indexOf('Action needed'));
|
|
3765
|
+
|
|
3766
|
+
const installation = runInstaller([]);
|
|
3767
|
+
assert.equal(installation.status, 0, installation.stderr);
|
|
3768
|
+
assert.equal(readFileSync(brokenSettingsPath, 'utf8'), brokenSettingsText);
|
|
3769
|
+
assert.equal(goodGateCount(), 1);
|
|
3770
|
+
assert.match(actionNeededSection(installation.stderr), /Action needed \(1\):/);
|
|
3771
|
+
assert.ok(actionNeededSection(installation.stderr).includes(brokenSettingsPath));
|
|
3772
|
+
assert.match(actionNeededSection(installation.stderr), /not valid JSON \(.+\)/);
|
|
3773
|
+
|
|
3774
|
+
const uninstallation = runInstaller(['--uninstall']);
|
|
3775
|
+
assert.equal(uninstallation.status, 0, uninstallation.stderr);
|
|
3776
|
+
assert.equal(readFileSync(brokenSettingsPath, 'utf8'), brokenSettingsText);
|
|
3777
|
+
assert.equal(goodGateCount(), 0);
|
|
3778
|
+
});
|
|
3779
|
+
|
|
3780
|
+
test('uninstall stops before changing anything while a profile settings.json that is not valid JSON still names the gate, and a rerun after the fix removes it', (context) => {
|
|
3781
|
+
const homeDirectory = mkdtempSync(join(tmpdir(), 'cde-profile-gate-uninstall-malformed-'));
|
|
3782
|
+
context.after(() => rmSync(homeDirectory, { recursive: true, force: true }));
|
|
3783
|
+
const profileSettingsPath = join(homeDirectory, '.claude-profiles', 'prefs-only', 'settings.json');
|
|
3784
|
+
mkdirSync(dirname(profileSettingsPath), { recursive: true });
|
|
3785
|
+
writeFileSync(profileSettingsPath, JSON.stringify({ outputStyle: 'plain' }, null, 4));
|
|
3786
|
+
const runInstaller = (argumentsList) => spawnSync(process.execPath, [join(dirname(fileURLToPath(import.meta.url)), 'install.mjs'), ...argumentsList], {
|
|
3787
|
+
cwd: dirname(dirname(fileURLToPath(import.meta.url))),
|
|
3788
|
+
encoding: 'utf8',
|
|
3789
|
+
env: {
|
|
3790
|
+
...process.env,
|
|
3791
|
+
CLAUDE_CONFIG_DIR: undefined,
|
|
3792
|
+
LLM_SETTINGS_PROFILES_ROOT: undefined,
|
|
3793
|
+
CLAUDE_DEV_ENV_INSTALL_FAULT: undefined,
|
|
3794
|
+
CDE_INSTALL_PSTACK: '0',
|
|
3795
|
+
CDE_INSTALL_DEEPSTACK: '0',
|
|
3796
|
+
CDE_INSTALL_USAGE_WRAPUP: '0',
|
|
3797
|
+
CDE_INSTALL_SUBAGENT_MODELS: '0',
|
|
3798
|
+
CDE_INSTALL_DEEPEN_FIRST_PROMPT: '0',
|
|
3799
|
+
HOME: homeDirectory,
|
|
3800
|
+
USERPROFILE: homeDirectory,
|
|
3801
|
+
CODEX_HOME: join(homeDirectory, '.codex'),
|
|
3802
|
+
GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
|
|
3803
|
+
TEMP: homeDirectory,
|
|
3804
|
+
TMP: homeDirectory,
|
|
3805
|
+
},
|
|
3806
|
+
});
|
|
3807
|
+
const manifestPath = join(homeDirectory, '.claude', '.claude-dev-env-manifest.json');
|
|
3808
|
+
|
|
3809
|
+
const installation = runInstaller([]);
|
|
3810
|
+
assert.equal(installation.status, 0, installation.stderr);
|
|
3811
|
+
const validText = readFileSync(profileSettingsPath, 'utf8');
|
|
3812
|
+
assert.ok(validText.includes('open_tasks_stop_gate.py'));
|
|
3813
|
+
const brokenText = validText.trimEnd().replace(/\}$/, ',}') + '\n';
|
|
3814
|
+
writeFileSync(profileSettingsPath, brokenText);
|
|
3815
|
+
|
|
3816
|
+
const stoppedUninstall = runInstaller(['--uninstall']);
|
|
3817
|
+
assert.equal(stoppedUninstall.status, 1, stoppedUninstall.stdout);
|
|
3818
|
+
assert.ok(stoppedUninstall.stderr.includes(profileSettingsPath), stoppedUninstall.stderr);
|
|
3819
|
+
assert.match(stoppedUninstall.stderr, /not valid JSON \(.+\)/);
|
|
3820
|
+
assert.match(stoppedUninstall.stderr, /run --uninstall again/);
|
|
3821
|
+
assert.equal(readFileSync(profileSettingsPath, 'utf8'), brokenText);
|
|
3822
|
+
assert.ok(existsSync(manifestPath));
|
|
3823
|
+
|
|
3824
|
+
writeFileSync(profileSettingsPath, validText);
|
|
3825
|
+
const rerunUninstall = runInstaller(['--uninstall']);
|
|
3826
|
+
assert.equal(rerunUninstall.status, 0, rerunUninstall.stderr);
|
|
3827
|
+
assert.deepEqual(JSON.parse(readFileSync(profileSettingsPath, 'utf8')), { outputStyle: 'plain' });
|
|
3828
|
+
assert.equal(existsSync(manifestPath), false);
|
|
3829
|
+
});
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { CURSOR_OPEN_TASKS_HOOK_RELATIVE_PATH } from './install-constants.mjs';
|
|
5
|
+
import {
|
|
6
|
+
discoverProfileSettingsPaths,
|
|
7
|
+
hostConfigurationIndent,
|
|
8
|
+
mergeProfileSettingsFile,
|
|
9
|
+
} from './merge_profile_settings.mjs';
|
|
10
|
+
import { normalizePathForComparison } from './resolve-install-root.mjs';
|
|
11
|
+
|
|
12
|
+
const STOP_EVENT_NAME = 'Stop';
|
|
13
|
+
const MANAGED_HOOKS_DIRECTORY_NAME = 'hooks';
|
|
14
|
+
const SETTINGS_INDENT = ' ';
|
|
15
|
+
|
|
16
|
+
function comparablePath(filesystemPath) {
|
|
17
|
+
try {
|
|
18
|
+
return normalizePathForComparison(realpathSync(filesystemPath));
|
|
19
|
+
} catch {
|
|
20
|
+
return normalizePathForComparison(filesystemPath);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function comparableCommand(command) {
|
|
25
|
+
const forwardSlashed = command.replace(/\\/g, '/');
|
|
26
|
+
return process.platform === 'win32' ? forwardSlashed.toLowerCase() : forwardSlashed;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Return the gate script's path under a Claude home, in the spelling hook commands compare by.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} claudeHome
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
export function gateScriptCommandFragment(claudeHome) {
|
|
36
|
+
return comparableCommand(join(claudeHome, MANAGED_HOOKS_DIRECTORY_NAME, ...CURSOR_OPEN_TASKS_HOOK_RELATIVE_PATH));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isGateHook(eachHook, claudeHome) {
|
|
40
|
+
return typeof eachHook?.command === 'string'
|
|
41
|
+
&& comparableCommand(eachHook.command).includes(gateScriptCommandFragment(claudeHome));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Find the open-tasks Stop gate hook the install wrote into the main settings.
|
|
46
|
+
*
|
|
47
|
+
* @param {string} mainSettingsPath
|
|
48
|
+
* @param {string} claudeHome
|
|
49
|
+
* @returns {Record<string, unknown>|null} A copy of the hook entry, or null when the main settings hold none.
|
|
50
|
+
*/
|
|
51
|
+
export function mainOpenTasksGateHook(mainSettingsPath, claudeHome) {
|
|
52
|
+
if (!existsSync(mainSettingsPath)) return null;
|
|
53
|
+
const settings = JSON.parse(readFileSync(mainSettingsPath, 'utf8'));
|
|
54
|
+
for (const eachGroup of settings?.hooks?.[STOP_EVENT_NAME] ?? []) {
|
|
55
|
+
for (const eachHook of eachGroup?.hooks ?? []) {
|
|
56
|
+
if (isGateHook(eachHook, claudeHome)) return structuredClone(eachHook);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* List the settings.json of every other Claude profile on this machine.
|
|
64
|
+
*
|
|
65
|
+
* A profile whose settings.json links to the main settings already reads the
|
|
66
|
+
* main hooks, so it is left out, and so is the main file itself.
|
|
67
|
+
*
|
|
68
|
+
* @param {{ homeDirectory: string, environment?: Record<string, string|undefined>, mainSettingsPath: string }} options
|
|
69
|
+
* @returns {string[]}
|
|
70
|
+
*/
|
|
71
|
+
export function otherProfileSettingsPaths(options) {
|
|
72
|
+
const mainKey = comparablePath(options.mainSettingsPath);
|
|
73
|
+
const seenKeys = new Set([mainKey]);
|
|
74
|
+
const allOtherPaths = [];
|
|
75
|
+
for (const eachPath of discoverProfileSettingsPaths(options)) {
|
|
76
|
+
if (!existsSync(eachPath)) continue;
|
|
77
|
+
const pathKey = comparablePath(eachPath);
|
|
78
|
+
if (seenKeys.has(pathKey)) continue;
|
|
79
|
+
seenKeys.add(pathKey);
|
|
80
|
+
allOtherPaths.push(eachPath);
|
|
81
|
+
}
|
|
82
|
+
return allOtherPaths;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Split profile settings paths into the ones whose settings.json parses and the ones that do not.
|
|
87
|
+
*
|
|
88
|
+
* A profile file that is not valid JSON is left as it is. The caller names
|
|
89
|
+
* each one, with its parse error, so the user can fix it and run the install
|
|
90
|
+
* again. An empty file counts as readable.
|
|
91
|
+
*
|
|
92
|
+
* @param {string[]} allProfileSettingsPaths
|
|
93
|
+
* @returns {{ allReadablePaths: string[], allUnreadable: { settingsPath: string, reason: string }[] }}
|
|
94
|
+
*/
|
|
95
|
+
export function splitUnreadableProfileSettings(allProfileSettingsPaths) {
|
|
96
|
+
const allReadablePaths = [];
|
|
97
|
+
const allUnreadable = [];
|
|
98
|
+
for (const eachPath of allProfileSettingsPaths) {
|
|
99
|
+
try {
|
|
100
|
+
const settingsText = readFileSync(eachPath, 'utf8');
|
|
101
|
+
if (settingsText.trim()) JSON.parse(settingsText);
|
|
102
|
+
allReadablePaths.push(eachPath);
|
|
103
|
+
} catch (readError) {
|
|
104
|
+
allUnreadable.push({
|
|
105
|
+
settingsPath: eachPath,
|
|
106
|
+
reason: readError instanceof Error ? readError.message : String(readError),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { allReadablePaths, allUnreadable };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* True when a settings.json that does not parse may still hold this Claude home's open-tasks gate.
|
|
115
|
+
*
|
|
116
|
+
* The raw text is searched for the gate script path, with JSON-escaped
|
|
117
|
+
* backslashes read as path separators. A file that cannot be read counts as
|
|
118
|
+
* one that may hold the gate.
|
|
119
|
+
*
|
|
120
|
+
* @param {string} settingsPath
|
|
121
|
+
* @param {string} claudeHome
|
|
122
|
+
* @returns {boolean}
|
|
123
|
+
*/
|
|
124
|
+
export function unparsedSettingsMayHoldGate(settingsPath, claudeHome) {
|
|
125
|
+
let settingsText;
|
|
126
|
+
try {
|
|
127
|
+
settingsText = readFileSync(settingsPath, 'utf8');
|
|
128
|
+
} catch {
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
return comparableCommand(settingsText.replace(/\\\\/g, '/')).includes(gateScriptCommandFragment(claudeHome));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function stopHooks(settingsPath) {
|
|
135
|
+
const settingsText = readFileSync(settingsPath, 'utf8');
|
|
136
|
+
if (!settingsText.trim()) return [];
|
|
137
|
+
const settings = JSON.parse(settingsText);
|
|
138
|
+
const stopGroups = settings?.hooks?.[STOP_EVENT_NAME];
|
|
139
|
+
if (!Array.isArray(stopGroups)) return [];
|
|
140
|
+
return stopGroups.flatMap((eachGroup) => (Array.isArray(eachGroup?.hooks) ? eachGroup.hooks : []));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function profileGateCommands(settingsPath, claudeHome) {
|
|
144
|
+
return stopHooks(settingsPath)
|
|
145
|
+
.filter((eachHook) => isGateHook(eachHook, claudeHome))
|
|
146
|
+
.map((eachHook) => eachHook.command);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* True when a profile's settings.json already runs a gate script from another Claude home.
|
|
151
|
+
*
|
|
152
|
+
* An install with CLAUDE_CONFIG_DIR set to the profile writes a gate entry
|
|
153
|
+
* for the profile's own copy of the script, and a second entry for the main
|
|
154
|
+
* home's copy would run the gate twice on every stop.
|
|
155
|
+
*
|
|
156
|
+
* @param {string} settingsPath
|
|
157
|
+
* @param {string} claudeHome
|
|
158
|
+
* @returns {boolean}
|
|
159
|
+
*/
|
|
160
|
+
export function profileRunsItsOwnGate(settingsPath, claudeHome) {
|
|
161
|
+
const scriptPathSuffix = `/${comparableCommand(join(MANAGED_HOOKS_DIRECTORY_NAME, ...CURSOR_OPEN_TASKS_HOOK_RELATIVE_PATH))}`;
|
|
162
|
+
return stopHooks(settingsPath).some((eachHook) => typeof eachHook?.command === 'string'
|
|
163
|
+
&& comparableCommand(eachHook.command).includes(scriptPathSuffix)
|
|
164
|
+
&& !isGateHook(eachHook, claudeHome));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Add the main settings' open-tasks Stop gate to every other profile's settings.json.
|
|
169
|
+
*
|
|
170
|
+
* A profile keeps its own settings and hooks. The merge adds the one gate
|
|
171
|
+
* entry, in the spelling the main settings use, only where it is missing;
|
|
172
|
+
* an entry left with an older interpreter spelling is replaced. The file as
|
|
173
|
+
* it was before either change is copied to a timestamped `.bak`. A profile
|
|
174
|
+
* that already runs its own copy of the gate script is left as it is.
|
|
175
|
+
*
|
|
176
|
+
* @param {{ mainSettingsPath: string, claudeHome: string, allProfileSettingsPaths: string[],
|
|
177
|
+
* homeDirectory: string, now?: Date }} options
|
|
178
|
+
* @returns {{ settingsPath: string, additions: string[], backupPath: string|null }[]}
|
|
179
|
+
*/
|
|
180
|
+
export function addOpenTasksGateToProfiles(options) {
|
|
181
|
+
const gateHook = mainOpenTasksGateHook(options.mainSettingsPath, options.claudeHome);
|
|
182
|
+
if (!gateHook) return [];
|
|
183
|
+
const timestamp = (options.now ?? new Date()).toISOString().replace(/[:.]/g, '-');
|
|
184
|
+
const declaredSettings = [{ keyPath: ['hooks', STOP_EVENT_NAME], items: [{ hooks: [gateHook] }] }];
|
|
185
|
+
const allTargetPaths = options.allProfileSettingsPaths.filter(
|
|
186
|
+
(eachPath) => !profileRunsItsOwnGate(eachPath, options.claudeHome),
|
|
187
|
+
);
|
|
188
|
+
const allStalePaths = allTargetPaths.filter(
|
|
189
|
+
(eachPath) => profileGateCommands(eachPath, options.claudeHome)
|
|
190
|
+
.some((eachCommand) => eachCommand !== gateHook.command),
|
|
191
|
+
);
|
|
192
|
+
const originalTextByStalePath = new Map(allStalePaths.map((eachPath) => [eachPath, readFileSync(eachPath, 'utf8')]));
|
|
193
|
+
removeOpenTasksGateFromProfiles({ claudeHome: options.claudeHome, allProfileSettingsPaths: allStalePaths });
|
|
194
|
+
return allTargetPaths.map((eachPath) => {
|
|
195
|
+
const outcome = mergeProfileSettingsFile(eachPath, {
|
|
196
|
+
dryRun: false,
|
|
197
|
+
homeDirectory: options.homeDirectory,
|
|
198
|
+
timestamp,
|
|
199
|
+
declaredSettings,
|
|
200
|
+
});
|
|
201
|
+
if (outcome.backupPath && originalTextByStalePath.has(eachPath)) {
|
|
202
|
+
writeFileSync(outcome.backupPath, originalTextByStalePath.get(eachPath));
|
|
203
|
+
}
|
|
204
|
+
return outcome;
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Remove the gate entry that points at the main Claude home from each listed profile settings path.
|
|
210
|
+
*
|
|
211
|
+
* Only a hook whose command runs the main home's gate script goes. A Stop
|
|
212
|
+
* group left empty goes too, and so does an emptied Stop list or hooks object.
|
|
213
|
+
*
|
|
214
|
+
* @param {{ claudeHome: string, allProfileSettingsPaths: string[] }} options
|
|
215
|
+
* @returns {string[]} The settings paths that changed.
|
|
216
|
+
*/
|
|
217
|
+
export function removeOpenTasksGateFromProfiles(options) {
|
|
218
|
+
const allChangedPaths = [];
|
|
219
|
+
for (const eachPath of options.allProfileSettingsPaths) {
|
|
220
|
+
const settingsText = readFileSync(eachPath, 'utf8');
|
|
221
|
+
if (!settingsText.trim()) continue;
|
|
222
|
+
const settings = JSON.parse(settingsText);
|
|
223
|
+
const stopGroups = settings?.hooks?.[STOP_EVENT_NAME];
|
|
224
|
+
if (!Array.isArray(stopGroups)) continue;
|
|
225
|
+
let removedCount = 0;
|
|
226
|
+
for (const eachGroup of stopGroups) {
|
|
227
|
+
if (!Array.isArray(eachGroup?.hooks)) continue;
|
|
228
|
+
const keptHooks = eachGroup.hooks.filter((eachHook) => !isGateHook(eachHook, options.claudeHome));
|
|
229
|
+
removedCount += eachGroup.hooks.length - keptHooks.length;
|
|
230
|
+
eachGroup.hooks = keptHooks;
|
|
231
|
+
}
|
|
232
|
+
if (removedCount === 0) continue;
|
|
233
|
+
settings.hooks[STOP_EVENT_NAME] = stopGroups.filter(
|
|
234
|
+
(eachGroup) => !Array.isArray(eachGroup?.hooks) || eachGroup.hooks.length > 0,
|
|
235
|
+
);
|
|
236
|
+
if (settings.hooks[STOP_EVENT_NAME].length === 0) delete settings.hooks[STOP_EVENT_NAME];
|
|
237
|
+
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
|
238
|
+
writeFileSync(eachPath, JSON.stringify(settings, null, hostConfigurationIndent(settingsText, SETTINGS_INDENT)) + '\n');
|
|
239
|
+
allChangedPaths.push(eachPath);
|
|
240
|
+
}
|
|
241
|
+
return allChangedPaths;
|
|
242
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import { strict as assert } from 'node:assert';
|
|
3
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
addOpenTasksGateToProfiles,
|
|
9
|
+
otherProfileSettingsPaths,
|
|
10
|
+
removeOpenTasksGateFromProfiles,
|
|
11
|
+
splitUnreadableProfileSettings,
|
|
12
|
+
unparsedSettingsMayHoldGate,
|
|
13
|
+
} from './profile-stop-gate.mjs';
|
|
14
|
+
|
|
15
|
+
function makeHome(context) {
|
|
16
|
+
const homeDirectory = mkdtempSync(join(tmpdir(), 'cde-profile-gate-'));
|
|
17
|
+
context.after(() => rmSync(homeDirectory, { recursive: true, force: true }));
|
|
18
|
+
const claudeHome = join(homeDirectory, '.claude');
|
|
19
|
+
mkdirSync(claudeHome, { recursive: true });
|
|
20
|
+
const gateCommand = `python3 ${join(claudeHome, 'hooks', 'blocking', 'open_tasks_stop_gate.py').replace(/\\/g, '/')}`;
|
|
21
|
+
const mainSettingsPath = join(claudeHome, 'settings.json');
|
|
22
|
+
writeFileSync(mainSettingsPath, JSON.stringify({
|
|
23
|
+
hooks: { Stop: [{ hooks: [{ type: 'command', command: gateCommand, timeout: 10 }] }] },
|
|
24
|
+
}, null, 4));
|
|
25
|
+
return { homeDirectory, claudeHome, mainSettingsPath, gateCommand };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function writeProfile(homeDirectory, name, settings) {
|
|
29
|
+
const profileDirectory = join(homeDirectory, '.claude-profiles', name);
|
|
30
|
+
mkdirSync(profileDirectory, { recursive: true });
|
|
31
|
+
const settingsPath = join(profileDirectory, 'settings.json');
|
|
32
|
+
if (settings !== undefined) writeFileSync(settingsPath, JSON.stringify(settings, null, 4));
|
|
33
|
+
return settingsPath;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function stopCommands(settingsPath) {
|
|
37
|
+
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
38
|
+
return (settings.hooks?.Stop ?? []).flatMap((eachGroup) => eachGroup.hooks.map((eachHook) => eachHook.command));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
test('every profile with its own settings.json gets the main gate entry', (context) => {
|
|
42
|
+
const { homeDirectory, claudeHome, mainSettingsPath, gateCommand } = makeHome(context);
|
|
43
|
+
const ownHooksPath = writeProfile(homeDirectory, 'own-hooks', {
|
|
44
|
+
hooks: { Stop: [{ hooks: [{ type: 'command', command: 'python3 /opt/own/stop.py' }] }] },
|
|
45
|
+
});
|
|
46
|
+
const prefsOnlyPath = writeProfile(homeDirectory, 'prefs-only', { outputStyle: 'plain' });
|
|
47
|
+
writeProfile(homeDirectory, 'no-settings');
|
|
48
|
+
|
|
49
|
+
const allProfilePaths = otherProfileSettingsPaths({ homeDirectory, environment: {}, mainSettingsPath });
|
|
50
|
+
addOpenTasksGateToProfiles({ mainSettingsPath, claudeHome, allProfileSettingsPaths: allProfilePaths, homeDirectory });
|
|
51
|
+
|
|
52
|
+
assert.deepEqual(allProfilePaths.sort(), [ownHooksPath, prefsOnlyPath].sort());
|
|
53
|
+
assert.deepEqual(stopCommands(ownHooksPath), ['python3 /opt/own/stop.py', gateCommand]);
|
|
54
|
+
assert.deepEqual(stopCommands(prefsOnlyPath), [gateCommand]);
|
|
55
|
+
assert.equal(JSON.parse(readFileSync(prefsOnlyPath, 'utf8')).outputStyle, 'plain');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('a profile whose settings.json links to the main settings is left out', (context) => {
|
|
59
|
+
const { homeDirectory, mainSettingsPath } = makeHome(context);
|
|
60
|
+
const linkedPath = writeProfile(homeDirectory, 'linked');
|
|
61
|
+
try {
|
|
62
|
+
symlinkSync(mainSettingsPath, linkedPath, 'file');
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (error.code === 'EPERM') return context.skip('this account cannot create symlinks');
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
assert.deepEqual(otherProfileSettingsPaths({ homeDirectory, environment: {}, mainSettingsPath }), []);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('a second run adds nothing, and an older interpreter spelling is replaced', (context) => {
|
|
72
|
+
const { homeDirectory, claudeHome, mainSettingsPath, gateCommand } = makeHome(context);
|
|
73
|
+
const staleCommand = gateCommand.replace(/^python3/, '/usr/bin/python3.9');
|
|
74
|
+
const profilePath = writeProfile(homeDirectory, 'stale', {
|
|
75
|
+
hooks: { Stop: [{ hooks: [{ type: 'command', command: staleCommand }] }] },
|
|
76
|
+
});
|
|
77
|
+
const options = { mainSettingsPath, claudeHome, allProfileSettingsPaths: [profilePath], homeDirectory };
|
|
78
|
+
|
|
79
|
+
addOpenTasksGateToProfiles(options);
|
|
80
|
+
const secondOutcome = addOpenTasksGateToProfiles(options);
|
|
81
|
+
|
|
82
|
+
assert.deepEqual(stopCommands(profilePath), [gateCommand]);
|
|
83
|
+
assert.deepEqual(secondOutcome[0].additions, []);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('replacing an older spelling backs up the file as it was before the replacement', (context) => {
|
|
87
|
+
const { homeDirectory, claudeHome, mainSettingsPath, gateCommand } = makeHome(context);
|
|
88
|
+
const staleCommand = gateCommand.replace(/^python3/, '/usr/bin/python3.9');
|
|
89
|
+
const profilePath = writeProfile(homeDirectory, 'stale', {
|
|
90
|
+
hooks: { Stop: [{ hooks: [{ type: 'command', command: staleCommand }] }] },
|
|
91
|
+
});
|
|
92
|
+
const originalText = readFileSync(profilePath, 'utf8');
|
|
93
|
+
|
|
94
|
+
const [outcome] = addOpenTasksGateToProfiles({ mainSettingsPath, claudeHome, allProfileSettingsPaths: [profilePath], homeDirectory });
|
|
95
|
+
|
|
96
|
+
assert.equal(readFileSync(outcome.backupPath, 'utf8'), originalText);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('removal takes out only the main gate entry and leaves the profile as it was', (context) => {
|
|
100
|
+
const { homeDirectory, claudeHome, mainSettingsPath } = makeHome(context);
|
|
101
|
+
const ownHooksPath = writeProfile(homeDirectory, 'own-hooks', {
|
|
102
|
+
hooks: { Stop: [{ hooks: [{ type: 'command', command: 'python3 /opt/own/stop.py' }] }] },
|
|
103
|
+
});
|
|
104
|
+
const prefsOnlyPath = writeProfile(homeDirectory, 'prefs-only', { outputStyle: 'plain' });
|
|
105
|
+
const allProfilePaths = [ownHooksPath, prefsOnlyPath];
|
|
106
|
+
addOpenTasksGateToProfiles({ mainSettingsPath, claudeHome, allProfileSettingsPaths: allProfilePaths, homeDirectory });
|
|
107
|
+
|
|
108
|
+
const allChangedPaths = removeOpenTasksGateFromProfiles({ claudeHome, allProfileSettingsPaths: allProfilePaths });
|
|
109
|
+
|
|
110
|
+
assert.deepEqual(allChangedPaths, allProfilePaths);
|
|
111
|
+
assert.deepEqual(stopCommands(ownHooksPath), ['python3 /opt/own/stop.py']);
|
|
112
|
+
assert.deepEqual(JSON.parse(readFileSync(prefsOnlyPath, 'utf8')), { outputStyle: 'plain' });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('a profile that already runs its own copy of the gate script gets no second entry', (context) => {
|
|
116
|
+
const { homeDirectory, claudeHome, mainSettingsPath } = makeHome(context);
|
|
117
|
+
const ownGateCommand = `python3 ${join(homeDirectory, '.claude-profiles', 'isolated', 'hooks', 'blocking', 'open_tasks_stop_gate.py').replace(/\\/g, '/')}`;
|
|
118
|
+
const profilePath = writeProfile(homeDirectory, 'isolated', {
|
|
119
|
+
hooks: { Stop: [{ hooks: [{ type: 'command', command: ownGateCommand }] }] },
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const allOutcomes = addOpenTasksGateToProfiles({ mainSettingsPath, claudeHome, allProfileSettingsPaths: [profilePath], homeDirectory });
|
|
123
|
+
|
|
124
|
+
assert.deepEqual(allOutcomes, []);
|
|
125
|
+
assert.deepEqual(stopCommands(profilePath), [ownGateCommand]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('a settings.json that is not valid JSON is reported with its parse error, and an empty one counts as readable', (context) => {
|
|
129
|
+
const { homeDirectory } = makeHome(context);
|
|
130
|
+
const goodPath = writeProfile(homeDirectory, 'good', { outputStyle: 'plain' });
|
|
131
|
+
const emptyPath = writeProfile(homeDirectory, 'empty');
|
|
132
|
+
writeFileSync(emptyPath, '');
|
|
133
|
+
const brokenPath = writeProfile(homeDirectory, 'broken');
|
|
134
|
+
writeFileSync(brokenPath, '{"hooks":{},}');
|
|
135
|
+
|
|
136
|
+
const { allReadablePaths, allUnreadable } = splitUnreadableProfileSettings([goodPath, emptyPath, brokenPath]);
|
|
137
|
+
|
|
138
|
+
assert.deepEqual(allReadablePaths, [goodPath, emptyPath]);
|
|
139
|
+
assert.equal(allUnreadable.length, 1);
|
|
140
|
+
assert.equal(allUnreadable[0].settingsPath, brokenPath);
|
|
141
|
+
assert.match(allUnreadable[0].reason, /JSON/);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('a settings.json that does not parse may hold the gate when its text names this home\'s gate script, in either slash spelling', (context) => {
|
|
145
|
+
const { homeDirectory, claudeHome, gateCommand } = makeHome(context);
|
|
146
|
+
const forwardPath = writeProfile(homeDirectory, 'forward');
|
|
147
|
+
writeFileSync(forwardPath, `{"hooks":{"Stop":[{"hooks":[{"command":"${gateCommand}"}]}]},}`);
|
|
148
|
+
const escapedPath = writeProfile(homeDirectory, 'escaped');
|
|
149
|
+
writeFileSync(escapedPath, `{"hooks":{"Stop":[{"hooks":[{"command":"${gateCommand.replace(/\//g, '\\\\')}"}]}]},}`);
|
|
150
|
+
const otherHomePath = writeProfile(homeDirectory, 'other-home');
|
|
151
|
+
writeFileSync(otherHomePath, '{"hooks":{"Stop":[{"hooks":[{"command":"python3 /opt/other/.claude/hooks/blocking/open_tasks_stop_gate.py"}]}]},}');
|
|
152
|
+
const noGatePath = writeProfile(homeDirectory, 'no-gate');
|
|
153
|
+
writeFileSync(noGatePath, '{"hooks":{},}');
|
|
154
|
+
|
|
155
|
+
assert.equal(unparsedSettingsMayHoldGate(forwardPath, claudeHome), true);
|
|
156
|
+
assert.equal(unparsedSettingsMayHoldGate(escapedPath, claudeHome), true);
|
|
157
|
+
assert.equal(unparsedSettingsMayHoldGate(otherHomePath, claudeHome), false);
|
|
158
|
+
assert.equal(unparsedSettingsMayHoldGate(noGatePath, claudeHome), false);
|
|
159
|
+
assert.equal(unparsedSettingsMayHoldGate(join(homeDirectory, 'missing', 'settings.json'), claudeHome), true);
|
|
160
|
+
});
|
|
@@ -25,7 +25,7 @@ When the host keeps an asks ledger with `ledger.py` and `asks_check.py`, record
|
|
|
25
25
|
|
|
26
26
|
The installer registers `open_tasks_stop_gate.py` as a Stop hook for each host:
|
|
27
27
|
|
|
28
|
-
- Claude Code: `hooks.json`. A block returns the open items as the reason, and the session keeps working.
|
|
28
|
+
- Claude Code: `hooks.json`. A block returns the open items as the reason, and the session keeps working. An install into `~/.claude` also adds the same Stop entry to every profile under `~/.claude-profiles` that has its own `settings.json` and does not already run its own copy of the gate. A profile whose `settings.json` links to `~/.claude/settings.json` already has it. Uninstall removes only that entry. On install, a profile `settings.json` that is not valid JSON is left unchanged and listed under Action needed with its path and parse error, and the rest of the install goes on. On uninstall, such a file that still names the gate stops the uninstall before anything changes, so the manifest stays and a rerun after the fix removes the gate; one that does not name the gate is left as it is.
|
|
29
29
|
- Codex: `$CODEX_HOME/hooks.json`. Codex turns the reason into a continuation prompt.
|
|
30
30
|
- Cursor: a native `stop` entry in `~/.cursor/hooks.json`. The reason comes back as `followup_message`, the next user message. A stop with status `aborted` or `error` passes, so a turn the user cancels stays silent.
|
|
31
31
|
|
package/docs/codex-accounts.md
CHANGED
|
@@ -142,8 +142,17 @@ package rules rewritten. The line names no model, so every Codex run uses the
|
|
|
142
142
|
file, whichever model it selects. `sync` links `config.toml` into every account
|
|
143
143
|
home, so every account gets the line.
|
|
144
144
|
|
|
145
|
+
When `CODEX_HOME/config.toml` is the same file as `~/.codex/config.toml`, the
|
|
146
|
+
copy goes in `~/.codex`. `sync` makes that file a symbolic link, or a hard link
|
|
147
|
+
when the account cannot create symbolic links. Installing from an account home
|
|
148
|
+
then names the shared copy in `~/.codex`, and deleting or uninstalling that
|
|
149
|
+
account home leaves the other homes working. A `config.toml` that links
|
|
150
|
+
anywhere else, such as a dotfiles folder, keeps the copy in `CODEX_HOME`.
|
|
151
|
+
|
|
145
152
|
A `model_instructions_file` the user wrote at the top level keeps the config as
|
|
146
|
-
it is. Uninstall removes only the package line.
|
|
153
|
+
it is. Uninstall removes only the package line and the copy. Uninstalling from
|
|
154
|
+
an account home that shares `~/.codex/config.toml` leaves both alone, because
|
|
155
|
+
`~/.codex` still uses them.
|
|
147
156
|
|
|
148
157
|
To see which prompt Codex uses, read the setting:
|
|
149
158
|
|