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.
@@ -1,16 +1,33 @@
1
1
  ---
2
2
  name: second-account-workers
3
3
  description: >-
4
- Run local headless Claude workers through the second-account picker.
5
- Triggers: spawn a worker on the second account, offload a worker,
6
- claude-ev worker, save main account usage.
4
+ Run local headless Claude workers through the account picker.
5
+ Triggers: spawn a worker on the second account or another extra account, offload a worker,
6
+ use a named Claude profile, save main account usage.
7
7
  ---
8
8
 
9
- # Second Account Workers
9
+ # Extra account workers
10
10
 
11
11
  Use this skill when a task needs a local Claude worker and the session should
12
12
  save main account usage.
13
13
 
14
+ ## Configure extra profiles
15
+
16
+ Run `claude_account_profile.py --profile-name NAME` for each extra profile.
17
+ The script creates `<profiles root>/NAME` and the `claude-NAME.cmd` launcher.
18
+ Running it again with the same name leaves the profile and launcher in place.
19
+
20
+ To set worker order, put `extra-profiles.json` in the main Claude home. Write a
21
+ JSON list of profile names, with the first choice first. Names use letters,
22
+ digits, hyphens, and underscores. The worker uses the existing second profile
23
+ when the file is absent. When the file exists, its list sets the full order.
24
+ Keep `main` and `wait` out of the list because they name picker decisions.
25
+ The picker tries each profile after checking the main account's expiring usage.
26
+ For direct picker calls, `--second-config-dir` sets the first extra profile and
27
+ each `--extra-config-dir` adds another in the order given.
28
+ Picker JSON prints `config_dir` for the choice and meters under `main`, `second`,
29
+ `extra_2`, and later labels.
30
+
14
31
  ## Choose a worker
15
32
 
16
33
  Keep the Agent tool for small, fast lookups. For any worker that edits code,
@@ -34,10 +51,11 @@ Read the JSON report and worker log after the process ends. The JSON fields are
34
51
  The runner writes one summary line with the selected account, exit code, and
35
52
  report path.
36
53
 
37
- When the command exits 3 and the report has `"account": "wait"`, both accounts
38
- are out of room. Report the picker's reason and reset time. Do not fall back to
39
- the Agent tool. Claude can also return exit 3 after a worker starts. When the
40
- report account is `main` or `second`, report the child's exit code and result.
54
+ When the command exits 3 and the report has `"account": "wait"`, no account is
55
+ eligible. Report the picker's reason and reset time. Do not fall back to the
56
+ Agent tool. Claude can also return exit 3 after a worker starts. When the
57
+ report account is `main`, `second`, or `extra_2` and later, report the child's
58
+ exit code and result.
41
59
 
42
60
  Workers never commit, push, or call `gh`. The calling session reviews each
43
61
  worktree diff and owns every Git step.
@@ -20,6 +20,7 @@ description: >-
20
20
  - Status
21
21
  - Removal and teardown
22
22
  - Self-modification warning
23
+ - Handing the elevated step to the operator
23
24
  - Verification
24
25
  - Files
25
26
 
@@ -36,6 +37,8 @@ prove the task by the artifact it changes.
36
37
  - Task Scheduler inherits none of the shell PATH. A bare executable name in the action fails at run time.
37
38
  - A helper under an installer-managed path deletes itself during an installer run.
38
39
  - `LastTaskResult: 0` from a run that took the idle branch says nothing about the branch that acts.
40
+ - A `#Requires -RunAsAdministrator` line pasted at a prompt checks nothing. It applies only to a script file.
41
+ - An elevated window opens in `C:\WINDOWS\system32`. A prompt in the user's home folder is usually unelevated.
39
42
 
40
43
  ## When this applies
41
44
 
@@ -134,6 +137,27 @@ instructions on a timer. A major upgrade can land mid-session while the agent ho
134
137
  Give the operator the teardown command, name the interval, and keep the interval wide enough for a
