claude-dev-env 8.15.1 → 8.16.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.
- package/.agents/skills/second-account-workers/SKILL.md +26 -8
- package/.agents/skills/windows-scheduled-task/SKILL.md +24 -0
- package/package.json +1 -1
- package/scripts/claude_account_choice.py +183 -52
- package/scripts/claude_account_profile.py +81 -49
- package/scripts/claude_account_worker.py +48 -15
- package/scripts/dev_env_scripts_constants/claude_account_constants.py +36 -1
- package/scripts/dev_env_scripts_constants/claude_account_worker_constants.py +2 -0
- package/scripts/test_claude_account_choice.py +127 -17
- package/scripts/test_claude_account_profile.py +63 -16
- package/scripts/test_claude_account_worker.py +118 -0
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import argparse
|
|
6
6
|
import importlib
|
|
7
|
+
import json
|
|
7
8
|
import os
|
|
8
9
|
import shutil
|
|
9
10
|
import subprocess
|
|
@@ -14,7 +15,13 @@ from dataclasses import dataclass
|
|
|
14
15
|
from datetime import datetime
|
|
15
16
|
from pathlib import Path
|
|
16
17
|
|
|
17
|
-
from claude_account_choice import
|
|
18
|
+
from claude_account_choice import (
|
|
19
|
+
choose_account_from_extras,
|
|
20
|
+
config_directory_for_decision,
|
|
21
|
+
decision_payload,
|
|
22
|
+
read_account_meters,
|
|
23
|
+
read_extra_accounts,
|
|
24
|
+
)
|
|
18
25
|
from claude_account_profile import default_profile_home
|
|
19
26
|
from claude_account_worker_process import WorkerProcess, invoke_worker
|
|
20
27
|
from claude_account_worker_report import (
|
|
@@ -26,7 +33,6 @@ from claude_account_worker_report import (
|
|
|
26
33
|
)
|
|
27
34
|
from dev_env_scripts_constants.claude_account_worker_constants import (
|
|
28
35
|
CHOICE_MAIN,
|
|
29
|
-
CHOICE_SECOND,
|
|
30
36
|
CHOICE_WAIT,
|
|
31
37
|
CLAUDE_BINARY_NAME,
|
|
32
38
|
CLAUDE_CONFIG_DIR_ENV_VAR,
|
|
@@ -35,6 +41,7 @@ from dev_env_scripts_constants.claude_account_worker_constants import (
|
|
|
35
41
|
CWD_FLAG,
|
|
36
42
|
DEFAULT_PERMISSION_MODE,
|
|
37
43
|
DEFAULT_TIMEOUT_MINUTES,
|
|
44
|
+
EXTRA_PROFILES_FILE_NAME,
|
|
38
45
|
INVALID_TIMEOUT_MESSAGE,
|
|
39
46
|
JSON_ACCOUNT_KEY,
|
|
40
47
|
JSON_CONFIG_DIRECTORY_KEY,
|
|
@@ -104,6 +111,33 @@ def _selection_from_payload(all_selection_payload: Mapping[str, str | None]) ->
|
|
|
104
111
|
)
|
|
105
112
|
|
|
106
113
|
|
|
114
|
+
def extra_config_directories(main_config_dir: Path) -> tuple[Path, ...]:
|
|
115
|
+
"""Read the ordered local profile list or use the legacy default.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
main_config_dir: The main Claude home that may hold the list file.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
The extra profile homes in the order they are tried.
|
|
122
|
+
|
|
123
|
+
Raises:
|
|
124
|
+
ValueError: When the list is empty, holds a non-name, or repeats a name.
|
|
125
|
+
"""
|
|
126
|
+
profiles_file = main_config_dir / EXTRA_PROFILES_FILE_NAME
|
|
127
|
+
if not profiles_file.exists():
|
|
128
|
+
return (default_profile_home(),)
|
|
129
|
+
profile_names = json.loads(profiles_file.read_text(encoding=UTF8_ENCODING))
|
|
130
|
+
if (
|
|
131
|
+
not isinstance(profile_names, list)
|
|
132
|
+
or not profile_names
|
|
133
|
+
or any(not isinstance(name, str) for name in profile_names)
|
|
134
|
+
):
|
|
135
|
+
raise ValueError("extra profile list must be a nonempty JSON list of names")
|
|
136
|
+
if len({name.casefold() for name in profile_names}) != len(profile_names):
|
|
137
|
+
raise ValueError("extra profile names must be distinct")
|
|
138
|
+
return tuple(default_profile_home(name) for name in profile_names)
|
|
139
|
+
|
|
140
|
+
|
|
107
141
|
def select_account() -> AccountSelection:
|
|
108
142
|
"""Pick the account this worker runs on and its Claude home directory.
|
|
109
143
|
|
|
@@ -111,20 +145,19 @@ def select_account() -> AccountSelection:
|
|
|
111
145
|
The chosen account, its Claude config directory, and the reason.
|
|
112
146
|
|
|
113
147
|
Raises:
|
|
114
|
-
ValueError: When the
|
|
148
|
+
ValueError: When the profile list or account decision is invalid.
|
|
115
149
|
"""
|
|
116
150
|
main_config_dir = Path.home() / MAIN_CLAUDE_HOME_DIRECTORY_NAME
|
|
117
|
-
|
|
118
|
-
config_directory_by_account = {
|
|
119
|
-
CHOICE_MAIN: main_config_dir,
|
|
120
|
-
CHOICE_SECOND: second_config_dir,
|
|
121
|
-
}
|
|
151
|
+
extra_accounts = read_extra_accounts(extra_config_directories(main_config_dir))
|
|
122
152
|
main_meters = read_account_meters(main_config_dir / CREDENTIALS_FILE_NAME)
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
153
|
+
decision = choose_account_from_extras(
|
|
154
|
+
main_meters=main_meters,
|
|
155
|
+
all_extra_accounts=extra_accounts,
|
|
156
|
+
now=datetime.now().astimezone(),
|
|
157
|
+
)
|
|
158
|
+
selected_config_dir = config_directory_for_decision(
|
|
159
|
+
decision, main_config_dir=main_config_dir, all_extra_accounts=extra_accounts
|
|
126
160
|
)
|
|
127
|
-
selected_config_dir = config_directory_by_account.get(decision.account)
|
|
128
161
|
payload = decision_payload(decision, config_directory=selected_config_dir)
|
|
129
162
|
return _selection_from_payload(payload)
|
|
130
163
|
|
|
@@ -163,14 +196,14 @@ def _child_environment(
|
|
|
163
196
|
selection: AccountSelection,
|
|
164
197
|
all_environment_variables: Mapping[str, str],
|
|
165
198
|
) -> dict[str, str]:
|
|
166
|
-
if selection.account not in (CHOICE_MAIN, CHOICE_SECOND):
|
|
167
|
-
raise ValueError(f"unsupported account selection: {selection.account}")
|
|
168
199
|
all_child_environment_variables = dict(all_environment_variables)
|
|
169
200
|
if selection.account == CHOICE_MAIN:
|
|
170
201
|
all_child_environment_variables.pop(CLAUDE_CONFIG_DIR_ENV_VAR, None)
|
|
171
202
|
return all_child_environment_variables
|
|
203
|
+
if selection.account == CHOICE_WAIT:
|
|
204
|
+
raise ValueError("wait selection cannot launch a worker")
|
|
172
205
|
if selection.config_dir is None:
|
|
173
|
-
raise ValueError("
|
|
206
|
+
raise ValueError("extra account selection needs a config directory")
|
|
174
207
|
all_child_environment_variables[CLAUDE_CONFIG_DIR_ENV_VAR] = str(selection.config_dir)
|
|
175
208
|
return all_child_environment_variables
|
|
176
209
|
|
|
@@ -22,10 +22,28 @@ PROFILES_ROOT_ENVIRONMENT_VARIABLE: str = "LLM_SETTINGS_PROFILES_ROOT"
|
|
|
22
22
|
SECOND_ACCOUNT_PROFILE_NAME: str = "ev"
|
|
23
23
|
"""Profile directory name the second account signs in under."""
|
|
24
24
|
|
|
25
|
+
EXTRA_PROFILES_FILE_NAME: str = "extra-profiles.json"
|
|
26
|
+
"""Local main-home file listing extra profile names in selection order."""
|
|
27
|
+
|
|
28
|
+
PROFILE_NAME_PATTERN: str = r"[A-Za-z0-9][A-Za-z0-9_-]*"
|
|
29
|
+
"""Allowed profile names for directories and command launchers."""
|
|
30
|
+
|
|
31
|
+
ALL_WINDOWS_RESERVED_PROFILE_NAMES: frozenset[str] = frozenset(
|
|
32
|
+
{"con", "prn", "aux", "nul"}
|
|
33
|
+
| {f"com{number}" for number in range(1, 10)}
|
|
34
|
+
| {f"lpt{number}" for number in range(1, 10)}
|
|
35
|
+
)
|
|
36
|
+
"""Windows device names that cannot be used as profile directories."""
|
|
37
|
+
|
|
25
38
|
ALL_LAUNCHER_DIRECTORY_RELATIVE_PARTS: tuple[str, ...] = (".local", "bin")
|
|
26
39
|
"""Path parts under the user home of the directory on PATH that holds the launcher."""
|
|
27
40
|
|
|
28
|
-
|
|
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
|
+
)
|
|
29
47
|
"""File name of the launcher that runs Claude under the second account."""
|
|
30
48
|
|
|
31
49
|
LAUNCHER_TEXT_TEMPLATE: str = (
|
|
@@ -67,6 +85,7 @@ ALL_ACCOUNT_LOCAL_NAMES: frozenset[str] = frozenset(
|
|
|
67
85
|
"cache",
|
|
68
86
|
"paste-cache",
|
|
69
87
|
"stats-cache.json",
|
|
88
|
+
EXTRA_PROFILES_FILE_NAME,
|
|
70
89
|
}
|
|
71
90
|
)
|
|
72
91
|
"""Top-level Claude home entries that belong to one account and are never linked."""
|
|
@@ -135,6 +154,22 @@ REASON_SECOND_UNREAD: str = (
|
|
|
135
154
|
)
|
|
136
155
|
"""Reason when the second account's meter cannot be read and it takes the job anyway."""
|
|
137
156
|
|
|
157
|
+
REASON_EXTRA_HAS_ROOM_TEMPLATE: str = (
|
|
158
|
+
"{account} has {weekly_remaining_percent:.0f}% of its week"
|
|
159
|
+
" and {session_remaining_percent:.0f}% of its 5-hour window left"
|
|
160
|
+
)
|
|
161
|
+
"""Reason when a later extra profile takes the job."""
|
|
162
|
+
|
|
163
|
+
REASON_EXTRA_UNREAD_TEMPLATE: str = (
|
|
164
|
+
"{account} meter unreadable; its own run refreshes the sign-in"
|
|
165
|
+
)
|
|
166
|
+
"""Reason when a later extra profile's meter cannot be read."""
|
|
167
|
+
|
|
168
|
+
REASON_ALL_EXTRAS_WAIT_TEMPLATE: str = (
|
|
169
|
+
"all extra accounts are full; next known reset {next_reset}"
|
|
170
|
+
)
|
|
171
|
+
"""Reason when every configured extra profile is blocked."""
|
|
172
|
+
|
|
138
173
|
REASON_WAIT_TEMPLATE: str = (
|
|
139
174
|
"second account is at {weekly_used_percent:.0f}% of its week"
|
|
140
175
|
" and {session_used_percent:.0f}% of its 5-hour window; next reset {next_reset}"
|
|
@@ -7,6 +7,7 @@ from dev_env_scripts_constants.claude_account_constants import (
|
|
|
7
7
|
CHOICE_SECOND,
|
|
8
8
|
CHOICE_WAIT,
|
|
9
9
|
CREDENTIALS_FILE_NAME,
|
|
10
|
+
EXTRA_PROFILES_FILE_NAME,
|
|
10
11
|
JSON_ACCOUNT_KEY,
|
|
11
12
|
JSON_CONFIG_DIRECTORY_KEY,
|
|
12
13
|
JSON_REASON_KEY,
|
|
@@ -103,6 +104,7 @@ __all__ = [
|
|
|
103
104
|
"DRAIN_ATTEMPT_LIMIT",
|
|
104
105
|
"DRAIN_GRACE_TIMEOUT_SECONDS",
|
|
105
106
|
"DURATION_DECIMAL_PLACES",
|
|
107
|
+
"EXTRA_PROFILES_FILE_NAME",
|
|
106
108
|
"INVALID_TIMEOUT_MESSAGE",
|
|
107
109
|
"JSON_ACCOUNT_KEY",
|
|
108
110
|
"JSON_CONFIG_DIRECTORY_KEY",
|
|
@@ -30,7 +30,7 @@ def meters(
|
|
|
30
30
|
|
|
31
31
|
|
|
32
32
|
class TestChooseAccount:
|
|
33
|
-
def
|
|
33
|
+
def test_should_pick_main_when_week_resets_soon(self) -> None:
|
|
34
34
|
main_meters = meters(
|
|
35
35
|
session_used=10.0, weekly_used=80.0, weekly_resets_in=timedelta(hours=5)
|
|
36
36
|
)
|
|
@@ -42,7 +42,7 @@ class TestChooseAccount:
|
|
|
42
42
|
)
|
|
43
43
|
assert decision.account == choice.CHOICE_MAIN
|
|
44
44
|
|
|
45
|
-
def
|
|
45
|
+
def test_should_pick_second_when_main_week_resets_later_than_a_day(self) -> None:
|
|
46
46
|
main_meters = meters(
|
|
47
47
|
session_used=0.0, weekly_used=5.0, weekly_resets_in=timedelta(hours=30)
|
|
48
48
|
)
|
|
@@ -54,7 +54,7 @@ class TestChooseAccount:
|
|
|
54
54
|
)
|
|
55
55
|
assert decision.account == choice.CHOICE_SECOND
|
|
56
56
|
|
|
57
|
-
def
|
|
57
|
+
def test_should_keep_main_once_it_nears_its_weekly_ceiling(self) -> None:
|
|
58
58
|
main_meters = meters(
|
|
59
59
|
session_used=0.0, weekly_used=90.0, weekly_resets_in=timedelta(hours=3)
|
|
60
60
|
)
|
|
@@ -66,7 +66,7 @@ class TestChooseAccount:
|
|
|
66
66
|
)
|
|
67
67
|
assert decision.account == choice.CHOICE_SECOND
|
|
68
68
|
|
|
69
|
-
def
|
|
69
|
+
def test_should_keep_main_when_its_five_hour_window_is_half_spent(self) -> None:
|
|
70
70
|
main_meters = meters(
|
|
71
71
|
session_used=50.0, weekly_used=20.0, weekly_resets_in=timedelta(hours=3)
|
|
72
72
|
)
|
|
@@ -78,7 +78,7 @@ class TestChooseAccount:
|
|
|
78
78
|
)
|
|
79
79
|
assert decision.account == choice.CHOICE_SECOND
|
|
80
80
|
|
|
81
|
-
def
|
|
81
|
+
def test_should_never_pick_main_when_its_meter_is_unread(self) -> None:
|
|
82
82
|
second_meters = meters(
|
|
83
83
|
session_used=95.0, weekly_used=99.0, weekly_resets_in=timedelta(days=4)
|
|
84
84
|
)
|
|
@@ -87,7 +87,7 @@ class TestChooseAccount:
|
|
|
87
87
|
)
|
|
88
88
|
assert decision.account == choice.CHOICE_WAIT
|
|
89
89
|
|
|
90
|
-
def
|
|
90
|
+
def test_should_never_pick_main_when_its_reset_time_is_missing(self) -> None:
|
|
91
91
|
main_meters = meters(session_used=0.0, weekly_used=0.0, weekly_resets_in=None)
|
|
92
92
|
second_meters = meters(
|
|
93
93
|
session_used=95.0, weekly_used=99.0, weekly_resets_in=timedelta(days=4)
|
|
@@ -97,12 +97,12 @@ class TestChooseAccount:
|
|
|
97
97
|
)
|
|
98
98
|
assert decision.account == choice.CHOICE_WAIT
|
|
99
99
|
|
|
100
|
-
def
|
|
100
|
+
def test_should_pick_second_when_its_meter_is_unread(self) -> None:
|
|
101
101
|
decision = choice.choose_account(main_meters=None, second_meters=None, now=NOW)
|
|
102
102
|
assert decision.account == choice.CHOICE_SECOND
|
|
103
103
|
assert decision.reason == choice.REASON_SECOND_UNREAD
|
|
104
104
|
|
|
105
|
-
def
|
|
105
|
+
def test_should_wait_and_name_the_session_reset_when_the_five_hour_window_blocks(
|
|
106
106
|
self,
|
|
107
107
|
) -> None:
|
|
108
108
|
second_meters = meters(
|
|
@@ -117,7 +117,7 @@ class TestChooseAccount:
|
|
|
117
117
|
assert decision.account == choice.CHOICE_WAIT
|
|
118
118
|
assert (NOW + timedelta(hours=1)).isoformat() in decision.reason
|
|
119
119
|
|
|
120
|
-
def
|
|
120
|
+
def test_should_wait_and_name_the_weekly_reset_when_the_week_blocks(self) -> None:
|
|
121
121
|
second_meters = meters(
|
|
122
122
|
session_used=10.0, weekly_used=95.0, weekly_resets_in=timedelta(days=2)
|
|
123
123
|
)
|
|
@@ -129,7 +129,7 @@ class TestChooseAccount:
|
|
|
129
129
|
|
|
130
130
|
|
|
131
131
|
class TestReadAccountMeters:
|
|
132
|
-
def
|
|
132
|
+
def test_should_return_none_when_the_probe_cannot_read_the_account(
|
|
133
133
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
134
134
|
) -> None:
|
|
135
135
|
def failing_probe(credentials_path: Path) -> AccountUsageMeters:
|
|
@@ -138,7 +138,7 @@ class TestReadAccountMeters:
|
|
|
138
138
|
monkeypatch.setattr(choice, "probe_account_meters", failing_probe)
|
|
139
139
|
assert choice.read_account_meters(tmp_path / ".credentials.json") is None
|
|
140
140
|
|
|
141
|
-
def
|
|
141
|
+
def test_should_return_the_probed_meters(
|
|
142
142
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
143
143
|
) -> None:
|
|
144
144
|
probed = meters(
|
|
@@ -149,7 +149,7 @@ class TestReadAccountMeters:
|
|
|
149
149
|
|
|
150
150
|
|
|
151
151
|
class TestDecisionPayload:
|
|
152
|
-
def
|
|
152
|
+
def test_should_name_no_config_directory_for_a_wait(self) -> None:
|
|
153
153
|
decision = choice.AccountDecision(account=choice.CHOICE_WAIT, reason="full")
|
|
154
154
|
assert choice.decision_payload(decision, config_directory=None) == {
|
|
155
155
|
"account": "wait",
|
|
@@ -159,10 +159,10 @@ class TestDecisionPayload:
|
|
|
159
159
|
|
|
160
160
|
|
|
161
161
|
class TestMetersPayload:
|
|
162
|
-
def
|
|
162
|
+
def test_should_name_no_meters_for_an_unread_account(self) -> None:
|
|
163
163
|
assert choice.meters_payload(None) is None
|
|
164
164
|
|
|
165
|
-
def
|
|
165
|
+
def test_should_carry_each_used_percent_and_reset_time(self) -> None:
|
|
166
166
|
read_meters = meters(
|
|
167
167
|
session_used=12.0,
|
|
168
168
|
weekly_used=34.0,
|
|
@@ -176,7 +176,7 @@ class TestMetersPayload:
|
|
|
176
176
|
"weekly_resets_at": "2026-09-25T21:00:00+00:00",
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
-
def
|
|
179
|
+
def test_should_name_no_reset_time_the_meter_left_out(self) -> None:
|
|
180
180
|
read_meters = meters(session_used=1.0, weekly_used=None, weekly_resets_in=None)
|
|
181
181
|
payload = choice.meters_payload(read_meters)
|
|
182
182
|
assert payload is not None
|
|
@@ -185,7 +185,7 @@ class TestMetersPayload:
|
|
|
185
185
|
|
|
186
186
|
|
|
187
187
|
class TestMain:
|
|
188
|
-
def
|
|
188
|
+
def test_should_print_the_choice_and_the_config_directory_of_the_chosen_account(
|
|
189
189
|
self,
|
|
190
190
|
tmp_path: Path,
|
|
191
191
|
monkeypatch: pytest.MonkeyPatch,
|
|
@@ -219,7 +219,7 @@ class TestMain:
|
|
|
219
219
|
assert printed["meters"]["main"] is None
|
|
220
220
|
assert printed["meters"]["second"]["weekly_used_percent"] == 10.0
|
|
221
221
|
|
|
222
|
-
def
|
|
222
|
+
def test_should_print_no_config_directory_when_the_job_waits(
|
|
223
223
|
self,
|
|
224
224
|
tmp_path: Path,
|
|
225
225
|
monkeypatch: pytest.MonkeyPatch,
|
|
@@ -243,3 +243,113 @@ class TestMain:
|
|
|
243
243
|
assert exit_code == 0
|
|
244
244
|
assert printed["account"] == choice.CHOICE_WAIT
|
|
245
245
|
assert printed["config_dir"] is None
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def test_should_pick_second_extra_in_order_and_print_every_meter(
|
|
249
|
+
tmp_path: Path,
|
|
250
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
251
|
+
capsys: pytest.CaptureFixture[str],
|
|
252
|
+
) -> None:
|
|
253
|
+
main_home = tmp_path / "main"
|
|
254
|
+
first_extra = tmp_path / "first"
|
|
255
|
+
second_extra = tmp_path / "next"
|
|
256
|
+
third_extra = tmp_path / "last"
|
|
257
|
+
readings = {
|
|
258
|
+
main_home: None,
|
|
259
|
+
first_extra: meters(
|
|
260
|
+
session_used=90.0, weekly_used=95.0, weekly_resets_in=timedelta(days=4)
|
|
261
|
+
),
|
|
262
|
+
second_extra: meters(
|
|
263
|
+
session_used=10.0, weekly_used=20.0, weekly_resets_in=timedelta(days=4)
|
|
264
|
+
),
|
|
265
|
+
third_extra: meters(
|
|
266
|
+
session_used=30.0, weekly_used=40.0, weekly_resets_in=timedelta(days=4)
|
|
267
|
+
),
|
|
268
|
+
}
|
|
269
|
+
monkeypatch.setattr(
|
|
270
|
+
choice, "read_account_meters", lambda path: readings[path.parent]
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
assert choice.main(
|
|
274
|
+
[
|
|
275
|
+
"--main-config-dir",
|
|
276
|
+
str(main_home),
|
|
277
|
+
"--second-config-dir",
|
|
278
|
+
str(first_extra),
|
|
279
|
+
"--extra-config-dir",
|
|
280
|
+
str(second_extra),
|
|
281
|
+
"--extra-config-dir",
|
|
282
|
+
str(third_extra),
|
|
283
|
+
]
|
|
284
|
+
) == 0
|
|
285
|
+
printed = json.loads(capsys.readouterr().out)
|
|
286
|
+
|
|
287
|
+
assert printed["account"] == "extra_2"
|
|
288
|
+
assert printed["config_dir"] == str(second_extra)
|
|
289
|
+
assert list(printed["meters"]) == ["main", "second", "extra_2", "extra_3"]
|
|
290
|
+
assert printed["meters"]["main"] is None
|
|
291
|
+
assert printed["meters"]["second"]["weekly_used_percent"] == 95.0
|
|
292
|
+
assert printed["meters"]["extra_2"]["weekly_used_percent"] == 20.0
|
|
293
|
+
assert printed["meters"]["extra_3"]["weekly_used_percent"] == 40.0
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def test_should_wait_for_first_usable_reset_when_all_extras_are_full(
|
|
297
|
+
tmp_path: Path,
|
|
298
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
299
|
+
capsys: pytest.CaptureFixture[str],
|
|
300
|
+
) -> None:
|
|
301
|
+
first_extra = tmp_path / "first"
|
|
302
|
+
second_extra = tmp_path / "next"
|
|
303
|
+
third_extra = tmp_path / "last"
|
|
304
|
+
readings = {
|
|
305
|
+
first_extra: meters(
|
|
306
|
+
session_used=90.0,
|
|
307
|
+
weekly_used=95.0,
|
|
308
|
+
weekly_resets_in=timedelta(hours=1),
|
|
309
|
+
session_resets_in=timedelta(hours=3),
|
|
310
|
+
),
|
|
311
|
+
second_extra: meters(
|
|
312
|
+
session_used=10.0, weekly_used=95.0, weekly_resets_in=timedelta(hours=2)
|
|
313
|
+
),
|
|
314
|
+
third_extra: meters(
|
|
315
|
+
session_used=90.0, weekly_used=10.0, weekly_resets_in=timedelta(days=4),
|
|
316
|
+
session_resets_in=timedelta(hours=4),
|
|
317
|
+
),
|
|
318
|
+
}
|
|
319
|
+
monkeypatch.setattr(
|
|
320
|
+
choice, "read_account_meters", lambda path: readings.get(path.parent)
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
assert choice.main(
|
|
324
|
+
[
|
|
325
|
+
"--main-config-dir",
|
|
326
|
+
str(tmp_path / "main"),
|
|
327
|
+
"--second-config-dir",
|
|
328
|
+
str(first_extra),
|
|
329
|
+
"--extra-config-dir",
|
|
330
|
+
str(second_extra),
|
|
331
|
+
"--extra-config-dir",
|
|
332
|
+
str(third_extra),
|
|
333
|
+
]
|
|
334
|
+
) == 0
|
|
335
|
+
printed = json.loads(capsys.readouterr().out)
|
|
336
|
+
|
|
337
|
+
assert printed["account"] == choice.CHOICE_WAIT
|
|
338
|
+
assert printed["config_dir"] is None
|
|
339
|
+
assert (NOW + timedelta(hours=2)).isoformat() in printed["reason"]
|
|
340
|
+
assert (NOW + timedelta(hours=1)).isoformat() not in printed["reason"]
|
|
341
|
+
assert list(printed["meters"]) == ["main", "second", "extra_2", "extra_3"]
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def test_should_reject_duplicate_extra_config_directories(tmp_path: Path) -> None:
|
|
345
|
+
with pytest.raises(SystemExit) as error:
|
|
346
|
+
choice.main(
|
|
347
|
+
[
|
|
348
|
+
"--second-config-dir",
|
|
349
|
+
str(tmp_path / "first"),
|
|
350
|
+
"--extra-config-dir",
|
|
351
|
+
str(tmp_path / "first"),
|
|
352
|
+
]
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
assert error.value.code == 2
|
|
@@ -32,13 +32,13 @@ def sync(main_home: Path, profile_home: Path) -> profile.ProfileSyncReport:
|
|
|
32
32
|
|
|
33
33
|
|
|
34
34
|
class TestDefaultProfileHome:
|
|
35
|
-
def
|
|
35
|
+
def test_should_use_the_profiles_root_the_environment_names(
|
|
36
36
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
37
37
|
) -> None:
|
|
38
38
|
monkeypatch.setenv("LLM_SETTINGS_PROFILES_ROOT", str(tmp_path))
|
|
39
39
|
assert profile.default_profile_home() == tmp_path / "ev"
|
|
40
40
|
|
|
41
|
-
def
|
|
41
|
+
def test_should_fall_back_to_the_profiles_root_in_the_user_home(
|
|
42
42
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
43
43
|
) -> None:
|
|
44
44
|
monkeypatch.delenv("LLM_SETTINGS_PROFILES_ROOT", raising=False)
|
|
@@ -47,23 +47,24 @@ class TestDefaultProfileHome:
|
|
|
47
47
|
|
|
48
48
|
|
|
49
49
|
class TestIsAccountLocal:
|
|
50
|
-
def
|
|
50
|
+
def test_should_keep_sign_in_state_and_history_local(self) -> None:
|
|
51
51
|
for each_name in (
|
|
52
52
|
".credentials.json",
|
|
53
53
|
".credentials.json.bak-session-dashboard",
|
|
54
54
|
".claude.json",
|
|
55
55
|
".claude.json.backup",
|
|
56
|
+
"extra-profiles.json",
|
|
56
57
|
"projects",
|
|
57
58
|
):
|
|
58
59
|
assert profile.is_account_local(each_name)
|
|
59
60
|
|
|
60
|
-
def
|
|
61
|
+
def test_should_share_skills_rules_and_settings(self) -> None:
|
|
61
62
|
for each_name in ("skills", "rules", "CLAUDE.md", "settings.json", "plugins"):
|
|
62
63
|
assert not profile.is_account_local(each_name)
|
|
63
64
|
|
|
64
65
|
|
|
65
66
|
class TestSyncProfile:
|
|
66
|
-
def
|
|
67
|
+
def test_should_link_every_shared_entry_to_the_main_home(self, tmp_path: Path) -> None:
|
|
67
68
|
main_home = build_main_home(tmp_path)
|
|
68
69
|
profile_home = tmp_path / "ev"
|
|
69
70
|
sync(main_home, profile_home)
|
|
@@ -76,7 +77,7 @@ class TestSyncProfile:
|
|
|
76
77
|
profile_home / "settings.json", main_home / "settings.json"
|
|
77
78
|
)
|
|
78
79
|
|
|
79
|
-
def
|
|
80
|
+
def test_should_link_only_what_the_callers_rule_shares(self, tmp_path: Path) -> None:
|
|
80
81
|
main_home = build_main_home(tmp_path)
|
|
81
82
|
profile_home = tmp_path / "codex-1"
|
|
82
83
|
report = profile.sync_profile(
|
|
@@ -88,7 +89,7 @@ class TestSyncProfile:
|
|
|
88
89
|
assert report.all_linked == ("rules",)
|
|
89
90
|
assert not (profile_home / "skills").exists()
|
|
90
91
|
|
|
91
|
-
def
|
|
92
|
+
def test_should_keep_the_accounts_own_sign_in_state_and_history_apart(
|
|
92
93
|
self, tmp_path: Path
|
|
93
94
|
) -> None:
|
|
94
95
|
main_home = build_main_home(tmp_path)
|
|
@@ -105,7 +106,7 @@ class TestSyncProfile:
|
|
|
105
106
|
assert not (profile_home / ".claude.json.backup").exists()
|
|
106
107
|
assert not (profile_home / "projects").exists()
|
|
107
108
|
|
|
108
|
-
def
|
|
109
|
+
def test_should_move_a_stale_copy_aside_and_keep_its_content(
|
|
109
110
|
self, tmp_path: Path
|
|
110
111
|
) -> None:
|
|
111
112
|
main_home = build_main_home(tmp_path)
|
|
@@ -120,7 +121,7 @@ class TestSyncProfile:
|
|
|
120
121
|
assert profile.links_to(profile_home / "CLAUDE.md", main_home / "CLAUDE.md")
|
|
121
122
|
assert report.all_moved_aside == ("CLAUDE.md",)
|
|
122
123
|
|
|
123
|
-
def
|
|
124
|
+
def test_should_remove_a_same_content_copy_without_moving_it_aside(
|
|
124
125
|
self, tmp_path: Path
|
|
125
126
|
) -> None:
|
|
126
127
|
main_home = build_main_home(tmp_path)
|
|
@@ -132,7 +133,7 @@ class TestSyncProfile:
|
|
|
132
133
|
assert not (profile_home / ".replaced").exists()
|
|
133
134
|
assert profile.links_to(profile_home / "CLAUDE.md", main_home / "CLAUDE.md")
|
|
134
135
|
|
|
135
|
-
def
|
|
136
|
+
def test_should_move_aside_a_link_that_points_somewhere_else(
|
|
136
137
|
self, tmp_path: Path
|
|
137
138
|
) -> None:
|
|
138
139
|
main_home = build_main_home(tmp_path)
|
|
@@ -147,7 +148,7 @@ class TestSyncProfile:
|
|
|
147
148
|
assert profile.links_to(profile_home / "rules", main_home / "rules")
|
|
148
149
|
assert report.all_moved_aside == ("rules",)
|
|
149
150
|
|
|
150
|
-
def
|
|
151
|
+
def test_should_change_nothing_on_a_second_run(self, tmp_path: Path) -> None:
|
|
151
152
|
main_home = build_main_home(tmp_path)
|
|
152
153
|
profile_home = tmp_path / "ev"
|
|
153
154
|
sync(main_home, profile_home)
|
|
@@ -156,7 +157,7 @@ class TestSyncProfile:
|
|
|
156
157
|
assert second_report.all_moved_aside == ()
|
|
157
158
|
assert second_report.all_unlinked == ()
|
|
158
159
|
|
|
159
|
-
def
|
|
160
|
+
def test_should_unlink_an_entry_the_main_home_no_longer_has(
|
|
160
161
|
self, tmp_path: Path
|
|
161
162
|
) -> None:
|
|
162
163
|
main_home = build_main_home(tmp_path)
|
|
@@ -168,7 +169,7 @@ class TestSyncProfile:
|
|
|
168
169
|
assert report.all_unlinked == ("rules",)
|
|
169
170
|
|
|
170
171
|
|
|
171
|
-
def
|
|
172
|
+
def test_should_remove_a_main_link_left_under_an_account_local_name(
|
|
172
173
|
self, tmp_path: Path
|
|
173
174
|
) -> None:
|
|
174
175
|
main_home = build_main_home(tmp_path)
|
|
@@ -185,7 +186,7 @@ class TestSyncProfile:
|
|
|
185
186
|
|
|
186
187
|
|
|
187
188
|
class TestWriteLauncher:
|
|
188
|
-
def
|
|
189
|
+
def test_should_point_claude_at_the_profile_and_pass_every_argument(
|
|
189
190
|
self, tmp_path: Path
|
|
190
191
|
) -> None:
|
|
191
192
|
profile_home = tmp_path / "ev"
|
|
@@ -196,7 +197,7 @@ class TestWriteLauncher:
|
|
|
196
197
|
assert f'set "CLAUDE_CONFIG_DIR={profile_home}"' in launcher_text
|
|
197
198
|
assert "claude %*" in launcher_text
|
|
198
199
|
|
|
199
|
-
def
|
|
200
|
+
def test_should_move_an_older_launcher_aside(self, tmp_path: Path) -> None:
|
|
200
201
|
launcher_directory = tmp_path / "bin"
|
|
201
202
|
launcher_directory.mkdir()
|
|
202
203
|
(launcher_directory / "claude-ev.cmd").write_text(
|
|
@@ -212,7 +213,7 @@ class TestWriteLauncher:
|
|
|
212
213
|
|
|
213
214
|
|
|
214
215
|
class TestMain:
|
|
215
|
-
def
|
|
216
|
+
def test_should_print_what_the_sync_changed(
|
|
216
217
|
self, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
217
218
|
) -> None:
|
|
218
219
|
main_home = build_main_home(tmp_path)
|
|
@@ -230,3 +231,49 @@ class TestMain:
|
|
|
230
231
|
assert exit_code == 0
|
|
231
232
|
assert "CLAUDE.md" in printed["linked"]
|
|
232
233
|
assert printed["launcher"] == str(tmp_path / "bin" / "claude-ev.cmd")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def test_should_create_named_profile_and_launcher_without_changes_on_repeat(
|
|
237
|
+
tmp_path: Path,
|
|
238
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
239
|
+
capsys: pytest.CaptureFixture[str],
|
|
240
|
+
) -> None:
|
|
241
|
+
main_home = build_main_home(tmp_path)
|
|
242
|
+
(main_home / "extra-profiles.json").write_text("[]", encoding="utf-8")
|
|
243
|
+
profiles_root = tmp_path / "profiles"
|
|
244
|
+
launcher_directory = tmp_path / "bin"
|
|
245
|
+
monkeypatch.setenv("LLM_SETTINGS_PROFILES_ROOT", str(profiles_root))
|
|
246
|
+
arguments = [
|
|
247
|
+
"--main-home",
|
|
248
|
+
str(main_home),
|
|
249
|
+
"--profile-name",
|
|
250
|
+
"profile-3",
|
|
251
|
+
"--launcher-directory",
|
|
252
|
+
str(launcher_directory),
|
|
253
|
+
]
|
|
254
|
+
|
|
255
|
+
assert profile.main(arguments) == 0
|
|
256
|
+
first_report = json.loads(capsys.readouterr().out)
|
|
257
|
+
profile_home = profiles_root / "profile-3"
|
|
258
|
+
launcher_path = launcher_directory / "claude-profile-3.cmd"
|
|
259
|
+
assert profile.links_to(profile_home / "CLAUDE.md", main_home / "CLAUDE.md")
|
|
260
|
+
assert not (profile_home / "extra-profiles.json").exists()
|
|
261
|
+
assert first_report["launcher"] == str(launcher_path)
|
|
262
|
+
assert f'set "CLAUDE_CONFIG_DIR={profile_home}"' in launcher_path.read_text(
|
|
263
|
+
encoding="utf-8"
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
assert profile.main(arguments) == 0
|
|
267
|
+
second_report = json.loads(capsys.readouterr().out)
|
|
268
|
+
assert second_report["linked"] == []
|
|
269
|
+
assert second_report["moved_aside"] == []
|
|
270
|
+
assert second_report["unlinked"] == []
|
|
271
|
+
assert list(launcher_directory.glob("*.replaced-*")) == []
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@pytest.mark.parametrize(
|
|
275
|
+
"profile_name", ["../outside", "bad name", "CON", "main", "wait"]
|
|
276
|
+
)
|
|
277
|
+
def test_should_reject_unsafe_profile_names(profile_name: str) -> None:
|
|
278
|
+
with pytest.raises(ValueError):
|
|
279
|
+
profile.default_profile_home(profile_name)
|