claude-dev-env 8.15.1 → 8.16.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/second-account-workers/SKILL.md +26 -8
- 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
|
@@ -1,16 +1,33 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: second-account-workers
|
|
3
3
|
description: >-
|
|
4
|
-
Run local headless Claude workers through the
|
|
5
|
-
Triggers: spawn a worker on the second account, offload a worker,
|
|
6
|
-
|
|
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
|
-
#
|
|
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"`,
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
report account is `main` or `
|
|
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.
|
package/package.json
CHANGED
|
@@ -1,21 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
2
|
+
"""Choose a Claude account for a runner job while protecting the main account.
|
|
3
3
|
|
|
4
|
-
The main account
|
|
5
|
-
|
|
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
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
if
|
|
101
|
-
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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=
|
|
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
|
|
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=
|
|
130
|
-
reason=
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
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
|
-
|
|
399
|
+
all_extra_accounts=extra_accounts,
|
|
276
400
|
now=datetime.now().astimezone(),
|
|
277
401
|
)
|
|
278
|
-
config_directory =
|
|
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
|
-
|
|
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
|
|
2
|
+
"""Keep a named extra Claude profile linked to the main Claude home.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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
|
|
73
|
-
"""
|
|
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 /
|
|
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"{
|
|
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
|
-
*,
|
|
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
|
|
323
|
+
"""Write the launcher that runs Claude under a named profile.
|
|
307
324
|
|
|
308
325
|
::
|
|
309
326
|
|
|
310
|
-
claude-
|
|
327
|
+
claude-NAME -p "fix the test"
|
|
311
328
|
-> CLAUDE_CONFIG_DIR=<profile home>, then claude -p "fix the test"
|
|
312
|
-
an older claude-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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-
|
|
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
|
-
|
|
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=
|
|
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=
|
|
402
|
+
profile_home=profile_home,
|
|
367
403
|
now=now,
|
|
404
|
+
profile_name=arguments.profile_name,
|
|
368
405
|
)
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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__":
|