135
138
  session to finish between runs.
136
139
 
140
+ ## Handing the elevated step to the operator
141
+
142
+ The operator runs the elevated part by hand, so hand it over as one block they paste into an
143
+ Administrator window. Put the block in the message itself. A file to download adds a step and a
144
+ path the browser can rename.
145
+
146
+ Wrap the block in `& { ... }` and open it with an elevation check, so an unelevated paste stops
147
+ with a clear line and changes nothing:
148
+
149
+ ```powershell
150
+ & {
151
+ $ErrorActionPreference = 'Stop'
152
+ $IsAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
153
+ if (-not $IsAdmin) { Write-Host 'NOT ELEVATED. Open PowerShell with Run as administrator and paste again.' -ForegroundColor Red; return }
154
+ Register-ScheduledTask -TaskName $TaskName -Action $Action -Trigger $Trigger -Settings $Settings -Principal $Principal -Force
155
+ }
156
+ ```
157
+
158
+ The script block scopes `$ErrorActionPreference` and lets `return` end the paste. End the block
159
+ with a status print the operator copies back, so the next step reads the result.
160
+
137
161
  ## Verification
138
162
 
139
163
  A run that takes the idle branch reports `LastTaskResult: 0`, and that result covers the idle branch
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.15.1",
3
+ "version": "8.16.1",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,21 +1,8 @@
1
1
  #!/usr/bin/env python3
2
- """Name the Claude account a runner job uses, protecting the main account.
2
+ """Choose a Claude account for a runner job while protecting the main account.
3
3
 
