claude-dev-env 8.88.1 → 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,5 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, writeFileSync } from 'node:fs';
2
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import {
@@ -23,6 +23,9 @@ export const DEEPSTACK_PLUGIN_OPT_OUT_FLAG = '--no-deepstack';
23
23
  export const DEEPSTACK_PLUGIN_OPT_OUT_VARIABLE = 'CDE_INSTALL_DEEPSTACK';
24
24
  export const DEEPSTACK_SOURCE_VARIABLE = 'CDE_DEEPSTACK_SOURCE';
25
25
  export const DEEPSTACK_CURSOR_DIRECTORY_NAME = 'plugins/deepstack';
26
+ /** Name prefix of the temporary DeepStack clone a full install makes when no local tree exists. */
27
+ export const DEEPSTACK_CLONE_PREFIX = 'deepstack-';
28
+ const CLONE_REMOVAL_RETRIES = 3;
26
29
  export const DEEPSTACK_SKILL_RELATIVE_PATH = 'skills/deepstack-mode/SKILL.md';
27
30
  export const DEEPSTACK_SESSION_SCRIPT_RELATIVE_PATH = 'hooks/session-start.sh';
28
31
  export const DEEPSTACK_SESSION_POWERSHELL_RELATIVE_PATH = 'hooks/session-start.ps1';
@@ -266,7 +269,9 @@ export function seedCodexDeepStackModels(codexHome) {
266
269
  * @param {string} [options.sourceDirectory] A local plugin tree for the Cursor copy.
267
270
  * @param {string[]} [options.argumentList] Command-line arguments, for the opt-out flag.
268
271
  * @param {Record<string, string|undefined>} [options.environment] The process environment.
269
- * @param {object} [dependencies] Seams for the marketplace command runner.
272
+ * @param {boolean} [options.allowClone] False stops the clone fallback.
273
+ * @param {string} [options.cloneParentDirectory] Where the temporary clone goes. Defaults to the system temp folder.
274
+ * @param {object} [dependencies] Seams for the marketplace command runner and the clone.
270
275
  * @returns {{status: string, hosts: object[], cursor: object, warning: string|null}} The outcome.
271
276
  */
272
277
  export function installDeepStackForHosts(options = {}, dependencies = {}) {
@@ -290,16 +295,25 @@ export function installDeepStackForHosts(options = {}, dependencies = {}) {
290
295
  environment,
291
296
  sourceDirectory: options.sourceDirectory,
292
297
  });
293
- if (!sourceDirectory && options.allowClone !== false) {
294
- sourceDirectory = cloneDeepStackRepository(mkdtempSync(join(tmpdir(), 'deepstack-')));
298
+ const cloneDirectory = !sourceDirectory && options.allowClone !== false
299
+ ? mkdtempSync(join(options.cloneParentDirectory ?? tmpdir(), DEEPSTACK_CLONE_PREFIX))
300
+ : null;
301
+ let cursor;
302
+ try {
303
+ if (cloneDirectory) {
304
+ const cloneRepository = dependencies.cloneRepository ?? cloneDeepStackRepository;
305
+ sourceDirectory = cloneRepository(cloneDirectory);
306
+ }
307
+ cursor = installDeepStackCursor({
308
+ cursorDirectory: options.cursorDirectory,
309
+ pluginSourceDirectory: sourceDirectory,
310
+ firstTaskHookPath: options.firstTaskHookPath,
311
+ pythonCommand: options.pythonCommand,
312
+ formatPathArgument: options.formatPathArgument,
313
+ });
314
+ } finally {
315
+ if (cloneDirectory) removeDeepStackClone(cloneDirectory);
295
316
  }
296
- const cursor = installDeepStackCursor({
297
- cursorDirectory: options.cursorDirectory,
298
- pluginSourceDirectory: sourceDirectory,
299
- firstTaskHookPath: options.firstTaskHookPath,
300
- pythonCommand: options.pythonCommand,
301
- formatPathArgument: options.formatPathArgument,
302
- });
303
317
  if (marketplace.hosts.some(host => host.host === 'codex' && host.status === 'installed') && options.codexHome) {
304
318
  seedCodexDeepStackModels(options.codexHome);
305
319
  }
@@ -314,14 +328,20 @@ export function installDeepStackForHosts(options = {}, dependencies = {}) {
314
328
  }
315
329
 
316
330
  /**
317
- * Clone jl-cmd-projects/DeepStack when no local tree is available.
331
+ * Remove the temporary DeepStack clone once the Cursor copy is written.
318
332
  *
319
- * The installer calls this only from a full install that has no source
320
- * directory and no marketplace cache. Tests pass a source directory instead.
333
+ * Nothing reads the clone after the install: the Cursor hooks point at the
334
+ * copy under the Cursor home, and Claude and Codex read their own marketplace
335
+ * caches. Git writes its pack files read-only, which the forced removal
336
+ * handles on Windows too.
321
337
  *
322
- * @param {string} destination An empty directory for the clone.
323
- * @returns {string|null} The plugin directory inside the clone.
338
+ * @param {string} cloneDirectory The directory the clone was made in.
339
+ * @returns {void}
324
340
  */
341
+ export function removeDeepStackClone(cloneDirectory) {
342
+ rmSync(cloneDirectory, { recursive: true, force: true, maxRetries: CLONE_REMOVAL_RETRIES });
343
+ }
344
+
325
345
  /**
326
346
  * Return the git clone URL for the DeepStack marketplace repository.
327
347
  *
@@ -331,6 +351,15 @@ export function deepStackCloneUrl() {
331
351
  return ['https:', '', 'github.com', `${DEEPSTACK_MARKETPLACE_REPOSITORY}.git`].join('/');
332
352
  }
333
353
 
354
+ /**
355
+ * Clone jl-cmd-projects/DeepStack when no local tree is available.
356
+ *
357
+ * The installer calls this only from a full install that has no source
358
+ * directory and no marketplace cache. Tests pass a source directory instead.
359
+ *
360
+ * @param {string} destination An empty directory for the clone.
361
+ * @returns {string|null} The plugin directory inside the clone.
362
+ */
334
363
  export function cloneDeepStackRepository(destination) {
335
364
  const outcome = spawnSync('git', [
336
365
  'clone',
@@ -1,10 +1,11 @@
1
1
  import { test } from 'node:test';
2
2
  import { strict as assert } from 'node:assert';
3
- import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { chmodSync, existsSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import {
7
7
  DEEPSTACK_MARKETPLACE_REPOSITORY,
8
+ DEEPSTACK_SKILL_RELATIVE_PATH,
8
9
  DEEPSTACK_PLUGIN_IDENTIFIER,
9
10
  DEEPSTACK_PLUGIN_SPEC,
10
11
  deepStackCloneUrl,
@@ -136,3 +137,66 @@ test('--no-deepstack and CDE_INSTALL_DEEPSTACK=0 skip the install', () => {
136
137
  assert.equal(skipped.status, 'skipped');
137
138
  assert.equal(skipped.cursor.status, 'skipped');
138
139
  });
140
+
141
+ const absentHostRunner = () => ({ status: null, stderr: '', error: { code: 'ENOENT' } });
142
+
143
+ function writeClonedPlugin(cloneDirectory) {
144
+ const pluginDirectory = join(cloneDirectory, 'plugins', 'deepstack');
145
+ mkdirSync(join(pluginDirectory, 'skills', 'deepstack-mode'), { recursive: true });
146
+ writeFileSync(join(pluginDirectory, DEEPSTACK_SKILL_RELATIVE_PATH), '# DeepStack\n');
147
+ mkdirSync(join(cloneDirectory, '.git', 'objects', 'pack'), { recursive: true });
148
+ const packPath = join(cloneDirectory, '.git', 'objects', 'pack', 'pack-1.pack');
149
+ writeFileSync(packPath, 'pack');
150
+ chmodSync(packPath, 0o444);
151
+ return pluginDirectory;
152
+ }
153
+
154
+ test('the temporary clone is removed once the Cursor copy is written', () => {
155
+ const root = mkdtempSync(join(tmpdir(), 'deepstack-clone-'));
156
+ try {
157
+ const cloneParentDirectory = join(root, 'temp');
158
+ mkdirSync(cloneParentDirectory);
159
+ const cursorDirectory = join(root, 'cursor');
160
+ let clonedInto = null;
161
+ const outcome = installDeepStackForHosts({
162
+ claudeRoot: join(root, 'claude'),
163
+ codexHome: join(root, 'codex'),
164
+ cursorDirectory,
165
+ firstTaskHookPath: join(root, 'hooks', 'deepen_first_prompt.py'),
166
+ environment: {},
167
+ cloneParentDirectory,
168
+ }, {
169
+ runCommand: absentHostRunner,
170
+ cloneRepository: cloneDirectory => {
171
+ clonedInto = cloneDirectory;
172
+ return writeClonedPlugin(cloneDirectory);
173
+ },
174
+ });
175
+ assert.equal(outcome.cursor.status, 'installed');
176
+ assert.ok(clonedInto.startsWith(cloneParentDirectory));
177
+ assert.ok(existsSync(join(cursorDirectory, 'plugins', 'deepstack', DEEPSTACK_SKILL_RELATIVE_PATH)));
178
+ assert.deepEqual(readdirSync(cloneParentDirectory), []);
179
+ } finally {
180
+ rmSync(root, { recursive: true, force: true });
181
+ }
182
+ });
183
+
184
+ test('a failed clone leaves no temporary folder behind', () => {
185
+ const root = mkdtempSync(join(tmpdir(), 'deepstack-clone-'));
186
+ try {
187
+ const cloneParentDirectory = join(root, 'temp');
188
+ mkdirSync(cloneParentDirectory);
189
+ const outcome = installDeepStackForHosts({
190
+ claudeRoot: join(root, 'claude'),
191
+ codexHome: join(root, 'codex'),
192
+ cursorDirectory: join(root, 'cursor'),
193
+ firstTaskHookPath: join(root, 'hooks', 'deepen_first_prompt.py'),
194
+ environment: {},
195
+ cloneParentDirectory,
196
+ }, { runCommand: absentHostRunner, cloneRepository: () => null });
197
+ assert.equal(outcome.cursor.status, 'failed');
198
+ assert.deepEqual(readdirSync(cloneParentDirectory), []);
199
+ } finally {
200
+ rmSync(root, { recursive: true, force: true });
201
+ }
202
+ });
@@ -1,6 +1,7 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import { existsSync, readdirSync, rmSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
+ import { findFolderLockers, lockedFolderAdvice, namesALockedFolder } from './folder-lockers.mjs';
4
5
 
5
6
  export const PSTACK_MARKETPLACE_REPOSITORY = 'jl-cmd/pstack-claude';
6
7
  export const PSTACK_MARKETPLACE_NAME = 'pstack-claude';
@@ -248,7 +249,17 @@ function absentHostOutcome(allSpecs, host, executable) {
248
249
  };
249
250
  }
250
251
 
251
- function installForHost(allSpecs, host, homeDirectory, environment, runCommand) {
252
+ function lockedFolderDetail(allSpecs, host, homeDirectory, errorText, findLockers) {
253
+ const hostDefinition = HOST_DEFINITIONS[host];
254
+ if (!namesALockedFolder(errorText) || !hostDefinition.orphanedVersionsDirectory || !homeDirectory) return '';
255
+ return allSpecs.map(eachSpec => {
256
+ const pluginCacheDirectory = hostDefinition.orphanedVersionsDirectory(homeDirectory, eachSpec);
257
+ const allLockers = findLockers(pluginCacheDirectory);
258
+ return allLockers === null ? '' : ` ${lockedFolderAdvice(pluginCacheDirectory, allLockers)}`;
259
+ }).join('');
260
+ }
261
+
262
+ function installForHost(allSpecs, host, homeDirectory, environment, runCommand, findLockers) {
252
263
  const plan = marketplacePluginsPlan(allSpecs, host);
253
264
  const executable = environment[plan.executableVariable] || plan.executable;
254
265
  const commandEnvironment = { [plan.homeVariable]: homeDirectory };
@@ -265,11 +276,12 @@ function installForHost(allSpecs, host, homeDirectory, environment, runCommand)
265
276
  if (namesAnAbsentCommand(outcome)) return absentHostOutcome(allSpecs, host, executable);
266
277
  if (outcome.status !== 0 || outcome.error) {
267
278
  const detail = singleLineDetail(outcome.stderr) || outcome.error?.message || `exit ${outcome.status}`;
279
+ const lockAdvice = lockedFolderDetail(allSpecs, host, homeDirectory, detail, findLockers);
268
280
  return {
269
281
  host,
270
282
  executable,
271
283
  status: 'failed',
272
- warning: `${executable} ${commandArguments.join(' ')} failed: ${detail}`,
284
+ warning: `${executable} ${commandArguments.join(' ')} failed: ${detail}${lockAdvice}`,
273
285
  };
274
286
  }
275
287
  }
@@ -294,7 +306,9 @@ function installForHost(allSpecs, host, homeDirectory, environment, runCommand)
294
306
  * and skills this run already wrote still reach their durable places. An
295
307
  * absent tool arrives as a spawn `ENOENT` on other platforms and as
296
308
  * `cmd.exe`'s command-not-found exit code on Windows, and both read as
297
- * skipped.
309
+ * skipped. When a host cannot replace a plugin folder because something holds
310
+ * it, the warning names each process that holds it, so the user knows what to
311
+ * close before running the install again.
298
312
  *
299
313
  * @param {ReadonlyArray<typeof PSTACK_PLUGIN_SPEC>} allSpecs The plugins to install, from one marketplace.
300
314
  * @param {object} [options] Install targets.
@@ -302,12 +316,13 @@ function installForHost(allSpecs, host, homeDirectory, environment, runCommand)
302
316
  * @param {string} [options.codexHome] The Codex home to install into.
303
317
  * @param {string[]} [options.hosts] The hosts to install. Defaults to the hosts every spec names.
304
318
  * @param {Record<string, string|undefined>} [options.environment] The process environment.
305
- * @param {object} [dependencies] Seams for the command runner.
319
+ * @param {object} [dependencies] Seams for the command runner and the folder locker search.
306
320
  * @returns {{status: string, hosts: object[], warning: string|null}} The outcome per host.
307
321
  */
308
322
  export function installMarketplacePlugins(allSpecs, options = {}, dependencies = {}) {
309
323
  const environment = options.environment ?? process.env;
310
324
  const runCommand = dependencies.runCommand ?? runHostCommand;
325
+ const findLockers = dependencies.findLockers ?? findFolderLockers;
311
326
  const homeDirectories = { claude: options.claudeRoot, codex: options.codexHome };
312
327
  const hosts = options.hosts ?? allSpecs[0].hosts.filter(
313
328
  eachHost => allSpecs.every(eachSpec => eachSpec.hosts.includes(eachHost)),
@@ -318,6 +333,7 @@ export function installMarketplacePlugins(allSpecs, options = {}, dependencies =
318
333
  homeDirectories[host],
319
334
  environment,
320
335
  runCommand,
336
+ findLockers,
321
337
  ));
322
338
  const failedCount = hostOutcomes.filter(outcome => outcome.status === 'failed').length;
323
339
  const installedCount = hostOutcomes.filter(outcome => outcome.status === 'installed').length;
@@ -392,3 +392,56 @@ test('a clean install deletes the orphaned pstack versions and keeps the install
392
392
  assert.equal(existsSync(join(versionsDirectory, '0.9.64')), true);
393
393
  assert.deepEqual(removeOrphanedPluginVersions(versionsDirectory), []);
394
394
  });
395
+
396
+ test('a plugin folder held by another program names the process to close', () => {
397
+ const lockedError = 'Failed to install plugin "pstack@pstack-claude": The installed copy of this plugin version'
398
+ + ' could not be replaced: it is in use by another program (EPERM).';
399
+ const searchedFolders = [];
400
+ const runner = recordingRunner((executable, commandArguments) => (
401
+ executable === 'claude' && commandArguments[1] === 'install'
402
+ ? { status: 1, stderr: lockedError }
403
+ : { status: 0, stderr: '' }));
404
+ const outcome = installMarketplacePlugins([PSTACK_PLUGIN_SPEC], ROOTS, {
405
+ runCommand: runner.runCommand,
406
+ findLockers: folderPath => {
407
+ searchedFolders.push(folderPath);
408
+ return [{ pid: 19056, name: 'Cursor.exe', reason: 'command-line' }];
409
+ },
410
+ });
411
+ const claude = outcome.hosts.find(host => host.host === 'claude');
412
+ assert.equal(claude.status, 'failed');
413
+ assert.deepEqual(searchedFolders, [join('/managed/.claude', 'plugins', 'cache', 'pstack-claude', 'pstack')]);
414
+ assert.match(claude.warning, /Close Cursor\.exe \(PID 19056, was started on a path inside it\)/);
415
+ });
416
+
417
+ test('a failure that is not a held folder runs no locker search', () => {
418
+ const runner = recordingRunner((executable, commandArguments) => (
419
+ executable === 'claude' && commandArguments[2] === 'add'
420
+ ? { status: 1, stderr: 'owner: Invalid input: expected object, received undefined' }
421
+ : { status: 0, stderr: '' }));
422
+ let searched = false;
423
+ installMarketplacePlugins([PSTACK_PLUGIN_SPEC], ROOTS, {
424
+ runCommand: runner.runCommand,
425
+ findLockers: () => {
426
+ searched = true;
427
+ return [];
428
+ },
429
+ });
430
+ assert.equal(searched, false);
431
+ });
432
+
433
+ test('a held folder that was not searched adds no claim about what holds it', () => {
434
+ const lockedError = 'Failed to install plugin "pstack@pstack-claude": EPERM: operation not permitted';
435
+ const runner = recordingRunner((executable, commandArguments) => (
436
+ executable === 'claude' && commandArguments[1] === 'install'
437
+ ? { status: 1, stderr: lockedError }
438
+ : { status: 0, stderr: '' }));
439
+ const outcome = installMarketplacePlugins([PSTACK_PLUGIN_SPEC], ROOTS, {
440
+ runCommand: runner.runCommand,
441
+ findLockers: () => null,
442
+ });
443
+ const claude = outcome.hosts.find(host => host.host === 'claude');
444
+ assert.equal(claude.status, 'failed');
445
+ assert.match(claude.warning, /EPERM: operation not permitted$/);
446
+ assert.doesNotMatch(claude.warning, /likely holder|Close /);
447
+ });
package/bin/install.mjs CHANGED
@@ -72,7 +72,9 @@ import {
72
72
  writeCursorAgentsGuidanceHooks,
73
73
  } from './cursor-agents-guidance.mjs';
74
74
  import {
75
+ codexSolPromptPath,
75
76
  codexSolSettingSnapshotPaths,
77
+ installCodexSolPrompt,
76
78
  removeCodexSolSetting,
77
79
  SOL_PROMPT_DIRECTORY_NAME,
78
80
  SOL_PROMPT_FILE_NAME,
@@ -126,6 +128,12 @@ import {
126
128
  resolveFaultPhaseFromEnvironment,
127
129
  runWithInstallTransaction,
128
130
  } from './install-transaction.mjs';
131
+ import { findShadowingGlobalInstall, shadowingGlobalAdvice } from './global-install-shadow.mjs';
132
+ import {
133
+ addOpenTasksGateToProfiles,
134
+ otherProfileSettingsPaths,
135
+ removeOpenTasksGateFromProfiles,
136
+ } from './profile-stop-gate.mjs';
129
137
  import { pythonExecutableProbeCommand } from './python-probe.mjs';
130
138
 
131
139
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
@@ -443,16 +451,28 @@ export function installPstackPluginForHosts() {
443
451
  });
444
452
  }
445
453
 
454
+ /** Side steps that need the user, repeated at the end of the run so none scrolls past unseen. */
455
+ const allActionNeededLines = [];
456
+
446
457
  function printPluginHostOutcomes(allSpecs, pluginOutcome) {
447
458
  for (const eachSpec of allSpecs) {
448
459
  for (const eachHost of pluginOutcome.hosts) {
449
- console.log(eachHost.warning
450
- ? ` ${eachSpec.label} (${eachHost.host}): ${eachHost.status} — ${eachHost.warning}`
451
- : ` ${eachSpec.label} (${eachHost.host}): ${eachHost.status}`);
460
+ const outcomeLine = eachHost.warning
461
+ ? `${eachSpec.label} (${eachHost.host}): ${eachHost.status} — ${eachHost.warning}`
462
+ : `${eachSpec.label} (${eachHost.host}): ${eachHost.status}`;
463
+ console.log(` ${outcomeLine}`);
464
+ if (eachHost.status === 'failed') allActionNeededLines.push(outcomeLine);
452
465
  }
453
466
  }
454
467
  }
455
468
 
469
+ function printActionNeeded() {
470
+ if (allActionNeededLines.length === 0) return;
471
+ console.warn(`Action needed (${allActionNeededLines.length}):`);
472
+ for (const eachLine of allActionNeededLines) console.warn(` - ${eachLine}`);
473
+ console.warn('');
474
+ }
475
+
456
476
  function seedSubagentModelPolicy() {
457
477
  const policyDirectory = join(AGENTS_HOME, CURSOR_RULES_DIRECTORY_NAME);
458
478
  const policyPath = join(policyDirectory, SUBAGENT_MODEL_POLICY_FILE_NAME);
@@ -2680,17 +2700,33 @@ function install(selectedGroups, options = {}) {
2680
2700
  executeInstallPlan(plan);
2681
2701
  }
2682
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
+
2683
2717
  function executeInstallPlan(plan) {
2684
2718
  const faultPhase = resolveFaultPhaseFromEnvironment(process.env);
2685
2719
  const snapshot = capturePriorInstallSnapshot({
2686
2720
  managedRoot: CLAUDE_HOME,
2687
2721
  manifestFilePath: MANIFEST_FILE,
2688
2722
  settingsPath: plan.settingsPath,
2689
- additionalSettingsPaths: [CODEX_HOOKS_CONFIGURATION_PATH],
2723
+ additionalSettingsPaths: [CODEX_HOOKS_CONFIGURATION_PATH, ...profileSettingsPathsForStopGate(plan.settingsPath)],
2690
2724
  priorManifestFiles: [
2691
2725
  ...(plan.priorManifest.files || []),
2692
2726
  join(INSTALL_ROOT_RESOLUTION.codexHomeDirectory, 'AGENTS.md'),
2693
2727
  ...codexSolSettingSnapshotPaths(INSTALL_ROOT_RESOLUTION.codexHomeDirectory),
2728
+ codexSolPromptPath(INSTALL_ROOT_RESOLUTION.codexHomeDirectory),
2729
+ join(INSTALL_ROOT_RESOLUTION.codexHomeDirectory, SOL_PROMPT_FILE_NAME),
2694
2730
  ],
2695
2731
  journalParentDirectory: join(CLAUDE_HOME, TRANSACTION_JOURNAL_DIRECTORY_NAME),
2696
2732
  });
@@ -2737,6 +2773,16 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2737
2773
  const groupLabel = selectedGroups ? `groups: ${selectedGroups.join(', ')}` : 'all';
2738
2774
  console.log(`\nInstalling ${PACKAGE_NAME} (${groupLabel})...\n`);
2739
2775
  console.log(` Python: ${pythonCommand}`);
2776
+ const shadowingGlobal = findShadowingGlobalInstall({
2777
+ packageName: PACKAGE_NAME,
2778
+ packageRoot: PACKAGE_ROOT,
2779
+ packageVersion: PACKAGE_VERSION,
2780
+ });
2781
+ if (shadowingGlobal) {
2782
+ const shadowAdvice = shadowingGlobalAdvice(PACKAGE_NAME, shadowingGlobal, PACKAGE_VERSION);
2783
+ console.warn(` Warning: ${shadowAdvice}`);
2784
+ allActionNeededLines.push(shadowAdvice);
2785
+ }
2740
2786
  mkdirSync(CLAUDE_HOME, { recursive: true });
2741
2787
  const publishedPointerPaths = publishManagedLookupPointers();
2742
2788
 
@@ -2996,6 +3042,9 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2996
3042
  console.log(cursorOutcome.warning
2997
3043
  ? ` DeepStack (cursor): ${cursorOutcome.status} — ${cursorOutcome.warning}`
2998
3044
  : ` DeepStack (cursor): ${cursorOutcome.status}`);
3045
+ if (cursorOutcome.status === 'failed') {
3046
+ allActionNeededLines.push(`DeepStack (cursor): failed — ${cursorOutcome.warning}`);
3047
+ }
2999
3048
  }
3000
3049
  const allRepositoryPluginSpecs = [
3001
3050
  USAGE_WRAPUP_PLUGIN_SPEC,
@@ -3028,9 +3077,14 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
3028
3077
  INSTALL_ROOT_RESOLUTION.codexHomeDirectory,
3029
3078
  );
3030
3079
  if (deepenGuidancePath) allCodexGuidancePaths.push(deepenGuidancePath);
3080
+ const solPromptPath = installCodexSolPrompt(
3081
+ INSTALL_ROOT_RESOLUTION.codexHomeDirectory,
3082
+ join(PACKAGE_ROOT, SOL_PROMPT_DIRECTORY_NAME, SOL_PROMPT_FILE_NAME),
3083
+ );
3084
+ if (solPromptPath) allCodexGuidancePaths.push(solPromptPath);
3031
3085
  const solSettingPath = writeCodexSolSetting(
3032
3086
  INSTALL_ROOT_RESOLUTION.codexHomeDirectory,
3033
- join(CLAUDE_HOME, SOL_PROMPT_DIRECTORY_NAME, SOL_PROMPT_FILE_NAME),
3087
+ codexSolPromptPath(INSTALL_ROOT_RESOLUTION.codexHomeDirectory),
3034
3088
  );
3035
3089
  if (solSettingPath) {
3036
3090
  allCodexGuidancePaths.push(solSettingPath);
@@ -3151,6 +3205,16 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
3151
3205
  for (const eachLine of describeProfileSettingsOutcome(mergeOutcome, false)) {
3152
3206
  console.log(` Profile settings: ${eachLine}`);
3153
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
+ }
3154
3218
 
3155
3219
  const agentsHubSource = join(PACKAGE_ROOT, 'AGENTS.md');
3156
3220
  if (existsSync(agentsHubSource)) {
@@ -3248,6 +3312,7 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
3248
3312
  console.log(` hooks: ${summary.hookFiles.created + summary.hookFiles.updated} files, ${summary.hookGroups} groups in settings.json`);
3249
3313
  }
3250
3314
  console.log(` python: ${pythonCommand}\n`);
3315
+ printActionNeeded();
3251
3316
  }
3252
3317
 
3253
3318
  function normalizePathForComparison(rawPath) {
@@ -3431,6 +3496,12 @@ function executeUninstallPlan(plan, helpers = {}) {
3431
3496
  writeFileSync(plan.settingsPath, JSON.stringify(settings, null, 4) + '\n');
3432
3497
  }
3433
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
+ }
3434
3505
  if (!codexRoutingHookExists()) {
3435
3506
  pruneManagedHooksFromHostConfiguration(CODEX_HOOKS_CONFIGURATION_PATH, 2);
3436
3507
  }
@@ -3489,11 +3560,13 @@ function uninstall() {
3489
3560
  managedRoot: CLAUDE_HOME,
3490
3561
  manifestFilePath: MANIFEST_FILE,
3491
3562
  settingsPath: plan.settingsPath,
3492
- additionalSettingsPaths: [CODEX_HOOKS_CONFIGURATION_PATH],
3563
+ additionalSettingsPaths: [CODEX_HOOKS_CONFIGURATION_PATH, ...profileSettingsPathsForStopGate(plan.settingsPath)],
3493
3564
  priorManifestFiles: [
3494
3565
  ...plan.removableFiles,
3495
3566
  join(INSTALL_ROOT_RESOLUTION.codexHomeDirectory, 'AGENTS.md'),
3496
3567
  ...codexSolSettingSnapshotPaths(INSTALL_ROOT_RESOLUTION.codexHomeDirectory),
3568
+ codexSolPromptPath(INSTALL_ROOT_RESOLUTION.codexHomeDirectory),
3569
+ join(INSTALL_ROOT_RESOLUTION.codexHomeDirectory, SOL_PROMPT_FILE_NAME),
3497
3570
  ],
3498
3571
  journalParentDirectory: join(CLAUDE_HOME, TRANSACTION_JOURNAL_DIRECTORY_NAME),
3499
3572
  });
@@ -3633,3 +3633,96 @@ test('a full install clears a retired session-continuity registration from every
3633
3633
  );
3634
3634
  }
3635
3635
  });
3636
+
3637
+ function installWithGlobalPrefix(context, globalPrefix) {
3638
+ const homeDirectory = mkdtempSync(join(tmpdir(), 'cde-action-needed-'));
3639
+ context.after(() => rmSync(homeDirectory, { recursive: true, force: true }));
3640
+ return spawnSync(process.execPath, [join(dirname(fileURLToPath(import.meta.url)), 'install.mjs')], {
3641
+ cwd: dirname(dirname(fileURLToPath(import.meta.url))),
3642
+ encoding: 'utf8',
3643
+ env: {
3644
+ ...process.env,
3645
+ CLAUDE_CONFIG_DIR: undefined,
3646
+ LLM_SETTINGS_PROFILES_ROOT: undefined,
3647
+ CLAUDE_DEV_ENV_INSTALL_FAULT: undefined,
3648
+ CDE_INSTALL_PSTACK: '0',
3649
+ CDE_INSTALL_DEEPSTACK: '0',
3650
+ CDE_INSTALL_USAGE_WRAPUP: '0',
3651
+ CDE_INSTALL_SUBAGENT_MODELS: '0',
3652
+ CDE_INSTALL_DEEPEN_FIRST_PROMPT: '0',
3653
+ HOME: homeDirectory,
3654
+ USERPROFILE: homeDirectory,
3655
+ APPDATA: join(homeDirectory, 'AppData', 'Roaming'),
3656
+ CODEX_HOME: join(homeDirectory, '.codex'),
3657
+ GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
3658
+ TEMP: homeDirectory,
3659
+ TMP: homeDirectory,
3660
+ npm_config_prefix: undefined,
3661
+ npm_config_global_prefix: globalPrefix ?? join(homeDirectory, 'no-global-prefix'),
3662
+ },
3663
+ });
3664
+ }
3665
+
3666
+ test('install ends with an Action needed block naming an older global copy that shadows npx', (context) => {
3667
+ const globalPrefix = mkdtempSync(join(tmpdir(), 'cde-global-prefix-'));
3668
+ context.after(() => rmSync(globalPrefix, { recursive: true, force: true }));
3669
+ const globalPackage = process.platform === 'win32'
3670
+ ? join(globalPrefix, 'node_modules', 'claude-dev-env')
3671
+ : join(globalPrefix, 'lib', 'node_modules', 'claude-dev-env');
3672
+ mkdirSync(globalPackage, { recursive: true });
3673
+ writeFileSync(join(globalPackage, 'package.json'), JSON.stringify({ name: 'claude-dev-env', version: '0.0.1' }));
3674
+
3675
+ const result = installWithGlobalPrefix(context, globalPrefix);
3676
+
3677
+ assert.equal(result.status, 0, result.stderr);
3678
+ const actionIndex = result.stderr.lastIndexOf('Action needed (1):');
3679
+ assert.ok(actionIndex >= 0, result.stderr);
3680
+ assert.match(result.stderr.slice(actionIndex), /claude-dev-env 0\.0\.1/);
3681
+ assert.match(result.stderr.slice(actionIndex), /npm uninstall -g claude-dev-env/);
3682
+ });
3683
+
3684
+ test('install prints no Action needed block when every side step finished', (context) => {
3685
+ const result = installWithGlobalPrefix(context, null);
3686
+
3687
+ assert.equal(result.status, 0, result.stderr);
3688
+ assert.equal(result.stderr.includes('Action needed'), false, result.stderr);
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
+ });