claude-dev-env 8.7.0 → 8.8.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.
@@ -0,0 +1,217 @@
1
+ """Tests for the picker that names which Claude account a runner job uses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import datetime, timedelta, timezone
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ import claude_account_choice as choice
12
+ from claude_chain_usage import AccountUsageMeters, WeeklyUtilizationProbeError
13
+
14
+ NOW = datetime(2026, 9, 22, 21, 0, tzinfo=timezone.utc)
15
+
16
+
17
+ def meters(
18
+ *,
19
+ session_used: float | None,
20
+ weekly_used: float | None,
21
+ weekly_resets_in: timedelta | None,
22
+ session_resets_in: timedelta = timedelta(hours=2),
23
+ ) -> AccountUsageMeters:
24
+ return AccountUsageMeters(
25
+ session_utilization=session_used,
26
+ session_resets_at=NOW + session_resets_in,
27
+ weekly_utilization=weekly_used,
28
+ weekly_resets_at=None if weekly_resets_in is None else NOW + weekly_resets_in,
29
+ )
30
+
31
+
32
+ class TestChooseAccount:
33
+ def should_pick_main_in_jons_example(self) -> None:
34
+ main_meters = meters(
35
+ session_used=10.0, weekly_used=80.0, weekly_resets_in=timedelta(hours=5)
36
+ )
37
+ second_meters = meters(
38
+ session_used=10.0, weekly_used=82.0, weekly_resets_in=timedelta(days=4)
39
+ )
40
+ decision = choice.choose_account(
41
+ main_meters=main_meters, second_meters=second_meters, now=NOW
42
+ )
43
+ assert decision.account == choice.CHOICE_MAIN
44
+
45
+ def should_pick_second_when_main_week_resets_later_than_a_day(self) -> None:
46
+ main_meters = meters(
47
+ session_used=0.0, weekly_used=5.0, weekly_resets_in=timedelta(hours=30)
48
+ )
49
+ second_meters = meters(
50
+ session_used=10.0, weekly_used=40.0, weekly_resets_in=timedelta(days=4)
51
+ )
52
+ decision = choice.choose_account(
53
+ main_meters=main_meters, second_meters=second_meters, now=NOW
54
+ )
55
+ assert decision.account == choice.CHOICE_SECOND
56
+
57
+ def should_keep_main_once_it_nears_its_weekly_ceiling(self) -> None:
58
+ main_meters = meters(
59
+ session_used=0.0, weekly_used=90.0, weekly_resets_in=timedelta(hours=3)
60
+ )
61
+ second_meters = meters(
62
+ session_used=0.0, weekly_used=10.0, weekly_resets_in=timedelta(days=4)
63
+ )
64
+ decision = choice.choose_account(
65
+ main_meters=main_meters, second_meters=second_meters, now=NOW
66
+ )
67
+ assert decision.account == choice.CHOICE_SECOND
68
+
69
+ def should_keep_main_when_its_five_hour_window_is_half_spent(self) -> None:
70
+ main_meters = meters(
71
+ session_used=50.0, weekly_used=20.0, weekly_resets_in=timedelta(hours=3)
72
+ )
73
+ second_meters = meters(
74
+ session_used=0.0, weekly_used=10.0, weekly_resets_in=timedelta(days=4)
75
+ )
76
+ decision = choice.choose_account(
77
+ main_meters=main_meters, second_meters=second_meters, now=NOW
78
+ )
79
+ assert decision.account == choice.CHOICE_SECOND
80
+
81
+ def should_never_pick_main_when_its_meter_is_unread(self) -> None:
82
+ second_meters = meters(
83
+ session_used=95.0, weekly_used=99.0, weekly_resets_in=timedelta(days=4)
84
+ )
85
+ decision = choice.choose_account(
86
+ main_meters=None, second_meters=second_meters, now=NOW
87
+ )
88
+ assert decision.account == choice.CHOICE_WAIT
89
+
90
+ def should_never_pick_main_when_its_reset_time_is_missing(self) -> None:
91
+ main_meters = meters(session_used=0.0, weekly_used=0.0, weekly_resets_in=None)
92
+ second_meters = meters(
93
+ session_used=95.0, weekly_used=99.0, weekly_resets_in=timedelta(days=4)
94
+ )
95
+ decision = choice.choose_account(
96
+ main_meters=main_meters, second_meters=second_meters, now=NOW
97
+ )
98
+ assert decision.account == choice.CHOICE_WAIT
99
+
100
+ def should_pick_second_when_its_meter_is_unread(self) -> None:
101
+ decision = choice.choose_account(main_meters=None, second_meters=None, now=NOW)
102
+ assert decision.account == choice.CHOICE_SECOND
103
+ assert decision.reason == choice.REASON_SECOND_UNREAD
104
+
105
+ def should_wait_and_name_the_session_reset_when_the_five_hour_window_blocks(
106
+ self,
107
+ ) -> None:
108
+ second_meters = meters(
109
+ session_used=90.0,
110
+ weekly_used=50.0,
111
+ weekly_resets_in=timedelta(days=4),
112
+ session_resets_in=timedelta(hours=1),
113
+ )
114
+ decision = choice.choose_account(
115
+ main_meters=None, second_meters=second_meters, now=NOW
116
+ )
117
+ assert decision.account == choice.CHOICE_WAIT
118
+ assert (NOW + timedelta(hours=1)).isoformat() in decision.reason
119
+
120
+ def should_wait_and_name_the_weekly_reset_when_the_week_blocks(self) -> None:
121
+ second_meters = meters(
122
+ session_used=10.0, weekly_used=95.0, weekly_resets_in=timedelta(days=2)
123
+ )
124
+ decision = choice.choose_account(
125
+ main_meters=None, second_meters=second_meters, now=NOW
126
+ )
127
+ assert decision.account == choice.CHOICE_WAIT
128
+ assert (NOW + timedelta(days=2)).isoformat() in decision.reason
129
+
130
+
131
+ class TestReadAccountMeters:
132
+ def should_return_none_when_the_probe_cannot_read_the_account(
133
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
134
+ ) -> None:
135
+ def failing_probe(credentials_path: Path) -> AccountUsageMeters:
136
+ raise WeeklyUtilizationProbeError(f"no token in {credentials_path}")
137
+
138
+ monkeypatch.setattr(choice, "probe_account_meters", failing_probe)
139
+ assert choice.read_account_meters(tmp_path / ".credentials.json") is None
140
+
141
+ def should_return_the_probed_meters(
142
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
143
+ ) -> None:
144
+ probed = meters(
145
+ session_used=1.0, weekly_used=2.0, weekly_resets_in=timedelta(days=1)
146
+ )
147
+ monkeypatch.setattr(choice, "probe_account_meters", lambda credentials_path: probed)
148
+ assert choice.read_account_meters(tmp_path / ".credentials.json") == probed
149
+
150
+
151
+ class TestDecisionPayload:
152
+ def should_name_no_config_directory_for_a_wait(self) -> None:
153
+ decision = choice.AccountDecision(account=choice.CHOICE_WAIT, reason="full")
154
+ assert choice.decision_payload(decision, config_directory=None) == {
155
+ "account": "wait",
156
+ "config_dir": None,
157
+ "reason": "full",
158
+ }
159
+
160
+
161
+ class TestMain:
162
+ def should_print_the_choice_and_the_config_directory_of_the_chosen_account(
163
+ self,
164
+ tmp_path: Path,
165
+ monkeypatch: pytest.MonkeyPatch,
166
+ capsys: pytest.CaptureFixture[str],
167
+ ) -> None:
168
+ main_home = tmp_path / "main"
169
+ second_home = tmp_path / "second"
170
+ meters_by_credentials_path = {
171
+ main_home / ".credentials.json": None,
172
+ second_home / ".credentials.json": meters(
173
+ session_used=0.0, weekly_used=10.0, weekly_resets_in=timedelta(days=4)
174
+ ),
175
+ }
176
+ monkeypatch.setattr(
177
+ choice,
178
+ "read_account_meters",
179
+ lambda credentials_path: meters_by_credentials_path[credentials_path],
180
+ )
181
+ exit_code = choice.main(
182
+ [
183
+ "--main-config-dir",
184
+ str(main_home),
185
+ "--second-config-dir",
186
+ str(second_home),
187
+ ]
188
+ )
189
+ printed = json.loads(capsys.readouterr().out)
190
+ assert exit_code == 0
191
+ assert printed["account"] == choice.CHOICE_SECOND
192
+ assert printed["config_dir"] == str(second_home)
193
+
194
+ def should_print_no_config_directory_when_the_job_waits(
195
+ self,
196
+ tmp_path: Path,
197
+ monkeypatch: pytest.MonkeyPatch,
198
+ capsys: pytest.CaptureFixture[str],
199
+ ) -> None:
200
+ blocked_meters = meters(
201
+ session_used=99.0, weekly_used=99.0, weekly_resets_in=timedelta(days=4)
202
+ )
203
+ monkeypatch.setattr(
204
+ choice, "read_account_meters", lambda credentials_path: blocked_meters
205
+ )
206
+ exit_code = choice.main(
207
+ [
208
+ "--main-config-dir",
209
+ str(tmp_path / "main"),
210
+ "--second-config-dir",
211
+ str(tmp_path / "second"),
212
+ ]
213
+ )
214
+ printed = json.loads(capsys.readouterr().out)
215
+ assert exit_code == 0
216
+ assert printed["account"] == choice.CHOICE_WAIT
217
+ assert printed["config_dir"] is None
@@ -0,0 +1,220 @@
1
+ """Tests for the second-account profile sync and its launcher."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+
10
+ import pytest
11
+
12
+ import claude_account_profile as profile
13
+
14
+ NOW = datetime(2026, 9, 22, 21, 0, tzinfo=timezone.utc)
15
+
16
+
17
+ def build_main_home(tmp_path: Path) -> Path:
18
+ main_home = tmp_path / "main"
19
+ (main_home / "skills" / "tdd").mkdir(parents=True)
20
+ (main_home / "rules").mkdir()
21
+ (main_home / "projects").mkdir()
22
+ (main_home / "CLAUDE.md").write_text("main instructions", encoding="utf-8")
23
+ (main_home / "settings.json").write_text("{}", encoding="utf-8")
24
+ (main_home / ".credentials.json").write_text("main sign-in", encoding="utf-8")
25
+ (main_home / ".claude.json").write_text("main state", encoding="utf-8")
26
+ (main_home / ".claude.json.backup").write_text("main backup", encoding="utf-8")
27
+ return main_home
28
+
29
+
30
+ def sync(main_home: Path, profile_home: Path) -> profile.ProfileSyncReport:
31
+ return profile.sync_profile(main_home=main_home, profile_home=profile_home, now=NOW)
32
+
33
+
34
+ class TestDefaultProfileHome:
35
+ def should_use_the_profiles_root_the_environment_names(
36
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
37
+ ) -> None:
38
+ monkeypatch.setenv("LLM_SETTINGS_PROFILES_ROOT", str(tmp_path))
39
+ assert profile.default_profile_home() == tmp_path / "ev"
40
+
41
+ def should_fall_back_to_the_profiles_root_in_the_user_home(
42
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
43
+ ) -> None:
44
+ monkeypatch.delenv("LLM_SETTINGS_PROFILES_ROOT", raising=False)
45
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
46
+ assert profile.default_profile_home() == tmp_path / ".claude-profiles" / "ev"
47
+
48
+
49
+ class TestIsAccountLocal:
50
+ def should_keep_sign_in_state_and_history_local(self) -> None:
51
+ for each_name in (
52
+ ".credentials.json",
53
+ ".credentials.json.bak-session-dashboard",
54
+ ".claude.json",
55
+ ".claude.json.backup",
56
+ "projects",
57
+ ):
58
+ assert profile.is_account_local(each_name)
59
+
60
+ def should_share_skills_rules_and_settings(self) -> None:
61
+ for each_name in ("skills", "rules", "CLAUDE.md", "settings.json", "plugins"):
62
+ assert not profile.is_account_local(each_name)
63
+
64
+
65
+ class TestSyncProfile:
66
+ def should_link_every_shared_entry_to_the_main_home(self, tmp_path: Path) -> None:
67
+ main_home = build_main_home(tmp_path)
68
+ profile_home = tmp_path / "ev"
69
+ sync(main_home, profile_home)
70
+ assert (profile_home / "skills" / "tdd").is_dir()
71
+ assert (profile_home / "CLAUDE.md").read_text(
72
+ encoding="utf-8"
73
+ ) == "main instructions"
74
+ assert profile.links_to(profile_home / "rules", main_home / "rules")
75
+ assert profile.links_to(
76
+ profile_home / "settings.json", main_home / "settings.json"
77
+ )
78
+
79
+ def should_keep_the_accounts_own_sign_in_state_and_history_apart(
80
+ self, tmp_path: Path
81
+ ) -> None:
82
+ main_home = build_main_home(tmp_path)
83
+ profile_home = tmp_path / "ev"
84
+ profile_home.mkdir()
85
+ (profile_home / ".credentials.json").write_text(
86
+ "second sign-in", encoding="utf-8"
87
+ )
88
+ sync(main_home, profile_home)
89
+ assert (profile_home / ".credentials.json").read_text(
90
+ encoding="utf-8"
91
+ ) == "second sign-in"
92
+ assert not (profile_home / ".claude.json").exists()
93
+ assert not (profile_home / ".claude.json.backup").exists()
94
+ assert not (profile_home / "projects").exists()
95
+
96
+ def should_move_a_stale_copy_aside_and_keep_its_content(
97
+ self, tmp_path: Path
98
+ ) -> None:
99
+ main_home = build_main_home(tmp_path)
100
+ profile_home = tmp_path / "ev"
101
+ profile_home.mkdir()
102
+ (profile_home / "CLAUDE.md").write_text("stale copy", encoding="utf-8")
103
+ report = sync(main_home, profile_home)
104
+ moved_copy = (
105
+ profile_home / ".replaced" / NOW.strftime("%Y%m%dT%H%M%SZ") / "CLAUDE.md"
106
+ )
107
+ assert moved_copy.read_text(encoding="utf-8") == "stale copy"
108
+ assert profile.links_to(profile_home / "CLAUDE.md", main_home / "CLAUDE.md")
109
+ assert report.all_moved_aside == ("CLAUDE.md",)
110
+
111
+ def should_remove_a_same_content_copy_without_moving_it_aside(
112
+ self, tmp_path: Path
113
+ ) -> None:
114
+ main_home = build_main_home(tmp_path)
115
+ profile_home = tmp_path / "ev"
116
+ profile_home.mkdir()
117
+ (profile_home / "CLAUDE.md").write_text("main instructions", encoding="utf-8")
118
+ report = sync(main_home, profile_home)
119
+ assert report.all_moved_aside == ()
120
+ assert not (profile_home / ".replaced").exists()
121
+ assert profile.links_to(profile_home / "CLAUDE.md", main_home / "CLAUDE.md")
122
+
123
+ def should_move_aside_a_link_that_points_somewhere_else(
124
+ self, tmp_path: Path
125
+ ) -> None:
126
+ main_home = build_main_home(tmp_path)
127
+ elsewhere = tmp_path / "old-shared" / "rules"
128
+ elsewhere.mkdir(parents=True)
129
+ (elsewhere / "keep.md").write_text("old rule", encoding="utf-8")
130
+ profile_home = tmp_path / "ev"
131
+ profile_home.mkdir()
132
+ (profile_home / "rules").symlink_to(elsewhere, target_is_directory=True)
133
+ report = sync(main_home, profile_home)
134
+ assert (elsewhere / "keep.md").read_text(encoding="utf-8") == "old rule"
135
+ assert profile.links_to(profile_home / "rules", main_home / "rules")
136
+ assert report.all_moved_aside == ("rules",)
137
+
138
+ def should_change_nothing_on_a_second_run(self, tmp_path: Path) -> None:
139
+ main_home = build_main_home(tmp_path)
140
+ profile_home = tmp_path / "ev"
141
+ sync(main_home, profile_home)
142
+ second_report = sync(main_home, profile_home)
143
+ assert second_report.all_linked == ()
144
+ assert second_report.all_moved_aside == ()
145
+ assert second_report.all_unlinked == ()
146
+
147
+ def should_unlink_an_entry_the_main_home_no_longer_has(
148
+ self, tmp_path: Path
149
+ ) -> None:
150
+ main_home = build_main_home(tmp_path)
151
+ profile_home = tmp_path / "ev"
152
+ sync(main_home, profile_home)
153
+ (main_home / "rules").rmdir()
154
+ report = sync(main_home, profile_home)
155
+ assert not os.path.lexists(profile_home / "rules")
156
+ assert report.all_unlinked == ("rules",)
157
+
158
+
159
+ def should_remove_a_main_link_left_under_an_account_local_name(
160
+ self, tmp_path: Path
161
+ ) -> None:
162
+ main_home = build_main_home(tmp_path)
163
+ (main_home / ".credentials.json.bak").write_text("main backup", encoding="utf-8")
164
+ profile_home = tmp_path / "ev"
165
+ profile_home.mkdir()
166
+ os.link(main_home / ".credentials.json.bak", profile_home / ".credentials.json.bak")
167
+ report = sync(main_home, profile_home)
168
+ assert not os.path.lexists(profile_home / ".credentials.json.bak")
169
+ assert (main_home / ".credentials.json.bak").read_text(
170
+ encoding="utf-8"
171
+ ) == "main backup"
172
+ assert report.all_unlinked == (".credentials.json.bak",)
173
+
174
+
175
+ class TestWriteLauncher:
176
+ def should_point_claude_at_the_profile_and_pass_every_argument(
177
+ self, tmp_path: Path
178
+ ) -> None:
179
+ profile_home = tmp_path / "ev"
180
+ launcher_path = profile.write_launcher(
181
+ launcher_directory=tmp_path / "bin", profile_home=profile_home, now=NOW
182
+ )
183
+ launcher_text = launcher_path.read_text(encoding="utf-8")
184
+ assert f'set "CLAUDE_CONFIG_DIR={profile_home}"' in launcher_text
185
+ assert "claude %*" in launcher_text
186
+
187
+ def should_move_an_older_launcher_aside(self, tmp_path: Path) -> None:
188
+ launcher_directory = tmp_path / "bin"
189
+ launcher_directory.mkdir()
190
+ (launcher_directory / "claude-ev.cmd").write_text(
191
+ "old launcher", encoding="utf-8"
192
+ )
193
+ profile.write_launcher(
194
+ launcher_directory=launcher_directory, profile_home=tmp_path / "ev", now=NOW
195
+ )
196
+ moved_launcher = launcher_directory / (
197
+ "claude-ev.cmd.replaced-" + NOW.strftime("%Y%m%dT%H%M%SZ")
198
+ )
199
+ assert moved_launcher.read_text(encoding="utf-8") == "old launcher"
200
+
201
+
202
+ class TestMain:
203
+ def should_print_what_the_sync_changed(
204
+ self, tmp_path: Path, capsys: pytest.CaptureFixture[str]
205
+ ) -> None:
206
+ main_home = build_main_home(tmp_path)
207
+ exit_code = profile.main(
208
+ [
209
+ "--main-home",
210
+ str(main_home),
211
+ "--profile-home",
212
+ str(tmp_path / "ev"),
213
+ "--launcher-directory",
214
+ str(tmp_path / "bin"),
215
+ ]
216
+ )
217
+ printed = json.loads(capsys.readouterr().out)
218
+ assert exit_code == 0
219
+ assert "CLAUDE.md" in printed["linked"]
220
+ assert printed["launcher"] == str(tmp_path / "bin" / "claude-ev.cmd")
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import json
6
6
  import subprocess