4
- The main account is the one its owner works on, so a job borrows it only to
5
- spend leftover usage that expires soon. Every other job goes to the second
6
- account while that account has room, and waits when it has none::
7
-
8
- main week resets in 5 hours, 20% left, 5-hour window 10% used -> main
9
- main week resets in 3 days -> second
10
- second at 99% of its week, main not spendable -> wait
11
- main meter unread -> never main
12
-
13
- ::
14
-
15
- python claude_account_choice.py --main-config-dir C:/Users/me/.claude
16
- --second-config-dir C:/Users/me/.claude-profiles/ev
17
- {"account": "second", "config_dir": "C:/Users/me/.claude-profiles/ev",
18
- "reason": "second account has 60% of its week and 90% of its 5-hour window left"}
4
+ The main account takes a job only to spend usage that expires soon. Otherwise,
5
+ the picker tries extra profiles in order and waits when every one is full.
19
6
  """
20
7
 
21
8
  from __future__ import annotations
@@ -23,6 +10,7 @@ from __future__ import annotations
23
10
  import argparse
24
11
  import json
25
12
  import sys
13
+ from collections.abc import Sequence
26
14
  from dataclasses import dataclass
27
15
  from datetime import datetime
28
16
  from pathlib import Path
@@ -52,6 +40,9 @@ from dev_env_scripts_constants.claude_account_constants import (
52
40
  MAIN_SPEND_WINDOW,
53
41
  MAIN_WEEKLY_USED_CEILING_PERCENT,
54
42
  REASON_MAIN_EXPIRING_TEMPLATE,
43
+ REASON_ALL_EXTRAS_WAIT_TEMPLATE,
44
+ REASON_EXTRA_HAS_ROOM_TEMPLATE,
45
+ REASON_EXTRA_UNREAD_TEMPLATE,
55
46
  REASON_SECOND_HAS_ROOM_TEMPLATE,
56
47
  REASON_SECOND_UNREAD,
57
48
  REASON_WAIT_TEMPLATE,
@@ -70,6 +61,15 @@ class AccountDecision:
70
61
  reason: str
71
62
 
72
63
 
64
+ @dataclass(frozen=True)
65
+ class ExtraAccount:
66
+ """One ordered extra profile and its usage reading."""
67
+
68
+ name: str
69
+ meters: AccountUsageMeters | None
70
+ config_dir: Path | None = None
71
+
72
+
73
73
  def _main_spendable_reason(
74
74
  main_meters: AccountUsageMeters | None, now: datetime
75
75
  ) -> str | None:
@@ -92,14 +92,23 @@ def _main_spendable_reason(
92
92
  )
93
93
 
94
94
 
95
- def _wait_decision(
96
- second_meters: AccountUsageMeters, *, is_week_blocked: bool
97
- ) -> AccountDecision:
98
- blocking_reset = (
99
- second_meters.weekly_resets_at
100
- if is_week_blocked
101
- else second_meters.session_resets_at
102
- )
95
+ def _blocking_reset(meters: AccountUsageMeters) -> datetime | None:
96
+ all_blocking_resets: list[datetime] = []
97
+ weekly_used = meters.weekly_utilization
98
+ session_used = meters.session_utilization
99
+ if weekly_used is not None and weekly_used >= SECOND_WEEKLY_USED_CEILING_PERCENT:
100
+ if meters.weekly_resets_at is None:
101
+ return None
102
+ all_blocking_resets.append(meters.weekly_resets_at)
103
+ if session_used is not None and session_used >= SECOND_SESSION_USED_CEILING_PERCENT:
104
+ if meters.session_resets_at is None:
105
+ return None
106
+ all_blocking_resets.append(meters.session_resets_at)
107
+ return max(all_blocking_resets, default=None)
108
+
109
+
110
+ def _wait_decision(second_meters: AccountUsageMeters) -> AccountDecision:
111
+ blocking_reset = _blocking_reset(second_meters)
103
112
  return AccountDecision(
104
113
  account=CHOICE_WAIT,
105
114
  reason=REASON_WAIT_TEMPLATE.format(
@@ -112,28 +121,83 @@ def _wait_decision(
112
121
  )
113
122
 
114
123
 
115
- def _second_account_decision(
116
- second_meters: AccountUsageMeters | None,
117
- ) -> AccountDecision:
118
- if second_meters is None:
119
- return AccountDecision(account=CHOICE_SECOND, reason=REASON_SECOND_UNREAD)
120
- session_used = second_meters.session_utilization
121
- weekly_used = second_meters.weekly_utilization
124
+ def _extra_account_decision(extra_account: ExtraAccount) -> AccountDecision | None:
125
+ meters = extra_account.meters
126
+ unread_reason = (
127
+ REASON_SECOND_UNREAD
128
+ if extra_account.name == CHOICE_SECOND
129
+ else REASON_EXTRA_UNREAD_TEMPLATE.format(account=extra_account.name)
130
+ )
131
+ if meters is None:
132
+ return AccountDecision(account=extra_account.name, reason=unread_reason)
133
+ session_used = meters.session_utilization
134
+ weekly_used = meters.weekly_utilization
122
135
  if session_used is None or weekly_used is None:
123
- return AccountDecision(account=CHOICE_SECOND, reason=REASON_SECOND_UNREAD)
136
+ return AccountDecision(account=extra_account.name, reason=unread_reason)
124
137
  is_week_blocked = weekly_used >= SECOND_WEEKLY_USED_CEILING_PERCENT
125
138
  is_session_blocked = session_used >= SECOND_SESSION_USED_CEILING_PERCENT
126
139
  if is_week_blocked or is_session_blocked:
127
- return _wait_decision(second_meters, is_week_blocked=is_week_blocked)
140
+ return None
141
+ reason_template = (
142
+ REASON_SECOND_HAS_ROOM_TEMPLATE
143
+ if extra_account.name == CHOICE_SECOND
144
+ else REASON_EXTRA_HAS_ROOM_TEMPLATE
145
+ )
128
146
  return AccountDecision(
129
- account=CHOICE_SECOND,
130
- reason=REASON_SECOND_HAS_ROOM_TEMPLATE.format(
147
+ account=extra_account.name,
148
+ reason=reason_template.format(
149
+ account=extra_account.name,
131
150
  weekly_remaining_percent=FULL_PERCENT - weekly_used,
132
151
  session_remaining_percent=FULL_PERCENT - session_used,
133
152
  ),
134
153
  )
135
154
 
136
155
 
156
+ def choose_account_from_extras(
157
+ *,
158
+ main_meters: AccountUsageMeters | None,
159
+ all_extra_accounts: Sequence[ExtraAccount],
160
+ now: datetime,
161
+ ) -> AccountDecision:
162
+ """Choose main or the first extra profile with room in list order.
163
+
164
+ Args:
165
+ main_meters: The main account's meters, or None when unread.
166
+ all_extra_accounts: The extra accounts in the order they are tried.
167
+ now: The time the reset windows are measured from.
168
+
169
+ Returns:
170
+ The chosen account and the reason, or a wait naming the soonest reset.
171
+ """
172
+ main_reason = _main_spendable_reason(main_meters, now)
173
+ if main_reason is not None:
174
+ return AccountDecision(account=CHOICE_MAIN, reason=main_reason)
175
+ for each_extra_account in all_extra_accounts:
176
+ decision = _extra_account_decision(each_extra_account)
177
+ if decision is not None:
178
+ return decision
179
+ return _all_extras_wait_decision(all_extra_accounts)
180
+
181
+
182
+ def _all_extras_wait_decision(
183
+ all_extra_accounts: Sequence[ExtraAccount],
184
+ ) -> AccountDecision:
185
+ if len(all_extra_accounts) == 1 and all_extra_accounts[0].meters is not None:
186
+ return _wait_decision(all_extra_accounts[0].meters)
187
+ all_resets = (
188
+ _blocking_reset(each_extra_account.meters)
189
+ for each_extra_account in all_extra_accounts
190
+ if each_extra_account.meters is not None
191
+ )
192
+ next_reset = min((reset for reset in all_resets if reset is not None), default=None)
193
+ return AccountDecision(
194
+ account=CHOICE_WAIT,
195
+ reason=REASON_ALL_EXTRAS_WAIT_TEMPLATE.format(
196
+ next_reset=next_reset.isoformat() if next_reset else UNKNOWN_RESET_TEXT
197
+ ),
198
+ )
199
+
200
+
137
201
  def choose_account(
138
202
  *,
139
203
  main_meters: AccountUsageMeters | None,
@@ -159,10 +223,11 @@ def choose_account(
159
223
  Returns:
160
224
  The chosen account and the reason.
161
225
  """
162
- main_reason = _main_spendable_reason(main_meters, now)
163
- if main_reason is not None:
164
- return AccountDecision(account=CHOICE_MAIN, reason=main_reason)
165
- return _second_account_decision(second_meters)
226
+ return choose_account_from_extras(
227
+ main_meters=main_meters,
228
+ all_extra_accounts=(ExtraAccount(name=CHOICE_SECOND, meters=second_meters),),
229
+ now=now,
230
+ )
166
231
 
167
232
 
168
233
  def read_account_meters(credentials_path: Path) -> AccountUsageMeters | None:
@@ -180,6 +245,64 @@ def read_account_meters(credentials_path: Path) -> AccountUsageMeters | None:
180
245
  return None
181
246
 
182
247
 
248
+ def read_extra_accounts(
249
+ all_config_directories: Sequence[Path],
250
+ ) -> tuple[ExtraAccount, ...]:
251
+ """Read ordered extra profiles and reject duplicate directories.
252
+
253
+ Args:
254
+ all_config_directories: The extra profile homes in the order they are tried.
255
+
256
+ Returns:
257
+ One extra account per directory, with its meters.
258
+
259
+ Raises:
260
+ ValueError: When two entries name the same directory.
261
+ """
262
+ all_resolved_directories = [
263
+ str(each_directory.resolve()).casefold()
264
+ for each_directory in all_config_directories
265
+ ]
266
+ if len(set(all_resolved_directories)) != len(all_config_directories):
267
+ raise ValueError("extra account config directories must be distinct")
268
+ return tuple(
269
+ ExtraAccount(
270
+ name=CHOICE_SECOND if each_index == 1 else f"extra_{each_index}",
271
+ meters=read_account_meters(each_directory / CREDENTIALS_FILE_NAME),
272
+ config_dir=each_directory,
273
+ )
274
+ for each_index, each_directory in enumerate(all_config_directories, start=1)
275
+ )
276
+
277
+
278
+ def config_directory_for_decision(
279
+ decision: AccountDecision,
280
+ *,
281
+ main_config_dir: Path,
282
+ all_extra_accounts: Sequence[ExtraAccount],
283
+ ) -> Path | None:
284
+ """Find the selected account's config directory.
285
+
286
+ Args:
287
+ decision: The chosen account and reason.
288
+ main_config_dir: The main account's Claude home.
289
+ all_extra_accounts: The extra accounts the decision chose from.
290
+
291
+ Returns:
292
+ The chosen account's Claude home, or None on wait.
293
+ """
294
+ if decision.account == CHOICE_MAIN:
295
+ return main_config_dir
296
+ return next(
297
+ (
298
+ each_extra_account.config_dir
299
+ for each_extra_account in all_extra_accounts
300
+ if each_extra_account.name == decision.account
301
+ ),
302
+ None,
303
+ )
304
+
305
+
183
306
  def _build_argument_parser() -> argparse.ArgumentParser:
