claude-dev-env 8.34.0 → 8.35.1

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.
@@ -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
 
@@ -46,6 +52,16 @@ class TestDefaultProfileHome:
46
52
  assert profile.default_profile_home() == tmp_path / ".claude-profiles" / "ev"
47
53
 
48
54
 
55
+ class TestValidateProfileName:
56
+ def test_should_accept_letters_digits_hyphens_and_underscores(self) -> None:
57
+ assert profile.validate_profile_name("alpha_2-b") == "alpha_2-b"
58
+
59
+ @pytest.mark.parametrize("profile_name", ["bad name", "-alpha", "CON", "main", "wait"])
60
+ def test_should_refuse_an_unsafe_or_reserved_name(self, profile_name: str) -> None:
61
+ with pytest.raises(ValueError):
62
+ profile.validate_profile_name(profile_name)
63
+
64
+
49
65
  class TestIsAccountLocal:
50
66
  def test_should_keep_sign_in_state_and_history_local(self) -> None:
51
67
  for each_name in (
@@ -191,11 +207,15 @@ class TestWriteLauncher:
191
207
  ) -> None:
192
208
  profile_home = tmp_path / "ev"
193
209
  launcher_path = profile.write_launcher(
194
- 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,
195
215
  )
196
216
  launcher_text = launcher_path.read_text(encoding="utf-8")
197
217
  assert f'set "CLAUDE_CONFIG_DIR={profile_home}"' in launcher_text
198
- assert "claude %*" in launcher_text
218
+ assert "call claude %*" in launcher_text
199
219
 
200
220
  def test_should_move_an_older_launcher_aside(self, tmp_path: Path) -> None:
201
221
  launcher_directory = tmp_path / "bin"
@@ -204,13 +224,105 @@ class TestWriteLauncher:
204
224
  "old launcher", encoding="utf-8"
205
225
  )
206
226
  profile.write_launcher(
207
- 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,
208
232
  )
209
233
  moved_launcher = launcher_directory / (
210
234
  "claude-ev.cmd.replaced-" + NOW.strftime("%Y%m%dT%H%M%SZ")
211
235
  )
212
236
  assert moved_launcher.read_text(encoding="utf-8") == "old launcher"
213
237
 
238
+ def test_should_write_the_launcher_for_the_callers_program(
239
+ self, tmp_path: Path
240
+ ) -> None:
241
+ launcher_path = profile.write_launcher(
242
+ launcher_directory=tmp_path / "bin",
243
+ profile_home=tmp_path / "alpha",
244
+ now=NOW,
245
+ profile_name="alpha",
246
+ launcher_program=LauncherProgram(
247
+ program="tool",
248
+ environment_variable="TOOL_HOME",
249
+ file_name_template="tool-{profile_name}.cmd",
250
+ ),
251
+ )
252
+ assert launcher_path == tmp_path / "bin" / "tool-alpha.cmd"
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)
315
+
316
+
317
+ class TestMoveLauncherAside:
318
+ def test_should_rename_the_launcher_with_the_run_time(self, tmp_path: Path) -> None:
319
+ launcher_path = tmp_path / "tool-alpha.cmd"
320
+ launcher_path.write_text("launcher", encoding="utf-8")
321
+ moved_path = profile.move_launcher_aside(launcher_path, NOW)
322
+ assert moved_path == tmp_path / "tool-alpha.cmd.replaced-20260922T210000Z"
323
+ assert moved_path.read_text(encoding="utf-8") == "launcher"
324
+ assert not launcher_path.exists()
325
+
214
326
 
215
327
  class TestMain:
216
328
  def test_should_print_what_the_sync_changed(
@@ -4,15 +4,71 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  import os
7
+ from collections.abc import Callable, Iterator
7
8
  from datetime import datetime, timedelta, timezone
8
9
  from pathlib import Path
9
10
 
10
11
  import pytest
11
12
 
12
13
  import codex_account_choice as choice
14
+ from claude_account_profile import links_to
13
15
  from codex_account_meters import CodexAccountMeters, CodexMeterUnreadError, UsageWindow
14
16
 
15
17
  NOW = datetime(2026, 9, 23, 17, 0, tzinfo=timezone.utc)
18
+ ALL_SHARED_ENTRY_NAMES = (
19
+ "AGENTS.md",
20
+ "agents",
21
+ "config.toml",
22
+ "hooks",
23
+ "hooks.json",
24
+ "plugins",
25
+ "rules",
26
+ "skills",
27
+ )
28
+
29
+
30
+ @pytest.fixture(autouse=True)
31
+ def clear_account_roster_environment(monkeypatch: pytest.MonkeyPatch) -> None:
32
+ monkeypatch.delenv("CODEX_ACCOUNT_PROFILES", raising=False)
33
+
34
+
35
+ def build_codex_main_home(tmp_path: Path) -> Path:
36
+ main_home = tmp_path / "main"
37
+ for each_directory in ("agents", "hooks", "plugins", "rules", "skills", "sessions"):
38
+ (main_home / each_directory).mkdir(parents=True)
39
+ for each_file in ("AGENTS.md", "config.toml", "hooks.json", "auth.json", "history.jsonl"):
40
+ (main_home / each_file).write_text(each_file, encoding="utf-8")
41
+ return main_home
42
+
43
+
44
+ def save_roster(profiles_root: Path, document: object) -> None:
45
+ profiles_root.mkdir(parents=True, exist_ok=True)
46
+ (profiles_root / "account-launchers.json").write_text(
47
+ json.dumps(document), encoding="utf-8"
48
+ )
49
+
50
+
51
+ def typed_lines(*all_lines: str) -> Callable[[str], str]:
52
+ remaining_lines: Iterator[str] = iter(all_lines)
53
+
54
+ def read_line(prompt: str) -> str:
55
+ assert prompt == "Name for this Codex account launcher (blank to finish): "
56
+ try:
57
+ return next(remaining_lines)
58
+ except StopIteration as error:
59
+ raise EOFError from error
60
+
61
+ return read_line
62
+
63
+
64
+ def install(main_home: Path, tmp_path: Path, *all_names: str) -> dict[str, dict[str, object]]:
65
+ return choice.install_codex_account_launchers(
66
+ main_home=main_home,
67
+ profiles_root=tmp_path / "profiles",
68
+ launcher_directory=tmp_path / "bin",
69
+ all_names=all_names,
70
+ now=NOW,
71
+ )
16
72
 
17
73
 
18
74
  def reading(
@@ -251,7 +307,7 @@ class TestSync:
251
307
  class TestSmallHelpers:
252
308
  @pytest.mark.parametrize(
253
309
  ("entry_name", "is_local"),
254
- [("auth.json", True), ("sessions", True), ("state_5.sqlite", True), ("config.toml", False), ("plugins", False)],
310
+ [("auth.json", True), ("sessions", True), ("state_5.sqlite", True), ("config.toml", False), ("plugins", False), ("agents", False)],
255
311
  )
256
312
  def should_keep_every_entry_outside_the_shared_set_per_account(
257
313
  self, entry_name: str, is_local: bool
@@ -274,3 +330,280 @@ class TestSmallHelpers:
274
330
  payload = choice.reading_payload(reading("codex-1", weekly_used=40.0, short_used=5.0))
275
331
  assert payload["percent_left"] == 60.0
276
332
  assert [each["minutes"] for each in payload["windows"]] == [300, 10080]
333
+
334
+
335
+ class TestAccountRoster:
336
+ def should_take_the_environment_roster_over_the_saved_file(
337
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
338
+ ) -> None:
339
+ save_roster(tmp_path, ["gamma"])
340
+ monkeypatch.setenv("CODEX_ACCOUNT_PROFILES", " beta , alpha,, ")
341
+ assert choice.saved_codex_account_names(tmp_path) == ("beta", "alpha")
342
+
343
+ def should_read_the_saved_file_when_the_environment_is_unset(
344
+ self, tmp_path: Path
345
+ ) -> None:
346
+ save_roster(tmp_path, ["gamma", "alpha"])
347
+ assert choice.saved_codex_account_names(tmp_path) == ("gamma", "alpha")
348
+
349
+ def should_name_no_account_when_neither_is_set(self, tmp_path: Path) -> None:
350
+ assert choice.saved_codex_account_names(tmp_path) == ()
351
+
352
+ def should_refuse_an_invalid_name_from_the_environment(
353
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
354
+ ) -> None:
355
+ monkeypatch.setenv("CODEX_ACCOUNT_PROFILES", "alpha,bad name")
356
+ with pytest.raises(ValueError, match="CODEX_ACCOUNT_PROFILES names 'bad name'"):
357
+ choice.saved_codex_account_names(tmp_path)
358
+
359
+ @pytest.mark.parametrize("document", [["alpha", "con"], ["alpha", 3], {"alpha": 1}])
360
+ def should_refuse_a_saved_file_with_an_invalid_name(
361
+ self, tmp_path: Path, document: object
362
+ ) -> None:
363
+ save_roster(tmp_path, document)
364
+ with pytest.raises(ValueError, match="account-launchers.json"):
365
+ choice.saved_codex_account_names(tmp_path)
366
+
367
+ def should_refuse_a_saved_file_that_is_not_json(
368
+ self, tmp_path: Path, capsys: pytest.CaptureFixture[str]
369
+ ) -> None:
370
+ (tmp_path / "account-launchers.json").write_text('["alpha",', encoding="utf-8")
371
+ with pytest.raises(SystemExit) as exit_info:
372
+ choice.main(["--profiles-root", str(tmp_path), "check", "alpha"])
373
+ assert exit_info.value.code == 2
374
+ assert "account-launchers.json" in capsys.readouterr().err
375
+
376
+ def should_drop_duplicates_and_keep_the_first_order(self, tmp_path: Path) -> None:
377
+ save_roster(tmp_path, ["beta", "alpha", "beta", "gamma", "alpha"])
378
+ assert choice.saved_codex_account_names(tmp_path) == ("beta", "alpha", "gamma")
379
+
380
+ def should_save_the_roster_as_a_json_list_ending_in_a_newline(
381
+ self, tmp_path: Path
382
+ ) -> None:
383
+ profiles_root = tmp_path / "profiles"
384
+ choice.save_codex_account_names(profiles_root, ("alpha", "beta"))
385
+ assert (profiles_root / "account-launchers.json").read_bytes() == (
386
+ b'[\n "alpha",\n "beta"\n]\n'
387
+ )
388
+ assert choice.saved_codex_account_names(profiles_root) == ("alpha", "beta")
389
+
390
+ def should_fall_back_to_the_four_fixed_accounts_without_a_roster(
391
+ self, tmp_path: Path
392
+ ) -> None:
393
+ assert choice.codex_account_names(tmp_path) == (
394
+ "codex-1",
395
+ "codex-2",
396
+ "codex-3",
397
+ "codex-4",
398
+ )
399
+
400
+
401
+ class TestRosterDrivesTheJobCommands:
402
+ def should_try_the_roster_in_order_and_skip_the_fixed_accounts(
403
+ self,
404
+ tmp_path: Path,
405
+ monkeypatch: pytest.MonkeyPatch,
406
+ capsys: pytest.CaptureFixture[str],
407
+ ) -> None:
408
+ all_weekly_used = {"beta": 95.0, "alpha": 50.0, "codex-1": 0.0}
409
+ for each_name in all_weekly_used:
410
+ (tmp_path / each_name).mkdir()
411
+ (tmp_path / each_name / "auth.json").write_text("{}")
412
+ monkeypatch.setenv("CODEX_ACCOUNT_PROFILES", "beta,alpha")
413
+ monkeypatch.setattr(
414
+ choice,
415
+ "_meter_reader",
416
+ lambda codex_path: (
417
+ lambda codex_home: CodexAccountMeters(
418
+ (UsageWindow(10080, all_weekly_used[codex_home.name], None),)
419
+ )
420
+ ),
421
+ )
422
+ assert choice.main(["--profiles-root", str(tmp_path), "choose"]) == 0
423
+ payload = json.loads(capsys.readouterr().out)
424
+ assert (payload["tier"], payload["account"]) == ("normal", "alpha")
425
+ assert [each["name"] for each in payload["accounts"]] == ["beta", "alpha"]
426
+
427
+ def should_choose_among_the_four_fixed_accounts_without_a_roster(
428
+ self,
429
+ tmp_path: Path,
430
+ monkeypatch: pytest.MonkeyPatch,
431
+ capsys: pytest.CaptureFixture[str],
432
+ ) -> None:
433
+ monkeypatch.setattr(choice, "_meter_reader", lambda codex_path: None)
434
+ assert choice.main(["--profiles-root", str(tmp_path), "choose"]) == 0
435
+ payload = json.loads(capsys.readouterr().out)
436
+ assert [each["name"] for each in payload["accounts"]] == [
437
+ "codex-1",
438
+ "codex-2",
439
+ "codex-3",
440
+ "codex-4",
441
+ ]
442
+
443
+ def should_sync_only_the_roster_accounts(
444
+ self,
445
+ tmp_path: Path,
446
+ monkeypatch: pytest.MonkeyPatch,
447
+ capsys: pytest.CaptureFixture[str],
448
+ ) -> None:
449
+ main_home = build_codex_main_home(tmp_path)
450
+ profiles_root = tmp_path / "profiles"
451
+ monkeypatch.setenv("CODEX_ACCOUNT_PROFILES", "alpha")
452
+ all_arguments = ["--profiles-root", str(profiles_root), "sync"]
453
+ assert choice.main([*all_arguments, "--main-home", str(main_home)]) == 0
454
+ assert list(json.loads(capsys.readouterr().out)) == ["alpha"]
455
+ assert links_to(profiles_root / "alpha" / "agents", main_home / "agents")
456
+ assert not (profiles_root / "codex-1").exists()
457
+
458
+ def should_check_a_saved_roster_account(
459
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
460
+ ) -> None:
461
+ save_roster(tmp_path, ["alpha"])
462
+ (tmp_path / "alpha").mkdir()
463
+ (tmp_path / "alpha" / "auth.json").write_text("{}")
464
+ monkeypatch.setattr(
465
+ choice,
466
+ "_meter_reader",
467
+ lambda codex_path: (
468
+ lambda codex_home: CodexAccountMeters((UsageWindow(10080, 40.0, None),))
469
+ ),
470
+ )
471
+ assert choice.main(["--profiles-root", str(tmp_path), "check", "alpha"]) == 0
472
+
473
+ @pytest.mark.parametrize("account", ["gamma", "codex-1"])
474
+ def should_refuse_to_check_an_account_outside_the_roster(
475
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, account: str
476
+ ) -> None:
477
+ save_roster(tmp_path, ["alpha"])
478
+ monkeypatch.setattr(choice, "_meter_reader", lambda codex_path: None)
479
+ with pytest.raises(SystemExit) as exit_info:
480
+ choice.main(["--profiles-root", str(tmp_path), "check", account])
481
+ assert exit_info.value.code == 2
482
+
483
+
484
+ class TestInstallLaunchers:
485
+ def should_link_the_shared_setup_and_keep_each_sign_in_per_account(
486
+ self, tmp_path: Path
487
+ ) -> None:
488
+ main_home = build_codex_main_home(tmp_path)
489
+ report = install(main_home, tmp_path, "alpha", "beta")
490
+ assert list(report) == ["alpha", "beta"]
491
+ for each_name in ("alpha", "beta"):
492
+ each_home = tmp_path / "profiles" / each_name
493
+ for each_entry in ALL_SHARED_ENTRY_NAMES:
494
+ assert links_to(each_home / each_entry, main_home / each_entry)
495
+ for each_local_entry in ("auth.json", "history.jsonl", "sessions"):
496
+ assert not os.path.lexists(each_home / each_local_entry)
497
+ assert report[each_name]["launcher"] == str(
498
+ tmp_path / "bin" / f"codex-{each_name}.cmd"
499
+ )
500
+
501
+ def should_change_nothing_on_a_second_run(self, tmp_path: Path) -> None:
502
+ main_home = build_codex_main_home(tmp_path)
503
+ install(main_home, tmp_path, "alpha")
504
+ launcher_path = tmp_path / "bin" / "codex-alpha.cmd"
505
+ first_launcher_bytes = launcher_path.read_bytes()
506
+ second_report = install(main_home, tmp_path, "alpha")
507
+ assert second_report["alpha"]["linked"] == []
508
+ assert second_report["alpha"]["moved_aside"] == []
509
+ assert second_report["alpha"]["unlinked"] == []
510
+ assert launcher_path.read_bytes() == first_launcher_bytes
511
+ assert sorted(each.name for each in (tmp_path / "bin").iterdir()) == [
512
+ "codex-alpha.cmd"
513
+ ]
514
+
515
+ def should_set_codex_home_and_call_codex_in_the_launcher(
516
+ self, tmp_path: Path
517
+ ) -> None:
518
+ install(build_codex_main_home(tmp_path), tmp_path, "alpha")
519
+ launcher_lines = (tmp_path / "bin" / "codex-alpha.cmd").read_bytes().split(b"\r\n")
520
+ profile_home = tmp_path / "profiles" / "alpha"
521
+ assert f'set "CODEX_HOME={profile_home}"'.encode() in launcher_lines
522
+ assert b"call codex %*" in launcher_lines
523
+
524
+ def should_install_the_environment_roster_from_the_command_line(
525
+ self,
526
+ tmp_path: Path,
527
+ monkeypatch: pytest.MonkeyPatch,
528
+ capsys: pytest.CaptureFixture[str],
529
+ ) -> None:
530
+ main_home = build_codex_main_home(tmp_path)
531
+ save_roster(tmp_path / "profiles", ["gamma"])
532
+ monkeypatch.setenv("CODEX_ACCOUNT_PROFILES", "beta")
533
+ all_arguments = [
534
+ "--profiles-root",
535
+ str(tmp_path / "profiles"),
536
+ "install",
537
+ "--main-home",
538
+ str(main_home),
539
+ "--launcher-directory",
540
+ str(tmp_path / "bin"),
541
+ ]
542
+ assert choice.main(all_arguments) == 0
543
+ assert list(json.loads(capsys.readouterr().out)) == ["beta"]
544
+ assert (tmp_path / "bin" / "codex-beta.cmd").is_file()
545
+ assert not (tmp_path / "bin" / "codex-gamma.cmd").exists()
546
+
547
+ def should_print_an_empty_report_for_an_empty_roster(
548
+ self, tmp_path: Path, capsys: pytest.CaptureFixture[str]
549
+ ) -> None:
550
+ all_arguments = ["--profiles-root", str(tmp_path / "profiles"), "install"]
551
+ assert choice.main([*all_arguments, "--launcher-directory", str(tmp_path / "bin")]) == 0
552
+ assert json.loads(capsys.readouterr().out) == {}
553
+ assert not (tmp_path / "bin").exists()
554
+
555
+
556
+ class TestSetupRoster:
557
+ def should_save_each_valid_name_once_in_typed_order(self, tmp_path: Path) -> None:
558
+ all_shown: list[str] = []
559
+ all_names, all_retired = choice.update_codex_account_roster(
560
+ profiles_root=tmp_path,
561
+ launcher_directory=tmp_path / "bin",
562
+ now=NOW,
563
+ read_line=typed_lines("alpha", "bad name", "alpha", "beta", "", "gamma"),
564
+ write_line=all_shown.append,
565
+ )
566
+ assert (all_names, all_retired) == (("alpha", "beta"), {})
567
+ assert choice.saved_codex_account_names(tmp_path) == ("alpha", "beta")
568
+ assert all_shown == [
569
+ "Saved Codex account launchers: none",
570
+ "profile name must use letters, digits, hyphens, or underscores",
571
+ ]
572
+
573
+ def should_finish_at_the_end_of_input(self, tmp_path: Path) -> None:
574
+ all_names, _ = choice.update_codex_account_roster(
575
+ profiles_root=tmp_path,
576
+ launcher_directory=tmp_path / "bin",
577
+ now=NOW,
578
+ read_line=typed_lines("gamma"),
579
+ write_line=lambda line: None,
580
+ )
581
+ assert all_names == ("gamma",)
582
+
583
+ def should_move_a_dropped_launcher_aside_and_keep_its_home(
584
+ self, tmp_path: Path
585
+ ) -> None:
586
+ main_home = build_codex_main_home(tmp_path)
587
+ profiles_root = tmp_path / "profiles"
588
+ choice.save_codex_account_names(profiles_root, ("alpha", "beta"))
589
+ install(main_home, tmp_path, "alpha", "beta")
590
+ (profiles_root / "beta" / "auth.json").write_text("beta sign-in", encoding="utf-8")
591
+ all_shown: list[str] = []
592
+ all_names, all_retired = choice.update_codex_account_roster(
593
+ profiles_root=profiles_root,
594
+ launcher_directory=tmp_path / "bin",
595
+ now=NOW,
596
+ read_line=typed_lines("alpha", ""),
597
+ write_line=all_shown.append,
598
+ )
599
+ moved_launcher = tmp_path / "bin" / "codex-beta.cmd.replaced-20260923T170000Z"
600
+ assert all_names == ("alpha",)
601
+ assert all_retired == {"beta": str(moved_launcher)}
602
+ assert all_shown == ["Saved Codex account launchers: alpha, beta"]
603
+ assert moved_launcher.is_file()
604
+ assert not (tmp_path / "bin" / "codex-beta.cmd").exists()
605
+ assert (tmp_path / "bin" / "codex-alpha.cmd").is_file()
606
+ assert (profiles_root / "beta" / "auth.json").read_text(encoding="utf-8") == (
607
+ "beta sign-in"
608
+ )
609
+ assert choice.saved_codex_account_names(profiles_root) == ("alpha",)