claude-dev-env 8.88.2 → 8.88.3
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 +39 -2
- package/bin/install.test.mjs +39 -0
- package/bin/profile-stop-gate.mjs +193 -0
- package/bin/profile-stop-gate.test.mjs +124 -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,11 @@ 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
|
+
} from './profile-stop-gate.mjs';
|
|
132
137
|
import { pythonExecutableProbeCommand } from './python-probe.mjs';
|
|
133
138
|
|
|
134
139
|
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
@@ -2695,18 +2700,33 @@ function install(selectedGroups, options = {}) {
|
|
|
2695
2700
|
executeInstallPlan(plan);
|
|
2696
2701
|
}
|
|
2697
2702
|
|
|
2703
|
+
/**
|
|
2704
|
+
* List the profile settings paths that get the open-tasks Stop gate.
|
|
2705
|
+
*
|
|
2706
|
+
* Only an install into the main ~/.claude does this, because the gate entry
|
|
2707
|
+
* names the main home's hook script.
|
|
2708
|
+
*
|
|
2709
|
+
* @param {string} mainSettingsPath
|
|
2710
|
+
* @returns {string[]}
|
|
2711
|
+
*/
|
|
2712
|
+
function profileSettingsPathsForStopGate(mainSettingsPath) {
|
|
2713
|
+
if (!pathsAreEquivalent(CLAUDE_HOME, join(homedir(), '.claude'))) return [];
|
|
2714
|
+
return otherProfileSettingsPaths({ homeDirectory: homedir(), environment: process.env, mainSettingsPath });
|
|
2715
|
+
}
|
|
2716
|
+
|
|
2698
2717
|
function executeInstallPlan(plan) {
|
|
2699
2718
|
const faultPhase = resolveFaultPhaseFromEnvironment(process.env);
|
|
2700
2719
|
const snapshot = capturePriorInstallSnapshot({
|
|
2701
2720
|
managedRoot: CLAUDE_HOME,
|
|
2702
2721
|
manifestFilePath: MANIFEST_FILE,
|
|
2703
2722
|
settingsPath: plan.settingsPath,
|
|
2704
|
-
additionalSettingsPaths: [CODEX_HOOKS_CONFIGURATION_PATH],
|
|
2723
|
+
additionalSettingsPaths: [CODEX_HOOKS_CONFIGURATION_PATH, ...profileSettingsPathsForStopGate(plan.settingsPath)],
|
|
2705
2724
|
priorManifestFiles: [
|
|
2706
2725
|
...(plan.priorManifest.files || []),
|
|
2707
2726
|
join(INSTALL_ROOT_RESOLUTION.codexHomeDirectory, 'AGENTS.md'),
|
|
2708
2727
|
...codexSolSettingSnapshotPaths(INSTALL_ROOT_RESOLUTION.codexHomeDirectory),
|
|
2709
2728
|
codexSolPromptPath(INSTALL_ROOT_RESOLUTION.codexHomeDirectory),
|
|
2729
|
+
join(INSTALL_ROOT_RESOLUTION.codexHomeDirectory, SOL_PROMPT_FILE_NAME),
|
|
2710
2730
|
],
|
|
2711
2731
|
journalParentDirectory: join(CLAUDE_HOME, TRANSACTION_JOURNAL_DIRECTORY_NAME),
|
|
2712
2732
|
});
|
|
@@ -3185,6 +3205,16 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
|
|
|
3185
3205
|
for (const eachLine of describeProfileSettingsOutcome(mergeOutcome, false)) {
|
|
3186
3206
|
console.log(` Profile settings: ${eachLine}`);
|
|
3187
3207
|
}
|
|
3208
|
+
for (const eachOutcome of addOpenTasksGateToProfiles({
|
|
3209
|
+
mainSettingsPath: join(CLAUDE_HOME, SETTINGS_FILE_NAME),
|
|
3210
|
+
claudeHome: CLAUDE_HOME,
|
|
3211
|
+
allProfileSettingsPaths: profileSettingsPathsForStopGate(join(CLAUDE_HOME, SETTINGS_FILE_NAME)),
|
|
3212
|
+
homeDirectory: homedir(),
|
|
3213
|
+
})) {
|
|
3214
|
+
if (eachOutcome.additions.length > 0) {
|
|
3215
|
+
console.log(` Profile settings: open-tasks Stop gate added to ${eachOutcome.settingsPath}`);
|
|
3216
|
+
}
|
|
3217
|
+
}
|
|
3188
3218
|
|
|
3189
3219
|
const agentsHubSource = join(PACKAGE_ROOT, 'AGENTS.md');
|
|
3190
3220
|
if (existsSync(agentsHubSource)) {
|
|
@@ -3466,6 +3496,12 @@ function executeUninstallPlan(plan, helpers = {}) {
|
|
|
3466
3496
|
writeFileSync(plan.settingsPath, JSON.stringify(settings, null, 4) + '\n');
|
|
3467
3497
|
}
|
|
3468
3498
|
}
|
|
3499
|
+
for (const eachChangedPath of removeOpenTasksGateFromProfiles({
|
|
3500
|
+
claudeHome: CLAUDE_HOME,
|
|
3501
|
+
allProfileSettingsPaths: profileSettingsPathsForStopGate(plan.settingsPath),
|
|
3502
|
+
})) {
|
|
3503
|
+
console.log(` Profile settings: open-tasks Stop gate removed from ${eachChangedPath}`);
|
|
3504
|
+
}
|
|
3469
3505
|
if (!codexRoutingHookExists()) {
|
|
3470
3506
|
pruneManagedHooksFromHostConfiguration(CODEX_HOOKS_CONFIGURATION_PATH, 2);
|
|
3471
3507
|
}
|
|
@@ -3524,12 +3560,13 @@ function uninstall() {
|
|
|
3524
3560
|
managedRoot: CLAUDE_HOME,
|
|
3525
3561
|
manifestFilePath: MANIFEST_FILE,
|
|
3526
3562
|
settingsPath: plan.settingsPath,
|
|
3527
|
-
additionalSettingsPaths: [CODEX_HOOKS_CONFIGURATION_PATH],
|
|
3563
|
+
additionalSettingsPaths: [CODEX_HOOKS_CONFIGURATION_PATH, ...profileSettingsPathsForStopGate(plan.settingsPath)],
|
|
3528
3564
|
priorManifestFiles: [
|
|
3529
3565
|
...plan.removableFiles,
|
|
3530
3566
|
join(INSTALL_ROOT_RESOLUTION.codexHomeDirectory, 'AGENTS.md'),
|
|
3531
3567
|
...codexSolSettingSnapshotPaths(INSTALL_ROOT_RESOLUTION.codexHomeDirectory),
|
|
3532
3568
|
codexSolPromptPath(INSTALL_ROOT_RESOLUTION.codexHomeDirectory),
|
|
3569
|
+
join(INSTALL_ROOT_RESOLUTION.codexHomeDirectory, SOL_PROMPT_FILE_NAME),
|
|
3533
3570
|
],
|
|
3534
3571
|
journalParentDirectory: join(CLAUDE_HOME, TRANSACTION_JOURNAL_DIRECTORY_NAME),
|
|
3535
3572
|
});
|
package/bin/install.test.mjs
CHANGED
|
@@ -3687,3 +3687,42 @@ 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
|
+
});
|
|
@@ -0,0 +1,193 @@
|
|
|
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
|
+
function stopHooks(settingsPath) {
|
|
86
|
+
const settingsText = readFileSync(settingsPath, 'utf8');
|
|
87
|
+
if (!settingsText.trim()) return [];
|
|
88
|
+
const settings = JSON.parse(settingsText);
|
|
89
|
+
const stopGroups = settings?.hooks?.[STOP_EVENT_NAME];
|
|
90
|
+
if (!Array.isArray(stopGroups)) return [];
|
|
91
|
+
return stopGroups.flatMap((eachGroup) => (Array.isArray(eachGroup?.hooks) ? eachGroup.hooks : []));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function profileGateCommands(settingsPath, claudeHome) {
|
|
95
|
+
return stopHooks(settingsPath)
|
|
96
|
+
.filter((eachHook) => isGateHook(eachHook, claudeHome))
|
|
97
|
+
.map((eachHook) => eachHook.command);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* True when a profile's settings.json already runs a gate script from another Claude home.
|
|
102
|
+
*
|
|
103
|
+
* An install with CLAUDE_CONFIG_DIR set to the profile writes a gate entry
|
|
104
|
+
* for the profile's own copy of the script, and a second entry for the main
|
|
105
|
+
* home's copy would run the gate twice on every stop.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} settingsPath
|
|
108
|
+
* @param {string} claudeHome
|
|
109
|
+
* @returns {boolean}
|
|
110
|
+
*/
|
|
111
|
+
export function profileRunsItsOwnGate(settingsPath, claudeHome) {
|
|
112
|
+
const scriptPathSuffix = `/${comparableCommand(join(MANAGED_HOOKS_DIRECTORY_NAME, ...CURSOR_OPEN_TASKS_HOOK_RELATIVE_PATH))}`;
|
|
113
|
+
return stopHooks(settingsPath).some((eachHook) => typeof eachHook?.command === 'string'
|
|
114
|
+
&& comparableCommand(eachHook.command).includes(scriptPathSuffix)
|
|
115
|
+
&& !isGateHook(eachHook, claudeHome));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Add the main settings' open-tasks Stop gate to every other profile's settings.json.
|
|
120
|
+
*
|
|
121
|
+
* A profile keeps its own settings and hooks. The merge adds the one gate
|
|
122
|
+
* entry, in the spelling the main settings use, only where it is missing;
|
|
123
|
+
* an entry left with an older interpreter spelling is replaced. The file as
|
|
124
|
+
* it was before either change is copied to a timestamped `.bak`. A profile
|
|
125
|
+
* that already runs its own copy of the gate script is left as it is.
|
|
126
|
+
*
|
|
127
|
+
* @param {{ mainSettingsPath: string, claudeHome: string, allProfileSettingsPaths: string[],
|
|
128
|
+
* homeDirectory: string, now?: Date }} options
|
|
129
|
+
* @returns {{ settingsPath: string, additions: string[], backupPath: string|null }[]}
|
|
130
|
+
*/
|
|
131
|
+
export function addOpenTasksGateToProfiles(options) {
|
|
132
|
+
const gateHook = mainOpenTasksGateHook(options.mainSettingsPath, options.claudeHome);
|
|
133
|
+
if (!gateHook) return [];
|
|
134
|
+
const timestamp = (options.now ?? new Date()).toISOString().replace(/[:.]/g, '-');
|
|
135
|
+
const declaredSettings = [{ keyPath: ['hooks', STOP_EVENT_NAME], items: [{ hooks: [gateHook] }] }];
|
|
136
|
+
const allTargetPaths = options.allProfileSettingsPaths.filter(
|
|
137
|
+
(eachPath) => !profileRunsItsOwnGate(eachPath, options.claudeHome),
|
|
138
|
+
);
|
|
139
|
+
const allStalePaths = allTargetPaths.filter(
|
|
140
|
+
(eachPath) => profileGateCommands(eachPath, options.claudeHome)
|
|
141
|
+
.some((eachCommand) => eachCommand !== gateHook.command),
|
|
142
|
+
);
|
|
143
|
+
const originalTextByStalePath = new Map(allStalePaths.map((eachPath) => [eachPath, readFileSync(eachPath, 'utf8')]));
|
|
144
|
+
removeOpenTasksGateFromProfiles({ claudeHome: options.claudeHome, allProfileSettingsPaths: allStalePaths });
|
|
145
|
+
return allTargetPaths.map((eachPath) => {
|
|
146
|
+
const outcome = mergeProfileSettingsFile(eachPath, {
|
|
147
|
+
dryRun: false,
|
|
148
|
+
homeDirectory: options.homeDirectory,
|
|
149
|
+
timestamp,
|
|
150
|
+
declaredSettings,
|
|
151
|
+
});
|
|
152
|
+
if (outcome.backupPath && originalTextByStalePath.has(eachPath)) {
|
|
153
|
+
writeFileSync(outcome.backupPath, originalTextByStalePath.get(eachPath));
|
|
154
|
+
}
|
|
155
|
+
return outcome;
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Remove the gate entry that points at the main Claude home from each listed profile settings path.
|
|
161
|
+
*
|
|
162
|
+
* Only a hook whose command runs the main home's gate script goes. A Stop
|
|
163
|
+
* group left empty goes too, and so does an emptied Stop list or hooks object.
|
|
164
|
+
*
|
|
165
|
+
* @param {{ claudeHome: string, allProfileSettingsPaths: string[] }} options
|
|
166
|
+
* @returns {string[]} The settings paths that changed.
|
|
167
|
+
*/
|
|
168
|
+
export function removeOpenTasksGateFromProfiles(options) {
|
|
169
|
+
const allChangedPaths = [];
|
|
170
|
+
for (const eachPath of options.allProfileSettingsPaths) {
|
|
171
|
+
const settingsText = readFileSync(eachPath, 'utf8');
|
|
172
|
+
if (!settingsText.trim()) continue;
|
|
173
|
+
const settings = JSON.parse(settingsText);
|
|
174
|
+
const stopGroups = settings?.hooks?.[STOP_EVENT_NAME];
|
|
175
|
+
if (!Array.isArray(stopGroups)) continue;
|
|
176
|
+
let removedCount = 0;
|
|
177
|
+
for (const eachGroup of stopGroups) {
|
|
178
|
+
if (!Array.isArray(eachGroup?.hooks)) continue;
|
|
179
|
+
const keptHooks = eachGroup.hooks.filter((eachHook) => !isGateHook(eachHook, options.claudeHome));
|
|
180
|
+
removedCount += eachGroup.hooks.length - keptHooks.length;
|
|
181
|
+
eachGroup.hooks = keptHooks;
|
|
182
|
+
}
|
|
183
|
+
if (removedCount === 0) continue;
|
|
184
|
+
settings.hooks[STOP_EVENT_NAME] = stopGroups.filter(
|
|
185
|
+
(eachGroup) => !Array.isArray(eachGroup?.hooks) || eachGroup.hooks.length > 0,
|
|
186
|
+
);
|
|
187
|
+
if (settings.hooks[STOP_EVENT_NAME].length === 0) delete settings.hooks[STOP_EVENT_NAME];
|
|
188
|
+
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
|
189
|
+
writeFileSync(eachPath, JSON.stringify(settings, null, hostConfigurationIndent(settingsText, SETTINGS_INDENT)) + '\n');
|
|
190
|
+
allChangedPaths.push(eachPath);
|
|
191
|
+
}
|
|
192
|
+
return allChangedPaths;
|
|
193
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
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
|
+
} from './profile-stop-gate.mjs';
|
|
12
|
+
|
|
13
|
+
function makeHome(context) {
|
|
14
|
+
const homeDirectory = mkdtempSync(join(tmpdir(), 'cde-profile-gate-'));
|
|
15
|
+
context.after(() => rmSync(homeDirectory, { recursive: true, force: true }));
|
|
16
|
+
const claudeHome = join(homeDirectory, '.claude');
|
|
17
|
+
mkdirSync(claudeHome, { recursive: true });
|
|
18
|
+
const gateCommand = `python3 ${join(claudeHome, 'hooks', 'blocking', 'open_tasks_stop_gate.py').replace(/\\/g, '/')}`;
|
|
19
|
+
const mainSettingsPath = join(claudeHome, 'settings.json');
|
|
20
|
+
writeFileSync(mainSettingsPath, JSON.stringify({
|
|
21
|
+
hooks: { Stop: [{ hooks: [{ type: 'command', command: gateCommand, timeout: 10 }] }] },
|
|
22
|
+
}, null, 4));
|
|
23
|
+
return { homeDirectory, claudeHome, mainSettingsPath, gateCommand };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function writeProfile(homeDirectory, name, settings) {
|
|
27
|
+
const profileDirectory = join(homeDirectory, '.claude-profiles', name);
|
|
28
|
+
mkdirSync(profileDirectory, { recursive: true });
|
|
29
|
+
const settingsPath = join(profileDirectory, 'settings.json');
|
|
30
|
+
if (settings !== undefined) writeFileSync(settingsPath, JSON.stringify(settings, null, 4));
|
|
31
|
+
return settingsPath;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function stopCommands(settingsPath) {
|
|
35
|
+
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
36
|
+
return (settings.hooks?.Stop ?? []).flatMap((eachGroup) => eachGroup.hooks.map((eachHook) => eachHook.command));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test('every profile with its own settings.json gets the main gate entry', (context) => {
|
|
40
|
+
const { homeDirectory, claudeHome, mainSettingsPath, gateCommand } = makeHome(context);
|
|
41
|
+
const ownHooksPath = writeProfile(homeDirectory, 'own-hooks', {
|
|
42
|
+
hooks: { Stop: [{ hooks: [{ type: 'command', command: 'python3 /opt/own/stop.py' }] }] },
|
|
43
|
+
});
|
|
44
|
+
const prefsOnlyPath = writeProfile(homeDirectory, 'prefs-only', { outputStyle: 'plain' });
|
|
45
|
+
writeProfile(homeDirectory, 'no-settings');
|
|
46
|
+
|
|
47
|
+
const allProfilePaths = otherProfileSettingsPaths({ homeDirectory, environment: {}, mainSettingsPath });
|
|
48
|
+
addOpenTasksGateToProfiles({ mainSettingsPath, claudeHome, allProfileSettingsPaths: allProfilePaths, homeDirectory });
|
|
49
|
+
|
|
50
|
+
assert.deepEqual(allProfilePaths.sort(), [ownHooksPath, prefsOnlyPath].sort());
|
|
51
|
+
assert.deepEqual(stopCommands(ownHooksPath), ['python3 /opt/own/stop.py', gateCommand]);
|
|
52
|
+
assert.deepEqual(stopCommands(prefsOnlyPath), [gateCommand]);
|
|
53
|
+
assert.equal(JSON.parse(readFileSync(prefsOnlyPath, 'utf8')).outputStyle, 'plain');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('a profile whose settings.json links to the main settings is left out', (context) => {
|
|
57
|
+
const { homeDirectory, mainSettingsPath } = makeHome(context);
|
|
58
|
+
const linkedPath = writeProfile(homeDirectory, 'linked');
|
|
59
|
+
try {
|
|
60
|
+
symlinkSync(mainSettingsPath, linkedPath, 'file');
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (error.code === 'EPERM') return context.skip('this account cannot create symlinks');
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
assert.deepEqual(otherProfileSettingsPaths({ homeDirectory, environment: {}, mainSettingsPath }), []);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('a second run adds nothing, and an older interpreter spelling is replaced', (context) => {
|
|
70
|
+
const { homeDirectory, claudeHome, mainSettingsPath, gateCommand } = makeHome(context);
|
|
71
|
+
const staleCommand = gateCommand.replace(/^python3/, '/usr/bin/python3.9');
|
|
72
|
+
const profilePath = writeProfile(homeDirectory, 'stale', {
|
|
73
|
+
hooks: { Stop: [{ hooks: [{ type: 'command', command: staleCommand }] }] },
|
|
74
|
+
});
|
|
75
|
+
const options = { mainSettingsPath, claudeHome, allProfileSettingsPaths: [profilePath], homeDirectory };
|
|
76
|
+
|
|
77
|
+
addOpenTasksGateToProfiles(options);
|
|
78
|
+
const secondOutcome = addOpenTasksGateToProfiles(options);
|
|
79
|
+
|
|
80
|
+
assert.deepEqual(stopCommands(profilePath), [gateCommand]);
|
|
81
|
+
assert.deepEqual(secondOutcome[0].additions, []);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('replacing an older spelling backs up the file as it was before the replacement', (context) => {
|
|
85
|
+
const { homeDirectory, claudeHome, mainSettingsPath, gateCommand } = makeHome(context);
|
|
86
|
+
const staleCommand = gateCommand.replace(/^python3/, '/usr/bin/python3.9');
|
|
87
|
+
const profilePath = writeProfile(homeDirectory, 'stale', {
|
|
88
|
+
hooks: { Stop: [{ hooks: [{ type: 'command', command: staleCommand }] }] },
|
|
89
|
+
});
|
|
90
|
+
const originalText = readFileSync(profilePath, 'utf8');
|
|
91
|
+
|
|
92
|
+
const [outcome] = addOpenTasksGateToProfiles({ mainSettingsPath, claudeHome, allProfileSettingsPaths: [profilePath], homeDirectory });
|
|
93
|
+
|
|
94
|
+
assert.equal(readFileSync(outcome.backupPath, 'utf8'), originalText);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('removal takes out only the main gate entry and leaves the profile as it was', (context) => {
|
|
98
|
+
const { homeDirectory, claudeHome, mainSettingsPath } = makeHome(context);
|
|
99
|
+
const ownHooksPath = writeProfile(homeDirectory, 'own-hooks', {
|
|
100
|
+
hooks: { Stop: [{ hooks: [{ type: 'command', command: 'python3 /opt/own/stop.py' }] }] },
|
|
101
|
+
});
|
|
102
|
+
const prefsOnlyPath = writeProfile(homeDirectory, 'prefs-only', { outputStyle: 'plain' });
|
|
103
|
+
const allProfilePaths = [ownHooksPath, prefsOnlyPath];
|
|
104
|
+
addOpenTasksGateToProfiles({ mainSettingsPath, claudeHome, allProfileSettingsPaths: allProfilePaths, homeDirectory });
|
|
105
|
+
|
|
106
|
+
const allChangedPaths = removeOpenTasksGateFromProfiles({ claudeHome, allProfileSettingsPaths: allProfilePaths });
|
|
107
|
+
|
|
108
|
+
assert.deepEqual(allChangedPaths, allProfilePaths);
|
|
109
|
+
assert.deepEqual(stopCommands(ownHooksPath), ['python3 /opt/own/stop.py']);
|
|
110
|
+
assert.deepEqual(JSON.parse(readFileSync(prefsOnlyPath, 'utf8')), { outputStyle: 'plain' });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('a profile that already runs its own copy of the gate script gets no second entry', (context) => {
|
|
114
|
+
const { homeDirectory, claudeHome, mainSettingsPath } = makeHome(context);
|
|
115
|
+
const ownGateCommand = `python3 ${join(homeDirectory, '.claude-profiles', 'isolated', 'hooks', 'blocking', 'open_tasks_stop_gate.py').replace(/\\/g, '/')}`;
|
|
116
|
+
const profilePath = writeProfile(homeDirectory, 'isolated', {
|
|
117
|
+
hooks: { Stop: [{ hooks: [{ type: 'command', command: ownGateCommand }] }] },
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const allOutcomes = addOpenTasksGateToProfiles({ mainSettingsPath, claudeHome, allProfileSettingsPaths: [profilePath], homeDirectory });
|
|
121
|
+
|
|
122
|
+
assert.deepEqual(allOutcomes, []);
|
|
123
|
+
assert.deepEqual(stopCommands(profilePath), [ownGateCommand]);
|
|
124
|
+
});
|
|
@@ -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.
|
|
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
|
|