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.
@@ -1,5 +1,8 @@
1
- import { lstatSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
2
- import { join } from 'node:path';
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 lives in CODEX_HOME beside config.toml, so the file the setting
60
- * names stays put when any claude-dev-env version, older ones included,
61
- * prunes or rewrites the managed files under ~/.claude. A missing file makes
62
- * every Codex command fail before it starts.
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 into CODEX_HOME.
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 Codex copy's path when this call wrote it, and null when it was already current.
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(codexHome, { recursive: true });
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 Codex copy of the prompt is removed in every
124
- * case, because install writes it even when the user's own setting kept the
125
- * package line out.
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
  });
@@ -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
 
@@ -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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.88.2",
3
+ "version": "8.88.3",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {