claude-dev-env 8.33.2 → 8.35.0

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.
@@ -4,15 +4,85 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  import os
7
+ import subprocess
8
+ from collections.abc import Callable, Iterator
7
9
  from datetime import datetime, timedelta, timezone
8
10
  from pathlib import Path
9
11
 
10
12
  import pytest
11
13
 
12
14
  import codex_account_choice as choice
15
+ from claude_account_profile import links_to
13
16
  from codex_account_meters import CodexAccountMeters, CodexMeterUnreadError, UsageWindow
14
17
 
15
18
  NOW = datetime(2026, 9, 23, 17, 0, tzinfo=timezone.utc)
19
+ ALL_SHARED_ENTRY_NAMES = (
20
+ "AGENTS.md",
21
+ "agents",
22
+ "config.toml",
23
+ "hooks",
24
+ "hooks.json",
25
+ "plugins",
26
+ "rules",
27
+ "skills",
28
+ )
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
+
43
+
44
+ @pytest.fixture(autouse=True)
45
+ def clear_account_roster_environment(monkeypatch: pytest.MonkeyPatch) -> None:
46
+ monkeypatch.delenv("CODEX_ACCOUNT_PROFILES", raising=False)
47
+
48
+
49
+ def build_codex_main_home(tmp_path: Path) -> Path:
50
+ main_home = tmp_path / "main"
51
+ for each_directory in ("agents", "hooks", "plugins", "rules", "skills", "sessions"):
52
+ (main_home / each_directory).mkdir(parents=True)
53
+ for each_file in ("AGENTS.md", "config.toml", "hooks.json", "auth.json", "history.jsonl"):
54
+ (main_home / each_file).write_text(each_file, encoding="utf-8")
55
+ return main_home
56
+
57
+
58
+ def save_roster(profiles_root: Path, document: object) -> None:
59
+ profiles_root.mkdir(parents=True, exist_ok=True)
60
+ (profiles_root / "account-launchers.json").write_text(
61
+ json.dumps(document), encoding="utf-8"
62
+ )
63
+
64
+
65
+ def typed_lines(*all_lines: str) -> Callable[[str], str]:
66
+ remaining_lines: Iterator[str] = iter(all_lines)
67
+
68
+ def read_line(prompt: str) -> str:
69
+ assert prompt == "Name for this Codex account launcher (blank to finish): "
70
+ try:
71
+ return next(remaining_lines)
72
+ except StopIteration as error:
73
+ raise EOFError from error
74
+
75
+ return read_line
76
+
77
+
78
+ def install(main_home: Path, tmp_path: Path, *all_names: str) -> dict[str, dict[str, object]]:
79
+ return choice.install_codex_account_launchers(
80
+ main_home=main_home,
81
+ profiles_root=tmp_path / "profiles",
82
+ launcher_directory=tmp_path / "bin",
83
+ all_names=all_names,
84
+ now=NOW,
85
+ )
16
86
 
17
87
 
18
88
  def reading(
@@ -251,7 +321,7 @@ class TestSync:
251
321
  class TestSmallHelpers:
252
322
  @pytest.mark.parametrize(
253
323
  ("entry_name", "is_local"),
254
- [("auth.json", True), ("sessions", True), ("state_5.sqlite", True), ("config.toml", False), ("plugins", False)],
324
+ [("auth.json", True), ("sessions", True), ("state_5.sqlite", True), ("config.toml", False), ("plugins", False), ("agents", False)],
255
325
  )
256
326
  def should_keep_every_entry_outside_the_shared_set_per_account(
257
327
  self, entry_name: str, is_local: bool
@@ -274,3 +344,301 @@ class TestSmallHelpers:
274
344
  payload = choice.reading_payload(reading("codex-1", weekly_used=40.0, short_used=5.0))
275
345
  assert payload["percent_left"] == 60.0
276
346
  assert [each["minutes"] for each in payload["windows"]] == [300, 10080]
347
+
348
+
349
+ class TestAccountRoster:
350
+ def should_take_the_environment_roster_over_the_saved_file(
351
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
352
+ ) -> None:
353
+ save_roster(tmp_path, ["gamma"])
354
+ monkeypatch.setenv("CODEX_ACCOUNT_PROFILES", " beta , alpha,, ")
355
+ assert choice.saved_codex_account_names(tmp_path) == ("beta", "alpha")
356
+
357
+ def should_read_the_saved_file_when_the_environment_is_unset(
358
+ self, tmp_path: Path
359
+ ) -> None:
360
+ save_roster(tmp_path, ["gamma", "alpha"])
361
+ assert choice.saved_codex_account_names(tmp_path) == ("gamma", "alpha")
362
+
363
+ def should_name_no_account_when_neither_is_set(self, tmp_path: Path) -> None:
364
+ assert choice.saved_codex_account_names(tmp_path) == ()
365
+
366
+ def should_refuse_an_invalid_name_from_the_environment(
367
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
368
+ ) -> None:
369
+ monkeypatch.setenv("CODEX_ACCOUNT_PROFILES", "alpha,bad name")
370
+ with pytest.raises(ValueError, match="CODEX_ACCOUNT_PROFILES names 'bad name'"):
371
+ choice.saved_codex_account_names(tmp_path)
372
+
373
+ @pytest.mark.parametrize("document", [["alpha", "con"], ["alpha", 3], {"alpha": 1}])
374
+ def should_refuse_a_saved_file_with_an_invalid_name(
375
+ self, tmp_path: Path, document: object
376
+ ) -> None:
377
+ save_roster(tmp_path, document)
378
+ with pytest.raises(ValueError, match="account-launchers.json"):
379
+ choice.saved_codex_account_names(tmp_path)
380
+
381
+ def should_refuse_a_saved_file_that_is_not_json(
382
+ self, tmp_path: Path, capsys: pytest.CaptureFixture[str]
383
+ ) -> None:
384
+ (tmp_path / "account-launchers.json").write_text('["alpha",', encoding="utf-8")
385
+ with pytest.raises(SystemExit) as exit_info:
386
+ choice.main(["--profiles-root", str(tmp_path), "check", "alpha"])
387
+ assert exit_info.value.code == 2
388
+ assert "account-launchers.json" in capsys.readouterr().err
389
+
390
+ def should_drop_duplicates_and_keep_the_first_order(self, tmp_path: Path) -> None:
391
+ save_roster(tmp_path, ["beta", "alpha", "beta", "gamma", "alpha"])
392
+ assert choice.saved_codex_account_names(tmp_path) == ("beta", "alpha", "gamma")
393
+
394
+ def should_save_the_roster_as_a_json_list_ending_in_a_newline(
395
+ self, tmp_path: Path
396
+ ) -> None:
397
+ profiles_root = tmp_path / "profiles"
398
+ choice.save_codex_account_names(profiles_root, ("alpha", "beta"))
399
+ assert (profiles_root / "account-launchers.json").read_bytes() == (
400
+ b'[\n "alpha",\n "beta"\n]\n'
401
+ )
402
+ assert choice.saved_codex_account_names(profiles_root) == ("alpha", "beta")
403
+
404
+ def should_fall_back_to_the_four_fixed_accounts_without_a_roster(
405
+ self, tmp_path: Path
406
+ ) -> None:
407
+ assert choice.codex_account_names(tmp_path) == (
408
+ "codex-1",
409
+ "codex-2",
410
+ "codex-3",
411
+ "codex-4",
412
+ )
413
+
414
+
415
+ class TestRosterDrivesTheJobCommands:
416
+ def should_try_the_roster_in_order_and_skip_the_fixed_accounts(
417
+ self,
418
+ tmp_path: Path,
419
+ monkeypatch: pytest.MonkeyPatch,
420
+ capsys: pytest.CaptureFixture[str],
421
+ ) -> None:
422
+ all_weekly_used = {"beta": 95.0, "alpha": 50.0, "codex-1": 0.0}
423
+ for each_name in all_weekly_used:
424
+ (tmp_path / each_name).mkdir()
425
+ (tmp_path / each_name / "auth.json").write_text("{}")
426
+ monkeypatch.setenv("CODEX_ACCOUNT_PROFILES", "beta,alpha")
427
+ monkeypatch.setattr(
428
+ choice,
429
+ "_meter_reader",
430
+ lambda codex_path: (
431
+ lambda codex_home: CodexAccountMeters(
432
+ (UsageWindow(10080, all_weekly_used[codex_home.name], None),)
433
+ )
434
+ ),
435
+ )
436
+ assert choice.main(["--profiles-root", str(tmp_path), "choose"]) == 0
437
+ payload = json.loads(capsys.readouterr().out)
438
+ assert (payload["tier"], payload["account"]) == ("normal", "alpha")
439
+ assert [each["name"] for each in payload["accounts"]] == ["beta", "alpha"]
440
+
441
+ def should_choose_among_the_four_fixed_accounts_without_a_roster(
442
+ self,
443
+ tmp_path: Path,
444
+ monkeypatch: pytest.MonkeyPatch,
445
+ capsys: pytest.CaptureFixture[str],
446
+ ) -> None:
447
+ monkeypatch.setattr(choice, "_meter_reader", lambda codex_path: None)
448
+ assert choice.main(["--profiles-root", str(tmp_path), "choose"]) == 0
449
+ payload = json.loads(capsys.readouterr().out)
450
+ assert [each["name"] for each in payload["accounts"]] == [
451
+ "codex-1",
452
+ "codex-2",
453
+ "codex-3",
454
+ "codex-4",
455
+ ]
456
+
457
+ def should_sync_only_the_roster_accounts(
458
+ self,
459
+ tmp_path: Path,
460
+ monkeypatch: pytest.MonkeyPatch,
461
+ capsys: pytest.CaptureFixture[str],
462
+ ) -> None:
463
+ main_home = build_codex_main_home(tmp_path)
464
+ profiles_root = tmp_path / "profiles"
465
+ monkeypatch.setenv("CODEX_ACCOUNT_PROFILES", "alpha")
466
+ all_arguments = ["--profiles-root", str(profiles_root), "sync"]
467
+ assert choice.main([*all_arguments, "--main-home", str(main_home)]) == 0
468
+ assert list(json.loads(capsys.readouterr().out)) == ["alpha"]
469
+ assert links_to(profiles_root / "alpha" / "agents", main_home / "agents")
470
+ assert not (profiles_root / "codex-1").exists()
471
+
472
+ def should_check_a_saved_roster_account(
473
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
474
+ ) -> None:
475
+ save_roster(tmp_path, ["alpha"])
476
+ (tmp_path / "alpha").mkdir()
477
+ (tmp_path / "alpha" / "auth.json").write_text("{}")
478
+ monkeypatch.setattr(
479
+ choice,
480
+ "_meter_reader",
481
+ lambda codex_path: (
482
+ lambda codex_home: CodexAccountMeters((UsageWindow(10080, 40.0, None),))
483
+ ),
484
+ )
485
+ assert choice.main(["--profiles-root", str(tmp_path), "check", "alpha"]) == 0
486
+
487
+ @pytest.mark.parametrize("account", ["gamma", "codex-1"])
488
+ def should_refuse_to_check_an_account_outside_the_roster(
489
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, account: str
490
+ ) -> None:
491
+ save_roster(tmp_path, ["alpha"])
492
+ monkeypatch.setattr(choice, "_meter_reader", lambda codex_path: None)
493
+ with pytest.raises(SystemExit) as exit_info:
494
+ choice.main(["--profiles-root", str(tmp_path), "check", account])
495
+ assert exit_info.value.code == 2
496
+
497
+
498
+ class TestInstallLaunchers:
499
+ def should_link_the_shared_setup_and_keep_each_sign_in_per_account(
500
+ self, tmp_path: Path
501
+ ) -> None:
502
+ main_home = build_codex_main_home(tmp_path)
503
+ report = install(main_home, tmp_path, "alpha", "beta")
504
+ assert list(report) == ["alpha", "beta"]
505
+ for each_name in ("alpha", "beta"):
506
+ each_home = tmp_path / "profiles" / each_name
507
+ for each_entry in ALL_SHARED_ENTRY_NAMES:
508
+ assert links_to(each_home / each_entry, main_home / each_entry)
509
+ for each_local_entry in ("auth.json", "history.jsonl", "sessions"):
510
+ assert not os.path.lexists(each_home / each_local_entry)
511
+ assert report[each_name]["launcher"] == str(
512
+ tmp_path / "bin" / f"codex-{each_name}.cmd"
513
+ )
514
+
515
+ def should_change_nothing_on_a_second_run(self, tmp_path: Path) -> None:
516
+ main_home = build_codex_main_home(tmp_path)
517
+ install(main_home, tmp_path, "alpha")
518
+ launcher_path = tmp_path / "bin" / "codex-alpha.cmd"
519
+ first_launcher_bytes = launcher_path.read_bytes()
520
+ second_report = install(main_home, tmp_path, "alpha")
521
+ assert second_report["alpha"]["linked"] == []
522
+ assert second_report["alpha"]["moved_aside"] == []
523
+ assert second_report["alpha"]["unlinked"] == []
524
+ assert launcher_path.read_bytes() == first_launcher_bytes
525
+ assert sorted(each.name for each in (tmp_path / "bin").iterdir()) == [
526
+ "codex-alpha.cmd"
527
+ ]
528
+
529
+ def should_set_codex_home_and_call_codex_in_the_launcher(
530
+ self, tmp_path: Path
531
+ ) -> None:
532
+ install(build_codex_main_home(tmp_path), tmp_path, "alpha")
533
+ launcher_lines = (tmp_path / "bin" / "codex-alpha.cmd").read_bytes().split(b"\r\n")
534
+ profile_home = tmp_path / "profiles" / "alpha"
535
+ assert f'set "CODEX_HOME={profile_home}"'.encode() in launcher_lines
536
+ assert b"call codex %*" in launcher_lines
537
+
538
+ def should_install_the_environment_roster_from_the_command_line(
539
+ self,
540
+ tmp_path: Path,
541
+ monkeypatch: pytest.MonkeyPatch,
542
+ capsys: pytest.CaptureFixture[str],
543
+ ) -> None:
544
+ main_home = build_codex_main_home(tmp_path)
545
+ save_roster(tmp_path / "profiles", ["gamma"])
546
+ monkeypatch.setenv("CODEX_ACCOUNT_PROFILES", "beta")
547
+ all_arguments = [
548
+ "--profiles-root",
549
+ str(tmp_path / "profiles"),
550
+ "install",
551
+ "--main-home",
552
+ str(main_home),
553
+ "--launcher-directory",
554
+ str(tmp_path / "bin"),
555
+ ]
556
+ assert choice.main(all_arguments) == 0
557
+ assert list(json.loads(capsys.readouterr().out)) == ["beta"]
558
+ assert (tmp_path / "bin" / "codex-beta.cmd").is_file()
559
+ assert not (tmp_path / "bin" / "codex-gamma.cmd").exists()
560
+
561
+ def should_print_an_empty_report_for_an_empty_roster(
562
+ self, tmp_path: Path, capsys: pytest.CaptureFixture[str]
563
+ ) -> None:
564
+ all_arguments = ["--profiles-root", str(tmp_path / "profiles"), "install"]
565
+ assert choice.main([*all_arguments, "--launcher-directory", str(tmp_path / "bin")]) == 0
566
+ assert json.loads(capsys.readouterr().out) == {}
567
+ assert not (tmp_path / "bin").exists()
568
+
569
+
570
+ class TestSetupRoster:
571
+ def should_save_each_valid_name_once_in_typed_order(self, tmp_path: Path) -> None:
572
+ all_shown: list[str] = []
573
+ all_names, all_retired = choice.update_codex_account_roster(
574
+ profiles_root=tmp_path,
575
+ launcher_directory=tmp_path / "bin",
576
+ now=NOW,
577
+ read_line=typed_lines("alpha", "bad name", "alpha", "beta", "", "gamma"),
578
+ write_line=all_shown.append,
579
+ )
580
+ assert (all_names, all_retired) == (("alpha", "beta"), {})
581
+ assert choice.saved_codex_account_names(tmp_path) == ("alpha", "beta")
582
+ assert all_shown == [
583
+ "Saved Codex account launchers: none",
584
+ "profile name must use letters, digits, hyphens, or underscores",
585
+ ]
586
+
587
+ def should_finish_at_the_end_of_input(self, tmp_path: Path) -> None:
588
+ all_names, _ = choice.update_codex_account_roster(
589
+ profiles_root=tmp_path,
590
+ launcher_directory=tmp_path / "bin",
591
+ now=NOW,
592
+ read_line=typed_lines("gamma"),
593
+ write_line=lambda line: None,
594
+ )
595
+ assert all_names == ("gamma",)
596
+
597
+ def should_move_a_dropped_launcher_aside_and_keep_its_home(
598
+ self, tmp_path: Path
599
+ ) -> None:
600
+ main_home = build_codex_main_home(tmp_path)
601
+ profiles_root = tmp_path / "profiles"
602
+ choice.save_codex_account_names(profiles_root, ("alpha", "beta"))
603
+ install(main_home, tmp_path, "alpha", "beta")
604
+ (profiles_root / "beta" / "auth.json").write_text("beta sign-in", encoding="utf-8")
605
+ all_shown: list[str] = []
606
+ all_names, all_retired = choice.update_codex_account_roster(
607
+ profiles_root=profiles_root,
608
+ launcher_directory=tmp_path / "bin",
609
+ now=NOW,
610
+ read_line=typed_lines("alpha", ""),
611
+ write_line=all_shown.append,
612
+ )
613
+ moved_launcher = tmp_path / "bin" / "codex-beta.cmd.replaced-20260923T170000Z"
614
+ assert all_names == ("alpha",)
615
+ assert all_retired == {"beta": str(moved_launcher)}
616
+ assert all_shown == ["Saved Codex account launchers: alpha, beta"]
617
+ assert moved_launcher.is_file()
618
+ assert not (tmp_path / "bin" / "codex-beta.cmd").exists()
619
+ assert (tmp_path / "bin" / "codex-alpha.cmd").is_file()
620
+ assert (profiles_root / "beta" / "auth.json").read_text(encoding="utf-8") == (
621
+ "beta sign-in"
622
+ )
623
+ 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")