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.
@@ -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,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) {
@@ -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
 
@@ -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.4",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {