claude-dev-env 8.7.0 → 8.9.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.
- package/.agents/skills/privacy-hygiene/SKILL.md +1 -0
- package/.agents/skills/pull-request/scripts/pull_request.py +1 -0
- package/.agents/skills/pull-request/scripts/test_pull_request.py +2 -0
- package/.agents/skills/team-advisor/SKILL.md +2 -2
- package/_shared/advisor/reference/identity.md +1 -1
- package/_shared/advisor/reference/spawn-walk-log.md +1 -1
- package/_shared/advisor/reference/warm-up.md +3 -1
- package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +15 -0
- package/_shared/advisor/scripts/model_tier_run_validator.py +20 -3
- package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +50 -6
- package/docs/references/advisor-tool.md +3 -2
- package/docs/second-claude-account.md +57 -0
- package/hooks/blocking/test_pii_scanner.py +8 -0
- package/hooks/hooks.json +25 -0
- package/hooks/hooks_constants/pii_prevention_constants.py +1 -0
- package/hooks/hooks_constants/skill_loaded_reminder_constants.py +58 -0
- package/hooks/session/skill_loaded_reminder.py +199 -0
- package/hooks/session/test_skill_loaded_reminder.py +206 -0
- package/package.json +1 -1
- package/rules/durable-post-artifacts.md +26 -1
- package/rules/long-horizon-autonomy.md +11 -0
- package/rules/prefer-existing-tools.md +35 -0
- package/rules/review-closure-is-a-check.md +1 -1
- package/scripts/claude_account_choice.py +291 -0
- package/scripts/claude_account_profile.py +371 -0
- package/scripts/claude_chain_usage.py +49 -5
- package/scripts/dev_env_scripts_constants/claude_account_constants.py +181 -0
- package/scripts/dev_env_scripts_constants/gh_artifact_upload_constants.py +27 -0
- package/scripts/dev_env_scripts_constants/private_term_constants.py +60 -0
- package/scripts/dev_env_scripts_constants/test_claude_account_constants.py +46 -0
- package/scripts/dev_env_scripts_constants/test_private_term_constants.py +29 -0
- package/scripts/durable_post_lint.py +47 -1
- package/scripts/durable_post_lint_config/config/constants.py +1 -0
- package/scripts/gh_artifact_upload.py +56 -3
- package/scripts/private_term_scan.py +135 -0
- package/scripts/private_terms.py +87 -0
- package/scripts/repository_checks/config/constants.py +3 -0
- package/scripts/repository_checks/runner.py +7 -0
- package/scripts/repository_checks/tracked_private_terms.py +57 -0
- package/scripts/test_claude_account_choice.py +245 -0
- package/scripts/test_claude_account_profile.py +220 -0
- package/scripts/test_claude_chain_usage.py +40 -0
- package/scripts/test_durable_post_lint.py +90 -0
- package/scripts/tests/test_gh_artifact_upload.py +75 -0
- package/scripts/tests/test_match_exemptions.py +2 -2
- package/scripts/tests/test_private_term_scan.py +176 -0
- package/scripts/tests/test_private_terms.py +59 -0
- package/scripts/tests/test_repository_policy.py +2 -0
- package/scripts/tests/test_runner.py +20 -0
- package/scripts/tests/test_tracked_private_terms.py +71 -0
- package/scripts/tests/test_tracked_secrets.py +1 -1
|
@@ -0,0 +1,245 @@
|
|
|
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 TestMetersPayload:
|
|
162
|
+
def should_name_no_meters_for_an_unread_account(self) -> None:
|
|
163
|
+
assert choice.meters_payload(None) is None
|
|
164
|
+
|
|
165
|
+
def should_carry_each_used_percent_and_reset_time(self) -> None:
|
|
166
|
+
read_meters = meters(
|
|
167
|
+
session_used=12.0,
|
|
168
|
+
weekly_used=34.0,
|
|
169
|
+
weekly_resets_in=timedelta(days=3),
|
|
170
|
+
session_resets_in=timedelta(hours=1),
|
|
171
|
+
)
|
|
172
|
+
assert choice.meters_payload(read_meters) == {
|
|
173
|
+
"session_used_percent": 12.0,
|
|
174
|
+
"session_resets_at": "2026-09-22T22:00:00+00:00",
|
|
175
|
+
"weekly_used_percent": 34.0,
|
|
176
|
+
"weekly_resets_at": "2026-09-25T21:00:00+00:00",
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
def should_name_no_reset_time_the_meter_left_out(self) -> None:
|
|
180
|
+
read_meters = meters(session_used=1.0, weekly_used=None, weekly_resets_in=None)
|
|
181
|
+
payload = choice.meters_payload(read_meters)
|
|
182
|
+
assert payload is not None
|
|
183
|
+
assert payload["weekly_used_percent"] is None
|
|
184
|
+
assert payload["weekly_resets_at"] is None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class TestMain:
|
|
188
|
+
def should_print_the_choice_and_the_config_directory_of_the_chosen_account(
|
|
189
|
+
self,
|
|
190
|
+
tmp_path: Path,
|
|
191
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
192
|
+
capsys: pytest.CaptureFixture[str],
|
|
193
|
+
) -> None:
|
|
194
|
+
main_home = tmp_path / "main"
|
|
195
|
+
second_home = tmp_path / "second"
|
|
196
|
+
meters_by_credentials_path = {
|
|
197
|
+
main_home / ".credentials.json": None,
|
|
198
|
+
second_home / ".credentials.json": meters(
|
|
199
|
+
session_used=0.0, weekly_used=10.0, weekly_resets_in=timedelta(days=4)
|
|
200
|
+
),
|
|
201
|
+
}
|
|
202
|
+
monkeypatch.setattr(
|
|
203
|
+
choice,
|
|
204
|
+
"read_account_meters",
|
|
205
|
+
lambda credentials_path: meters_by_credentials_path[credentials_path],
|
|
206
|
+
)
|
|
207
|
+
exit_code = choice.main(
|
|
208
|
+
[
|
|
209
|
+
"--main-config-dir",
|
|
210
|
+
str(main_home),
|
|
211
|
+
"--second-config-dir",
|
|
212
|
+
str(second_home),
|
|
213
|
+
]
|
|
214
|
+
)
|
|
215
|
+
printed = json.loads(capsys.readouterr().out)
|
|
216
|
+
assert exit_code == 0
|
|
217
|
+
assert printed["account"] == choice.CHOICE_SECOND
|
|
218
|
+
assert printed["config_dir"] == str(second_home)
|
|
219
|
+
assert printed["meters"]["main"] is None
|
|
220
|
+
assert printed["meters"]["second"]["weekly_used_percent"] == 10.0
|
|
221
|
+
|
|
222
|
+
def should_print_no_config_directory_when_the_job_waits(
|
|
223
|
+
self,
|
|
224
|
+
tmp_path: Path,
|
|
225
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
226
|
+
capsys: pytest.CaptureFixture[str],
|
|
227
|
+
) -> None:
|
|
228
|
+
blocked_meters = meters(
|
|
229
|
+
session_used=99.0, weekly_used=99.0, weekly_resets_in=timedelta(days=4)
|
|
230
|
+
)
|
|
231
|
+
monkeypatch.setattr(
|
|
232
|
+
choice, "read_account_meters", lambda credentials_path: blocked_meters
|
|
233
|
+
)
|
|
234
|
+
exit_code = choice.main(
|
|
235
|
+
[
|
|
236
|
+
"--main-config-dir",
|
|
237
|
+
str(tmp_path / "main"),
|
|
238
|
+
"--second-config-dir",
|
|
239
|
+
str(tmp_path / "second"),
|
|
240
|
+
]
|
|
241
|
+
)
|
|
242
|
+
printed = json.loads(capsys.readouterr().out)
|
|
243
|
+
assert exit_code == 0
|
|
244
|
+
assert printed["account"] == choice.CHOICE_WAIT
|
|
245
|
+
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:
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import hashlib
|
|
5
6
|
import subprocess
|
|
6
7
|
import sys
|
|
7
8
|
from pathlib import Path
|
|
@@ -13,6 +14,7 @@ if str(_SCRIPTS_DIRECTORY) not in sys.path:
|
|
|
13
14
|
sys.path.insert(0, str(_SCRIPTS_DIRECTORY))
|
|
14
15
|
|
|
15
16
|
import durable_post_lint
|
|
17
|
+
from dev_env_scripts_constants.private_term_constants import PrivateTermDigest
|
|
16
18
|
|
|
17
19
|
SCRIPT_PATH = _SCRIPTS_DIRECTORY / "durable_post_lint.py"
|
|
18
20
|
VALID_PR_BODY = """## Why
|
|
@@ -506,3 +508,91 @@ def test_a_release_body_carrying_a_contrast_stays_accepted() -> None:
|
|
|
506
508
|
)
|
|
507
509
|
|
|
508
510
|
assert all_findings == ()
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
_PRIVATE_FIXTURE_TERM = "acmewidget"
|
|
514
|
+
_ALL_PRIVATE_FIXTURE_DIGESTS = frozenset(
|
|
515
|
+
{
|
|
516
|
+
PrivateTermDigest(
|
|
517
|
+
length=len(_PRIVATE_FIXTURE_TERM),
|
|
518
|
+
sha256=hashlib.sha256(_PRIVATE_FIXTURE_TERM.encode("utf-8")).hexdigest(),
|
|
519
|
+
)
|
|
520
|
+
}
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
@pytest.fixture
|
|
525
|
+
def private_fixture_terms(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
526
|
+
monkeypatch.setattr(
|
|
527
|
+
durable_post_lint, "ALL_PRIVATE_TERM_DIGESTS", _ALL_PRIVATE_FIXTURE_DIGESTS
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
@pytest.mark.usefixtures("private_fixture_terms")
|
|
532
|
+
def test_a_body_naming_a_private_organization_reports_its_line() -> None:
|
|
533
|
+
all_findings = durable_post_lint.lint_durable_post(
|
|
534
|
+
action="pr-comment",
|
|
535
|
+
title=None,
|
|
536
|
+
body_text="Fixed.\nSee Acme-Widgets-Inc/tools#12.\n",
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
assert [each_finding.code for each_finding in all_findings] == ["private-term"]
|
|
540
|
+
assert "Line 2" in all_findings[0].message
|
|
541
|
+
assert "Acme" not in all_findings[0].message
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
@pytest.mark.usefixtures("private_fixture_terms")
|
|
545
|
+
def test_a_title_naming_a_private_organization_reports() -> None:
|
|
546
|
+
all_findings = durable_post_lint.lint_durable_post(
|
|
547
|
+
action="pr-create",
|
|
548
|
+
title="fix: handle the acme widgets upload",
|
|
549
|
+
body_text=VALID_PR_BODY,
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
assert [each_finding.code for each_finding in all_findings] == ["private-term"]
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
@pytest.mark.usefixtures("private_fixture_terms")
|
|
556
|
+
def test_a_post_inside_the_private_organization_may_name_it() -> None:
|
|
557
|
+
all_findings = durable_post_lint.lint_durable_post(
|
|
558
|
+
action="pr-comment",
|
|
559
|
+
title=None,
|
|
560
|
+
body_text="See Acme-Widgets-Inc/tools#12.\n",
|
|
561
|
+
repository="Acme-Widgets-Inc/tools",
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
assert all_findings == ()
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
@pytest.mark.usefixtures("private_fixture_terms")
|
|
568
|
+
def test_a_post_to_a_public_repository_may_not_name_it() -> None:
|
|
569
|
+
all_findings = durable_post_lint.lint_durable_post(
|
|
570
|
+
action="pr-comment",
|
|
571
|
+
title=None,
|
|
572
|
+
body_text="See Acme-Widgets-Inc/tools#12.\n",
|
|
573
|
+
repository="pat/public-tools",
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
assert [each_finding.code for each_finding in all_findings] == ["private-term"]
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def test_cli_accepts_a_repository(tmp_path: Path) -> None:
|
|
580
|
+
body_file = tmp_path / "body.md"
|
|
581
|
+
body_file.write_text("Comment body.\n", encoding="utf-8")
|
|
582
|
+
completed_process = subprocess.run(
|
|
583
|
+
[
|
|
584
|
+
sys.executable,
|
|
585
|
+
str(SCRIPT_PATH),
|
|
586
|
+
"--action",
|
|
587
|
+
"pr-comment",
|
|
588
|
+
"--repository",
|
|
589
|
+
"pat/public-tools",
|
|
590
|
+
"--body-file",
|
|
591
|
+
str(body_file),
|
|
592
|
+
],
|
|
593
|
+
check=False,
|
|
594
|
+
capture_output=True,
|
|
595
|
+
text=True,
|
|
596
|
+
)
|
|
597
|
+
assert completed_process.returncode == 0
|
|
598
|
+
assert completed_process.stderr == ""
|