184
307
  parser = argparse.ArgumentParser(
185
308
  description="Name the Claude account a runner job uses."
@@ -194,6 +317,7 @@ def _build_argument_parser() -> argparse.ArgumentParser:
194
317
  type=Path,
195
318
  default=default_profile_home(),
196
319
  )
320
+ parser.add_argument("--extra-config-dir", type=Path, action="append", default=[])
197
321
  return parser
198
322
 
199
323
 
@@ -253,7 +377,7 @@ def meters_payload(
253
377
 
254
378
 
255
379
  def main(all_command_arguments: list[str]) -> int:
256
- """Print the chosen account and both accounts' meters as JSON.
380
+ """Print the chosen account and every account's meters as JSON.
257
381
 
258
382
  Args:
259
383
  all_command_arguments: Command-line arguments after the program name.
@@ -261,26 +385,33 @@ def main(all_command_arguments: list[str]) -> int:
261
385
  Returns:
262
386
  Zero once the choice is printed.
263
387
  """
264
- arguments = _build_argument_parser().parse_args(all_command_arguments)
265
- config_directory_by_account = {
266
- CHOICE_MAIN: arguments.main_config_dir,
267
- CHOICE_SECOND: arguments.second_config_dir,
268
- }
388
+ parser = _build_argument_parser()
389
+ arguments = parser.parse_args(all_command_arguments)
390
+ try:
391
+ extra_accounts = read_extra_accounts(
392
+ (arguments.second_config_dir, *arguments.extra_config_dir)
393
+ )
394
+ except ValueError as error:
395
+ parser.error(str(error))
269
396
  main_meters = read_account_meters(arguments.main_config_dir / CREDENTIALS_FILE_NAME)
270
- second_meters = read_account_meters(
271
- arguments.second_config_dir / CREDENTIALS_FILE_NAME
272
- )
273
- decision = choose_account(
397
+ decision = choose_account_from_extras(
274
398
  main_meters=main_meters,
275
- second_meters=second_meters,
399
+ all_extra_accounts=extra_accounts,
276
400
  now=datetime.now().astimezone(),
277
401
  )
278
- config_directory = config_directory_by_account.get(decision.account)
402
+ config_directory = config_directory_for_decision(
403
+ decision,
404
+ main_config_dir=arguments.main_config_dir,
405
+ all_extra_accounts=extra_accounts,
406
+ )
279
407
  report = {
280
408
  **decision_payload(decision, config_directory=config_directory),
281
409
  JSON_METERS_KEY: {
282
410
  CHOICE_MAIN: meters_payload(main_meters),
283
- CHOICE_SECOND: meters_payload(second_meters),
411
+ **{
412
+ extra_account.name: meters_payload(extra_account.meters)
413
+ for extra_account in extra_accounts
414
+ },
284
415
  },
285
416
  }
286
417
  print(json.dumps(report))
@@ -1,26 +1,10 @@
1
1
  #!/usr/bin/env python3
2
- """Keep the second account's Claude profile linked to the main Claude home.
2
+ """Keep a named extra Claude profile linked to the main Claude home.
3
3
 
4
- The second account runs Claude with its own sign-in and history, and with
5
- everything else the main home holds: skills, rules, plugins, settings, docs::
6
-
7
- main home profile home (second account)
8
- skills/ <----- skills/ link
9
- CLAUDE.md <----- CLAUDE.md link
10
- .credentials.json .credentials.json its own sign-in
11
- projects/ projects/ its own history
12
-
13
- Each run converges the profile on that picture. A stale copy moves into
14
- ``.replaced/<time>/`` with its content kept, a link whose main entry is gone
15
- is removed, a main link under an account-local name such as a credentials
16
- backup is removed, and a second run changes nothing. The run also writes the
17
- ``claude-ev.cmd`` launcher that starts Claude on this profile.
18
-
19
- ::
20
-
21
- python claude_account_profile.py
22
- {"linked": ["CLAUDE.md"], "moved_aside": [], "unlinked": [],
23
- "launcher": "C:/Users/me/.local/bin/claude-ev.cmd"}
4
+ Each profile keeps its own sign-in and history while sharing the main home's
5
+ skills, rules, plugins, settings, and docs. Stale shared copies move into
6
+ ``.replaced/<time>/``. A second run with the same name changes nothing. The
7
+ launcher is named ``claude-<profile-name>.cmd``.
24
8
  """
25
9
 
26
10
  from __future__ import annotations
@@ -29,6 +13,7 @@ import argparse
29
13
  import filecmp
30
14
  import json
31
15
  import os
16
+ import re
32
17
  import subprocess
33
18
  import sys
34
19
  from collections.abc import Callable
@@ -39,18 +24,22 @@ from pathlib import Path
39
24
  from dev_env_scripts_constants.claude_account_constants import (
40
25
  ALL_ACCOUNT_LOCAL_NAME_PREFIXES,
41
26
  ALL_ACCOUNT_LOCAL_NAMES,
27
+ ALL_WINDOWS_RESERVED_PROFILE_NAMES,
42
28
  ALL_WINDOWS_JUNCTION_COMMAND_PREFIX,
29
+ CHOICE_MAIN,
30
+ CHOICE_WAIT,
43
31
  JSON_LAUNCHER_KEY,
44
32
  JSON_LINKED_KEY,
45
33
  JSON_MOVED_ASIDE_KEY,
46
34
  JSON_UNLINKED_KEY,
47
35
  ALL_LAUNCHER_DIRECTORY_RELATIVE_PARTS,
48
- LAUNCHER_FILE_NAME,
36
+ LAUNCHER_FILE_NAME_TEMPLATE,
49
37
  LAUNCHER_REPLACED_SUFFIX,
50
38
  LAUNCHER_TEXT_TEMPLATE,
51
39
  MAIN_CLAUDE_HOME_DIRECTORY_NAME,
52
40
  PROFILES_ROOT_DIRECTORY_NAME,
53
41
  PROFILES_ROOT_ENVIRONMENT_VARIABLE,
42
+ PROFILE_NAME_PATTERN,
54
43
  REPLACED_DIRECTORY_NAME,
55
44
  REPLACED_STAMP_FORMAT,
56
45
  SECOND_ACCOUNT_PROFILE_NAME,
@@ -69,8 +58,32 @@ class ProfileSyncReport:
69
58
  all_unlinked: tuple[str, ...]
70
59
 
71
60
 
72
- def default_profile_home() -> Path:
73
- """Locate the second account's profile directory.
61
+ def validate_profile_name(profile_name: str) -> str:
62
+ """Accept a profile name safe for a directory and command launcher.
63
+
64
+ Args:
65
+ profile_name: The name to check.
66
+
67
+ Returns:
68
+ The same name.
69
+
70
+ Raises:
71
+ ValueError: When the name has other characters or is reserved.
72
+ """
73
+ reserved_names = ALL_WINDOWS_RESERVED_PROFILE_NAMES | {CHOICE_MAIN, CHOICE_WAIT}
74
+ if (
75
+ re.fullmatch(PROFILE_NAME_PATTERN, profile_name) is None
76
+ or profile_name.casefold() in reserved_names
77
+ ):
78
+ raise ValueError("profile name must use letters, digits, hyphens, or underscores")
79
+ return profile_name
80
+
81
+
82
+ def default_profile_home(profile_name: str = SECOND_ACCOUNT_PROFILE_NAME) -> Path:
83
+ """Locate a named extra account's profile directory.
84
+
85
+ Args:
86
+ profile_name: The profile's name under the profiles root.
74
87
 
75
88
  Returns:
76
89
  The profile under the profiles root the environment names, else under
@@ -82,7 +95,7 @@ def default_profile_home() -> Path:
82
95
  if profiles_root
83
96
  else Path.home() / PROFILES_ROOT_DIRECTORY_NAME
84
97
  )
85
- return root / SECOND_ACCOUNT_PROFILE_NAME
98
+ return root / validate_profile_name(profile_name)
86
99
 
87
100
 
88
101
  def is_account_local(entry_name: str) -> bool:
@@ -296,36 +309,47 @@ def sync_profile(
296
309
  )
297
310
 
298
311
 
299
- def _moved_launcher_name(now: datetime) -> str:
300
- return f"{LAUNCHER_FILE_NAME}{LAUNCHER_REPLACED_SUFFIX}{_stamp(now)}"
312
+ def _moved_launcher_name(launcher_file_name: str, now: datetime) -> str:
313
+ return f"{launcher_file_name}{LAUNCHER_REPLACED_SUFFIX}{_stamp(now)}"
301
314
 
302
315
 
303
316
  def write_launcher(
304
- *, launcher_directory: Path, profile_home: Path, now: datetime
317
+ *,
318
+ launcher_directory: Path,
319
+ profile_home: Path,
320
+ now: datetime,
321
+ profile_name: str = SECOND_ACCOUNT_PROFILE_NAME,
305
322
  ) -> Path:
306
- """Write the launcher that runs Claude under the second account's profile.
323
+ """Write the launcher that runs Claude under a named profile.
307
324
 
308
325
  ::
309
326
 
310
- claude-ev -p "fix the test"
327
+ claude-NAME -p "fix the test"
311
328
  -> CLAUDE_CONFIG_DIR=<profile home>, then claude -p "fix the test"
312
- an older claude-ev.cmd -> claude-ev.cmd.replaced-<time>
329
+ an older claude-NAME.cmd -> claude-NAME.cmd.replaced-<time>
313
330
 
314
331
  Args:
315
332
  launcher_directory: The directory on PATH that holds the launcher.
316
- profile_home: The second account's Claude home.
333
+ profile_home: The named account's Claude home.
317
334
  now: The run time that names a moved older launcher.
335
+ profile_name: The name used in the launcher file name.
318
336
 
319
337
  Returns:
320
338
  The launcher path.
321
339
  """
322
- launcher_path = launcher_directory / LAUNCHER_FILE_NAME
340
+ launcher_file_name = LAUNCHER_FILE_NAME_TEMPLATE.format(
341
+ profile_name=validate_profile_name(profile_name)
342
+ )
343
+ launcher_path = launcher_directory / launcher_file_name
323
344
  launcher_text = LAUNCHER_TEXT_TEMPLATE.format(profile_home=profile_home)
324
345
  launcher_bytes = launcher_text.encode(TEXT_ENCODING)
325
346
  if launcher_path.is_file() and launcher_path.read_bytes() == launcher_bytes:
326
347
  return launcher_path
327
348
  if launcher_path.is_file():
328
- os.replace(launcher_path, launcher_path.with_name(_moved_launcher_name(now)))
349
+ os.replace(
350
+ launcher_path,
351
+ launcher_path.with_name(_moved_launcher_name(launcher_file_name, now)),
352
+ )
329
353
  launcher_directory.mkdir(parents=True, exist_ok=True)
330
354
  launcher_path.write_bytes(launcher_bytes)
331
355
  return launcher_path
@@ -338,7 +362,8 @@ def _build_argument_parser() -> argparse.ArgumentParser:
338
362
  parser.add_argument(
339
363
  "--main-home", type=Path, default=Path.home() / MAIN_CLAUDE_HOME_DIRECTORY_NAME
340
364
  )
341
- parser.add_argument("--profile-home", type=Path, default=default_profile_home())
365
+ parser.add_argument("--profile-name", default=SECOND_ACCOUNT_PROFILE_NAME)
366
+ parser.add_argument("--profile-home", type=Path)
342
367
  parser.add_argument(
343
368
  "--launcher-directory",
344
369
  type=Path,
@@ -356,27 +381,34 @@ def main(all_command_arguments: list[str]) -> int:
356
381
  Returns:
357
382
  Zero once the profile and launcher match the main home.
358
383
  """
359
- arguments = _build_argument_parser().parse_args(all_command_arguments)
384
+ parser = _build_argument_parser()
385
+ arguments = parser.parse_args(all_command_arguments)
386
+ try:
387
+ validate_profile_name(arguments.profile_name)
388
+ except ValueError as error:
389
+ parser.error(str(error))
390
+ print(json.dumps(_sync_named_profile(arguments)))
391
+ return 0
392
+
393
+
394
+ def _sync_named_profile(arguments: argparse.Namespace) -> dict[str, object]:
395
+ profile_home = arguments.profile_home or default_profile_home(arguments.profile_name)
360
396
  now = datetime.now(timezone.utc)
361
397
  report = sync_profile(
362
- main_home=arguments.main_home, profile_home=arguments.profile_home, now=now
398
+ main_home=arguments.main_home, profile_home=profile_home, now=now
363
399
  )
364
400
  launcher_path = write_launcher(
365
401
  launcher_directory=arguments.launcher_directory,
366
- profile_home=arguments.profile_home,
402
+ profile_home=profile_home,
367
403
  now=now,
404
+ profile_name=arguments.profile_name,
368
405
  )
369
- print(
370
- json.dumps(
371
- {
372
- JSON_LINKED_KEY: list(report.all_linked),
373
- JSON_MOVED_ASIDE_KEY: list(report.all_moved_aside),
374
- JSON_UNLINKED_KEY: list(report.all_unlinked),
375
- JSON_LAUNCHER_KEY: str(launcher_path),
376
- }
377
- )
378
- )
379
- return 0
406
+ return {
407
+ JSON_LINKED_KEY: list(report.all_linked),
408
+ JSON_MOVED_ASIDE_KEY: list(report.all_moved_aside),
409
+ JSON_UNLINKED_KEY: list(report.all_unlinked),
410
+ JSON_LAUNCHER_KEY: str(launcher_path),
411
+ }
380
412
 
381
413
 
382
414
  if __name__ == "__main__":