7
7
  import sys
8
+ from datetime import datetime
8
9
  from pathlib import Path
9
10
 
10
11
  import pytest
@@ -372,7 +373,10 @@ def test_probe_weekly_utilization_reuses_resolver_windows(
372
373
  monkeypatch: pytest.MonkeyPatch,
373
374
  ) -> None:
374
375
  class _FakeWindows:
376
+ session_utilization = None
377
+ session_resets_at = None
375
378
  weekly_utilization = 37.5
379
+ weekly_resets_at = None
376
380
 
377
381
  class _FakeResolver:
378
382
  def read_oauth_access_token(self, credentials_path: Path, now: object) -> str:
@@ -401,6 +405,42 @@ def test_probe_weekly_utilization_reuses_resolver_windows(
401
405
  assert weekly_utilization == pytest.approx(37.5)
402
406
 
403
407
 
408
+ def should_read_both_meters_and_their_resets_for_one_account(
409
+ monkeypatch: pytest.MonkeyPatch,
410
+ ) -> None:
411
+ session_reset = datetime(2026, 9, 22, 23, 0).astimezone()
412
+ weekly_reset = datetime(2026, 9, 26, 9, 0).astimezone()
413
+
414
+ class _FakeWindows:
415
+ session_utilization = 12.0
416
+ session_resets_at = session_reset
417
+ weekly_utilization = 64.0
418
+ weekly_resets_at = weekly_reset
419
+
420
+ class _FakeResolver:
421
+ def read_oauth_access_token(self, credentials_path: Path, now: object) -> str:
422
+ return "token-value"
423
+
424
+ def _fetch_usage_payload(self, access_token: str) -> dict[str, object]:
425
+ return {}
426
+
427
+ def extract_usage_windows(
428
+ self, usage_payload: dict[str, object]
429
+ ) -> _FakeWindows:
430
+ return _FakeWindows()
431
+
432
+ monkeypatch.setattr(
433
+ usage, "_load_resolve_usage_window_module", lambda: _FakeResolver()
434
+ )
435
+ account_meters = usage.probe_account_meters(Path(PLACEHOLDER_CREDENTIALS_PRIMARY))
436
+ assert account_meters == usage.AccountUsageMeters(
437
+ session_utilization=12.0,
438
+ session_resets_at=session_reset,
439
+ weekly_utilization=64.0,
440
+ weekly_resets_at=weekly_reset,
441
+ )
442
+
443
+
404
444
  def test_probe_weekly_utilization_raises_when_token_missing(
405
445
  monkeypatch: pytest.MonkeyPatch,
406
446
  ) -> None: