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.
@@ -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 choose_account, decision_payload, read_account_meters
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 account picker returns an incomplete decision.
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
- second_config_dir = default_profile_home()
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
- second_meters = read_account_meters(second_config_dir / CREDENTIALS_FILE_NAME)
124
- decision = choose_account(
125
- main_meters=main_meters, second_meters=second_meters, now=datetime.now().astimezone()
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("second account selection needs a config directory")
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
- LAUNCHER_FILE_NAME: str = "claude-ev.cmd"
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 should_pick_main_in_jons_example(self) -> None:
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 should_pick_second_when_main_week_resets_later_than_a_day(self) -> None:
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 should_keep_main_once_it_nears_its_weekly_ceiling(self) -> None:
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 should_keep_main_when_its_five_hour_window_is_half_spent(self) -> None:
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 should_never_pick_main_when_its_meter_is_unread(self) -> None:
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 should_never_pick_main_when_its_reset_time_is_missing(self) -> None:
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 should_pick_second_when_its_meter_is_unread(self) -> None:
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 should_wait_and_name_the_session_reset_when_the_five_hour_window_blocks(
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 should_wait_and_name_the_weekly_reset_when_the_week_blocks(self) -> None:
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 should_return_none_when_the_probe_cannot_read_the_account(
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 should_return_the_probed_meters(
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 should_name_no_config_directory_for_a_wait(self) -> None:
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 should_name_no_meters_for_an_unread_account(self) -> None:
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 should_carry_each_used_percent_and_reset_time(self) -> None:
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 should_name_no_reset_time_the_meter_left_out(self) -> None:
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 should_print_the_choice_and_the_config_directory_of_the_chosen_account(
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 should_print_no_config_directory_when_the_job_waits(
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 should_use_the_profiles_root_the_environment_names(
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 should_fall_back_to_the_profiles_root_in_the_user_home(
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 should_keep_sign_in_state_and_history_local(self) -> None:
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 should_share_skills_rules_and_settings(self) -> None:
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 should_link_every_shared_entry_to_the_main_home(self, tmp_path: Path) -> None:
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 should_link_only_what_the_callers_rule_shares(self, tmp_path: Path) -> None:
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 should_keep_the_accounts_own_sign_in_state_and_history_apart(
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 should_move_a_stale_copy_aside_and_keep_its_content(
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 should_remove_a_same_content_copy_without_moving_it_aside(
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 should_move_aside_a_link_that_points_somewhere_else(
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 should_change_nothing_on_a_second_run(self, tmp_path: Path) -> None:
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 should_unlink_an_entry_the_main_home_no_longer_has(
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 should_remove_a_main_link_left_under_an_account_local_name(
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 should_point_claude_at_the_profile_and_pass_every_argument(
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 should_move_an_older_launcher_aside(self, tmp_path: Path) -> None:
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 should_print_what_the_sync_changed(
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)