claude-dev-env 8.35.0 → 8.35.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/install.mjs CHANGED
@@ -76,6 +76,7 @@ import {
76
76
  } from './resolve-package-managed-directory.mjs';
77
77
  import {
78
78
  ensureDirectoryPointer,
79
+ POINTER_ACTION_CREATED,
79
80
  } from './publish-directory-pointer.mjs';
80
81
  import {
81
82
  parseInstallTargetSelectionFromArgv,
@@ -872,7 +873,11 @@ function owningManagedRoot(installedFilePath) {
872
873
  * so those two paths are pointers. A real directory from an older install at a
873
874
  * lookup path is merged into the agents home before the pointer is written.
874
875
  *
875
- * @returns {string[]} The lookup paths this run published.
876
+ * A pointer that already aimed at its target is left out of the result, so a
877
+ * rollback that removes this run's written paths keeps the pointer a prior
878
+ * install published.
879
+ *
880
+ * @returns {string[]} The lookup paths this run created.
876
881
  */
877
882
  function publishManagedLookupPointers() {
878
883
  const pointerPairs = [
@@ -897,10 +902,10 @@ function publishManagedLookupPointers() {
897
902
  INSTALL_ROOT_RESOLUTION.hooksInstallDirectory,
898
903
  ],
899
904
  ];
900
- for (const [pointerPath, targetPath] of pointerPairs) {
901
- ensureDirectoryPointer(pointerPath, targetPath);
902
- }
903
- return pointerPairs.map(([pointerPath]) => pointerPath);
905
+ return pointerPairs
906
+ .map(([pointerPath, targetPath]) => ensureDirectoryPointer(pointerPath, targetPath))
907
+ .filter((pointerResult) => pointerResult.action === POINTER_ACTION_CREATED)
908
+ .map((pointerResult) => pointerResult.pointerPath);
904
909
  }
905
910
 
906
911
  /**
@@ -6,9 +6,11 @@ import { test } from 'node:test';
6
6
  import { strict as assert } from 'node:assert';
7
7
  import {
8
8
  existsSync,
9
+ lstatSync,
9
10
  mkdtempSync,
10
11
  mkdirSync,
11
12
  readFileSync,
13
+ realpathSync,
12
14
  rmSync,
13
15
  writeFileSync,
14
16
  } from 'node:fs';
@@ -501,3 +503,44 @@ test('successful install after prior install leaves one manifest and no fault',
501
503
  rmSync(homeDirectory, { recursive: true, force: true });
502
504
  }
503
505
  });
506
+
507
+ test('installer fault keeps the lookup pointers a prior install published', () => {
508
+ const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-txn-e2e-pointers-'));
509
+ try {
510
+ writeFileSync(join(homeDirectory, '.gitconfig'), '');
511
+ const homeOnlyEnvironment = { CLAUDE_CONFIG_DIR: '' };
512
+ const okRun = runInstaller(homeDirectory, ['--only', 'core'], {
513
+ environment: homeOnlyEnvironment,
514
+ });
515
+ assert.equal(okRun.status, 0, okRun.stdout + okRun.stderr);
516
+ const allPointerTargets = [
517
+ ...['skills', 'agents', 'hooks', 'scripts'].map((name) => ({
518
+ pointerPath: join(homeDirectory, '.claude', name),
519
+ targetPath: realpathSync(join(homeDirectory, '.agents', name)),
520
+ })),
521
+ {
522
+ pointerPath: join(homeDirectory, '.codex', 'hooks'),
523
+ targetPath: realpathSync(join(homeDirectory, '.agents', 'hooks')),
524
+ },
525
+ ];
526
+
527
+ for (const faultPhase of [FAULT_PHASES.AFTER_FILE_STAGING, FAULT_PHASES.AFTER_MANIFEST_WRITE]) {
528
+ const failedRun = runInstaller(homeDirectory, ['--only', 'core'], {
529
+ faultPhase,
530
+ environment: homeOnlyEnvironment,
531
+ });
532
+ assert.notEqual(failedRun.status, 0, failedRun.stdout + failedRun.stderr);
533
+ assert.match(`${failedRun.stdout}${failedRun.stderr}`, /prior installation restored/);
534
+ for (const { pointerPath, targetPath } of allPointerTargets) {
535
+ assert.equal(
536
+ lstatSync(pointerPath, { throwIfNoEntry: false })?.isSymbolicLink(),
537
+ true,
538
+ `${pointerPath} survives a fault at ${faultPhase}`,
539
+ );
540
+ assert.equal(realpathSync(pointerPath), targetPath);
541
+ }
542
+ }
543
+ } finally {
544
+ rmSync(homeDirectory, { recursive: true, force: true });
545
+ }
546
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.35.0",
3
+ "version": "8.35.2",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,14 +28,15 @@ from dev_env_scripts_constants.claude_account_constants import (
28
28
  ALL_WINDOWS_JUNCTION_COMMAND_PREFIX,
29
29
  CHOICE_MAIN,
30
30
  CHOICE_WAIT,
31
+ CLAUDE_LAUNCHER_PROGRAM,
31
32
  JSON_LAUNCHER_KEY,
32
33
  JSON_LINKED_KEY,
33
34
  JSON_MOVED_ASIDE_KEY,
34
35
  JSON_UNLINKED_KEY,
35
36
  ALL_LAUNCHER_DIRECTORY_RELATIVE_PARTS,
36
- LAUNCHER_FILE_NAME_TEMPLATE,
37
+ LAUNCHER_BODY_TEMPLATE,
37
38
  LAUNCHER_REPLACED_SUFFIX,
38
- LAUNCHER_TEXT_TEMPLATE,
39
+ LauncherProgram,
39
40
  MAIN_CLAUDE_HOME_DIRECTORY_NAME,
40
41
  PROFILES_ROOT_DIRECTORY_NAME,
41
42
  PROFILES_ROOT_ENVIRONMENT_VARIABLE,
@@ -343,16 +344,14 @@ def move_launcher_aside(launcher_path: Path, now: datetime) -> Path:
343
344
 
344
345
 
345
346
  def launcher_path(
346
- launcher_directory: Path,
347
- profile_name: str,
348
- launcher_file_name_template: str = LAUNCHER_FILE_NAME_TEMPLATE,
347
+ launcher_directory: Path, profile_name: str, launcher_program: LauncherProgram
349
348
  ) -> Path:
350
349
  """Name the launcher file for a profile.
351
350
 
352
351
  Args:
353
352
  launcher_directory: The directory on PATH that holds the launcher.
354
353
  profile_name: The name used in the launcher file name.
355
- launcher_file_name_template: The launcher file name, with ``{profile_name}``.
354
+ launcher_program: The program the launcher runs, which names the file.
356
355
 
357
356
  Returns:
358
357
  The launcher path.
@@ -360,7 +359,7 @@ def launcher_path(
360
359
  Raises:
361
360
  ValueError: When the profile name is not a valid profile name.
362
361
  """
363
- return launcher_directory / launcher_file_name_template.format(
362
+ return launcher_directory / launcher_program.file_name_template.format(
364
363
  profile_name=validate_profile_name(profile_name)
365
364
  )
366
365
 
@@ -370,33 +369,33 @@ def write_launcher(
370
369
  launcher_directory: Path,
371
370
  profile_home: Path,
372
371
  now: datetime,
373
- profile_name: str = SECOND_ACCOUNT_PROFILE_NAME,
374
- launcher_file_name_template: str = LAUNCHER_FILE_NAME_TEMPLATE,
375
- launcher_text_template: str = LAUNCHER_TEXT_TEMPLATE,
372
+ profile_name: str,
373
+ launcher_program: LauncherProgram,
376
374
  ) -> Path:
377
- """Write the launcher that runs Claude under a named profile.
375
+ """Write the launcher that runs a program under a named profile.
378
376
 
379
377
  ::
380
378
 
381
379
  claude-NAME -p "fix the test"
382
- -> CLAUDE_CONFIG_DIR=<profile home>, then claude -p "fix the test"
380
+ -> CLAUDE_CONFIG_DIR=<profile home>, then call claude -p "fix the test"
383
381
  an older claude-NAME.cmd -> claude-NAME.cmd.replaced-<time>
384
382
 
385
383
  Args:
386
384
  launcher_directory: The directory on PATH that holds the launcher.
387
- profile_home: The named account's Claude home.
385
+ profile_home: The named account's home for the program.
388
386
  now: The run time that names a moved older launcher.
389
387
  profile_name: The name used in the launcher file name.
390
- launcher_file_name_template: The launcher file name, with ``{profile_name}``.
391
- launcher_text_template: The launcher body, with ``{profile_home}``.
388
+ launcher_program: The program to run and the variable naming its home.
392
389
 
393
390
  Returns:
394
391
  The launcher path.
395
392
  """
396
- target_path = launcher_path(
397
- launcher_directory, profile_name, launcher_file_name_template
393
+ target_path = launcher_path(launcher_directory, profile_name, launcher_program)
394
+ launcher_text = LAUNCHER_BODY_TEMPLATE.format(
395
+ environment_variable=launcher_program.environment_variable,
396
+ profile_home=profile_home,
397
+ program=launcher_program.program,
398
398
  )
399
- launcher_text = launcher_text_template.format(profile_home=profile_home)
400
399
  launcher_bytes = launcher_text.encode(TEXT_ENCODING)
401
400
  if target_path.is_file() and target_path.read_bytes() == launcher_bytes:
402
401
  return target_path
@@ -454,6 +453,7 @@ def _sync_named_profile(arguments: argparse.Namespace) -> dict[str, object]:
454
453
  profile_home=profile_home,
455
454
  now=now,
456
455
  profile_name=arguments.profile_name,
456
+ launcher_program=CLAUDE_LAUNCHER_PROGRAM,
457
457
  )
458
458
  return {**sync_report_payload(report), JSON_LAUNCHER_KEY: str(launcher_path)}
459
459
 
@@ -71,8 +71,7 @@ from dev_env_scripts_constants.codex_account_constants import (
71
71
  CODEX_ACCOUNT_NAME_SEPARATOR,
72
72
  CODEX_ACCOUNT_PROFILES_ENVIRONMENT_VARIABLE,
73
73
  CODEX_AUTH_FILE_NAME,
74
- CODEX_LAUNCHER_FILE_NAME_TEMPLATE,
75
- CODEX_LAUNCHER_TEXT_TEMPLATE,
74
+ CODEX_LAUNCHER_PROGRAM,
76
75
  CODEX_PROFILES_ROOT_DIRECTORY_NAME,
77
76
  CODEX_PROFILES_ROOT_ENVIRONMENT_VARIABLE,
78
77
  COMMAND_INSTALL,
@@ -285,8 +284,7 @@ def _install_account(
285
284
  profile_home=profiles_root / name,
286
285
  now=now,
287
286
  profile_name=name,
288
- launcher_file_name_template=CODEX_LAUNCHER_FILE_NAME_TEMPLATE,
289
- launcher_text_template=CODEX_LAUNCHER_TEXT_TEMPLATE,
287
+ launcher_program=CODEX_LAUNCHER_PROGRAM,
290
288
  )
291
289
  return {**sync_payload, JSON_LAUNCHER_KEY: str(launcher_path)}
292
290
 
@@ -338,9 +336,7 @@ def _retire_codex_account_launchers(
338
336
  """
339
337
  all_retired: dict[str, str] = {}
340
338
  for each_name in all_names:
341
- retired_path = launcher_path(
342
- launcher_directory, each_name, CODEX_LAUNCHER_FILE_NAME_TEMPLATE
343
- )
339
+ retired_path = launcher_path(launcher_directory, each_name, CODEX_LAUNCHER_PROGRAM)
344
340
  if retired_path.is_file():
345
341
  all_retired[each_name] = str(move_launcher_aside(retired_path, now))
346
342
  return all_retired
@@ -8,8 +8,11 @@ main account so its owner never runs out.
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ from dataclasses import dataclass
11
12
  from datetime import timedelta
12
13
 
14
+ from dev_env_scripts_constants.shared_tree_constants import CLAUDE_CONFIG_DIR_ENV_VAR
15
+
13
16
  MAIN_CLAUDE_HOME_DIRECTORY_NAME: str = ".claude"
14
17
  """Directory under the user home that holds the main account's Claude home."""
15
18
 
@@ -38,22 +41,39 @@ ALL_WINDOWS_RESERVED_PROFILE_NAMES: frozenset[str] = frozenset(
38
41
  ALL_LAUNCHER_DIRECTORY_RELATIVE_PARTS: tuple[str, ...] = (".local", "bin")
39
42
  """Path parts under the user home of the directory on PATH that holds the launcher."""
40
43
 
41
- LAUNCHER_FILE_NAME_TEMPLATE: str = "claude-{profile_name}.cmd"
42
- """File name pattern for a named profile launcher."""
43
-
44
- LAUNCHER_FILE_NAME: str = LAUNCHER_FILE_NAME_TEMPLATE.format(
45
- profile_name=SECOND_ACCOUNT_PROFILE_NAME
46
- )
47
- """File name of the launcher that runs Claude under the second account."""
44
+ CLAUDE_BINARY_NAME: str = "claude"
45
+ """Claude command name looked up on PATH."""
48
46
 
49
- LAUNCHER_TEXT_TEMPLATE: str = (
47
+ LAUNCHER_BODY_TEMPLATE: str = (
50
48
  "@echo off\r\n"
51
49
  "setlocal\r\n"
52
- 'set "CLAUDE_CONFIG_DIR={profile_home}"\r\n'
53
- "claude %*\r\n"
50
+ 'set "{environment_variable}={profile_home}"\r\n'
51
+ "call {program} %*\r\n"
54
52
  "exit /b %ERRORLEVEL%\r\n"
55
53
  )
56
- """Launcher body: point Claude at the profile and pass every argument through."""
54
+ """Launcher body: point a program at the profile home and pass every argument through.
55
+
56
+ The program resolves to npm's ``<program>.cmd``, whose last line runs ``endLocal``
57
+ and a ``goto`` to a missing label. Chained into without ``call``, that line ends
58
+ this launcher's ``setlocal`` too, and the program starts without the variable.
59
+ """
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class LauncherProgram:
64
+ """The program a profile launcher runs and the variable naming its home."""
65
+
66
+ program: str
67
+ environment_variable: str
68
+ file_name_template: str
69
+
70
+
71
+ CLAUDE_LAUNCHER_PROGRAM: LauncherProgram = LauncherProgram(
72
+ program=CLAUDE_BINARY_NAME,
73
+ environment_variable=CLAUDE_CONFIG_DIR_ENV_VAR,
74
+ file_name_template="claude-{profile_name}.cmd",
75
+ )
76
+ """Launcher that runs Claude with ``CLAUDE_CONFIG_DIR`` set to the profile home."""
57
77
 
58
78
  LAUNCHER_REPLACED_SUFFIX: str = ".replaced-"
59
79
  """Suffix, before the run time, of an older launcher the sync moved aside."""
@@ -6,6 +6,7 @@ from dev_env_scripts_constants.claude_account_constants import (
6
6
  CHOICE_MAIN,
7
7
  CHOICE_SECOND,
8
8
  CHOICE_WAIT,
9
+ CLAUDE_BINARY_NAME,
9
10
  CREDENTIALS_FILE_NAME,
10
11
  EXTRA_PROFILES_FILE_NAME,
11
12
  JSON_ACCOUNT_KEY,
@@ -15,8 +16,6 @@ from dev_env_scripts_constants.claude_account_constants import (
15
16
  )
16
17
  from dev_env_scripts_constants.shared_tree_constants import CLAUDE_CONFIG_DIR_ENV_VAR
17
18
 
18
- CLAUDE_BINARY_NAME: str = "claude"
19
-
20
19
  DEFAULT_PERMISSION_MODE: str = "auto"
21
20
 
22
21
  DEFAULT_TIMEOUT_MINUTES: int = 60
@@ -7,6 +7,8 @@ a job runs on, trying the accounts in roster order.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ from dev_env_scripts_constants.claude_account_constants import LauncherProgram
11
+
10
12
  ALL_CODEX_ACCOUNT_NAMES: tuple[str, ...] = ("codex-1", "codex-2", "codex-3", "codex-4")
11
13
  """Fallback Codex accounts, in try order, when no account roster is saved or set."""
12
14
 
@@ -19,23 +21,6 @@ CODEX_ACCOUNT_LAUNCHERS_FILE_NAME: str = "account-launchers.json"
19
21
  CODEX_ACCOUNT_NAME_SEPARATOR: str = ","
20
22
  """Separator between account names in the roster environment variable."""
21
23
 
22
- CODEX_LAUNCHER_FILE_NAME_TEMPLATE: str = "codex-{profile_name}.cmd"
23
- """File name pattern for one account's Codex launcher."""
24
-
25
- CODEX_LAUNCHER_TEXT_TEMPLATE: str = (
26
- "@echo off\r\n"
27
- "setlocal\r\n"
28
- 'set "CODEX_HOME={profile_home}"\r\n'
29
- "call codex %*\r\n"
30
- "exit /b %ERRORLEVEL%\r\n"
31
- )
32
- """Launcher body: point Codex at the account's home and pass every argument through.
33
-
34
- ``codex`` resolves to npm's ``codex.cmd``, whose last line runs ``endLocal`` and a
35
- ``goto`` to a missing label. Chained into without ``call``, that line ends this
36
- launcher's ``setlocal`` too, and Codex starts without ``CODEX_HOME``.
37
- """
38
-
39
24
  SETUP_PROMPT_TEXT: str = "Name for this Codex account launcher (blank to finish): "
40
25
  """Prompt the setup command shows for each account name."""
41
26
 
@@ -134,6 +119,13 @@ ALL_CODEX_BINARY_CANDIDATE_RELATIVE_PARTS: tuple[tuple[str, ...], ...] = (
134
119
  CODEX_BINARY_NAME: str = "codex"
135
120
  """Codex command name looked up on PATH."""
136
121
 
122
+ CODEX_LAUNCHER_PROGRAM: LauncherProgram = LauncherProgram(
123
+ program=CODEX_BINARY_NAME,
124
+ environment_variable=CODEX_HOME_ENVIRONMENT_VARIABLE,
125
+ file_name_template="codex-{profile_name}.cmd",
126
+ )
127
+ """Launcher that runs Codex with ``CODEX_HOME`` set to the account's home."""
128
+
137
129
  ALL_APP_SERVER_ARGUMENTS: tuple[str, ...] = ("app-server", "--listen", "stdio://")
138
130
  """Arguments that start Codex as a JSON-RPC server on standard input and output."""
139
131
 
@@ -11,7 +11,6 @@ from dev_env_scripts_constants.claude_account_constants import (
11
11
  JSON_SESSION_USED_PERCENT_KEY,
12
12
  JSON_WEEKLY_RESETS_AT_KEY,
13
13
  JSON_WEEKLY_USED_PERCENT_KEY,
14
- LAUNCHER_TEXT_TEMPLATE,
15
14
  MAIN_SESSION_USED_CEILING_PERCENT,
16
15
  MAIN_WEEKLY_USED_CEILING_PERCENT,
17
16
  SECOND_SESSION_USED_CEILING_PERCENT,
@@ -24,11 +23,6 @@ def test_main_ceilings_sit_below_the_second_account_ceilings() -> None:
24
23
  assert MAIN_SESSION_USED_CEILING_PERCENT < SECOND_SESSION_USED_CEILING_PERCENT
25
24
 
26
25
 
27
- def test_launcher_names_the_profile_and_passes_every_argument() -> None:
28
- assert "{profile_home}" in LAUNCHER_TEXT_TEMPLATE
29
- assert "claude %*" in LAUNCHER_TEXT_TEMPLATE
30
-
31
-
32
26
  def test_picker_report_keys_never_collide() -> None:
33
27
  all_report_keys = [
34
28
  JSON_ACCOUNT_KEY,
@@ -4,12 +4,18 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  import os
7
+ import subprocess
7
8
  from datetime import datetime, timezone
8
9
  from pathlib import Path
9
10
 
10
11
  import pytest
11
12
 
12
13
  import claude_account_profile as profile
14
+ from dev_env_scripts_constants.claude_account_constants import (
15
+ CLAUDE_LAUNCHER_PROGRAM,
16
+ LauncherProgram,
17
+ )
18
+ from dev_env_scripts_constants.codex_account_constants import CODEX_LAUNCHER_PROGRAM
13
19
 
14
20
  NOW = datetime(2026, 9, 22, 21, 0, tzinfo=timezone.utc)
15
21
 
@@ -201,11 +207,15 @@ class TestWriteLauncher:
201
207
  ) -> None:
202
208
  profile_home = tmp_path / "ev"
203
209
  launcher_path = profile.write_launcher(
204
- launcher_directory=tmp_path / "bin", profile_home=profile_home, now=NOW
210
+ launcher_directory=tmp_path / "bin",
211
+ profile_home=profile_home,
212
+ now=NOW,
213
+ profile_name="ev",
214
+ launcher_program=CLAUDE_LAUNCHER_PROGRAM,
205
215
  )
206
216
  launcher_text = launcher_path.read_text(encoding="utf-8")
207
217
  assert f'set "CLAUDE_CONFIG_DIR={profile_home}"' in launcher_text
208
- assert "claude %*" in launcher_text
218
+ assert "call claude %*" in launcher_text
209
219
 
210
220
  def test_should_move_an_older_launcher_aside(self, tmp_path: Path) -> None:
211
221
  launcher_directory = tmp_path / "bin"
@@ -214,14 +224,18 @@ class TestWriteLauncher:
214
224
  "old launcher", encoding="utf-8"
215
225
  )
216
226
  profile.write_launcher(
217
- launcher_directory=launcher_directory, profile_home=tmp_path / "ev", now=NOW
227
+ launcher_directory=launcher_directory,
228
+ profile_home=tmp_path / "ev",
229
+ now=NOW,
230
+ profile_name="ev",
231
+ launcher_program=CLAUDE_LAUNCHER_PROGRAM,
218
232
  )
219
233
  moved_launcher = launcher_directory / (
220
234
  "claude-ev.cmd.replaced-" + NOW.strftime("%Y%m%dT%H%M%SZ")
221
235
  )
222
236
  assert moved_launcher.read_text(encoding="utf-8") == "old launcher"
223
237
 
224
- def test_should_write_the_launcher_from_the_callers_templates(
238
+ def test_should_write_the_launcher_for_the_callers_program(
225
239
  self, tmp_path: Path
226
240
  ) -> None:
227
241
  launcher_path = profile.write_launcher(
@@ -229,11 +243,75 @@ class TestWriteLauncher:
229
243
  profile_home=tmp_path / "alpha",
230
244
  now=NOW,
231
245
  profile_name="alpha",
232
- launcher_file_name_template="tool-{profile_name}.cmd",
233
- launcher_text_template="home={profile_home}",
246
+ launcher_program=LauncherProgram(
247
+ program="tool",
248
+ environment_variable="TOOL_HOME",
249
+ file_name_template="tool-{profile_name}.cmd",
250
+ ),
234
251
  )
235
252
  assert launcher_path == tmp_path / "bin" / "tool-alpha.cmd"
236
- assert launcher_path.read_text(encoding="utf-8") == f"home={tmp_path / 'alpha'}"
253
+ assert launcher_path.read_bytes() == (
254
+ "@echo off\r\n"
255
+ "setlocal\r\n"
256
+ f'set "TOOL_HOME={tmp_path / "alpha"}"\r\n'
257
+ "call tool %*\r\n"
258
+ "exit /b %ERRORLEVEL%\r\n"
259
+ ).encode()
260
+
261
+
262
+ NPM_SHIM_TEXT_TEMPLATE = (
263
+ "@ECHO off\r\n"
264
+ "GOTO start\r\n"
265
+ ":find_dp0\r\n"
266
+ "SET dp0=%~dp0\r\n"
267
+ "EXIT /b\r\n"
268
+ ":start\r\n"
269
+ "SETLOCAL\r\n"
270
+ "CALL :find_dp0\r\n"
271
+ 'SET "_prog=cmd"\r\n'
272
+ "endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% &"
273
+ ' "%_prog%" /d /c "echo %%{environment_variable}%%"\r\n'
274
+ )
275
+
276
+
277
+ class TestNpmShimLauncher:
278
+ @pytest.mark.skipif(os.name != "nt", reason="runs the launcher through cmd.exe")
279
+ @pytest.mark.parametrize(
280
+ "launcher_program",
281
+ [
282
+ pytest.param(CLAUDE_LAUNCHER_PROGRAM, id="claude"),
283
+ pytest.param(CODEX_LAUNCHER_PROGRAM, id="codex"),
284
+ ],
285
+ )
286
+ def should_hand_the_profile_home_to_the_npm_shim(
287
+ self, tmp_path: Path, launcher_program: LauncherProgram
288
+ ) -> None:
289
+ profile_home = tmp_path / "profiles" / "alpha"
290
+ launcher_path = profile.write_launcher(
291
+ launcher_directory=tmp_path / "bin",
292
+ profile_home=profile_home,
293
+ now=NOW,
294
+ profile_name="alpha",
295
+ launcher_program=launcher_program,
296
+ )
297
+ fake_directory = tmp_path / "fake"
298
+ fake_directory.mkdir()
299
+ (fake_directory / f"{launcher_program.program}.cmd").write_bytes(
300
+ NPM_SHIM_TEXT_TEMPLATE.format(
301
+ environment_variable=launcher_program.environment_variable
302
+ ).encode("utf-8")
303
+ )
304
+ environment = dict(os.environ)
305
+ environment["PATH"] = str(fake_directory) + os.pathsep + environment["PATH"]
306
+ environment[launcher_program.environment_variable] = str(tmp_path / "decoy")
307
+ completed = subprocess.run(
308
+ ["cmd", "/c", str(launcher_path)],
309
+ env=environment,
310
+ capture_output=True,
311
+ text=True,
312
+ check=False,
313
+ )
314
+ assert completed.stdout.strip() == str(profile_home)
237
315
 
238
316
 
239
317
  class TestMoveLauncherAside:
@@ -4,7 +4,6 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  import os
7
- import subprocess
8
7
  from collections.abc import Callable, Iterator
9
8
  from datetime import datetime, timedelta, timezone
10
9
  from pathlib import Path
@@ -26,19 +25,6 @@ ALL_SHARED_ENTRY_NAMES = (
26
25
  "rules",
27
26
  "skills",
28
27
  )
29
- NPM_CODEX_SHIM_TEXT = (
30
- "@ECHO off\r\n"
31
- "GOTO start\r\n"
32
- ":find_dp0\r\n"
33
- "SET dp0=%~dp0\r\n"
34
- "EXIT /b\r\n"
35
- ":start\r\n"
36
- "SETLOCAL\r\n"
37
- "CALL :find_dp0\r\n"
38
- 'SET "_prog=cmd"\r\n'
39
- "endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% &"
40
- ' "%_prog%" /d /c "echo %%CODEX_HOME%%"\r\n'
41
- )
42
28
 
43
29
 
44
30
  @pytest.fixture(autouse=True)
@@ -621,24 +607,3 @@ class TestSetupRoster:
621
607
  "beta sign-in"
622
608
  )
623
609
  assert choice.saved_codex_account_names(profiles_root) == ("alpha",)
624
-
625
-
626
- class TestCodexLauncher:
627
- @pytest.mark.skipif(os.name != "nt", reason="runs the launcher through cmd.exe")
628
- def should_hand_codex_home_to_the_npm_codex_shim(self, tmp_path: Path) -> None:
629
- install(build_codex_main_home(tmp_path), tmp_path, "alpha")
630
- launcher_path = tmp_path / "bin" / "codex-alpha.cmd"
631
- fake_directory = tmp_path / "fake"
632
- fake_directory.mkdir()
633
- (fake_directory / "codex.cmd").write_bytes(NPM_CODEX_SHIM_TEXT.encode("utf-8"))
634
- environment = dict(os.environ)
635
- environment["PATH"] = str(fake_directory) + os.pathsep + environment["PATH"]
636
- environment["CODEX_HOME"] = str(tmp_path / "decoy")
637
- completed = subprocess.run(
638
- ["cmd", "/c", str(launcher_path)],
639
- env=environment,
640
- capture_output=True,
641
- text=True,
642
- check=False,
643
- )
644
- assert completed.stdout.strip() == str(tmp_path / "profiles" / "alpha")