claude-dev-env 8.34.0 → 8.35.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/docs/codex-accounts.md +65 -12
- package/package.json +1 -1
- package/scripts/claude_account_profile.py +74 -27
- package/scripts/codex_account_choice.py +385 -24
- package/scripts/dev_env_scripts_constants/claude_account_constants.py +31 -11
- package/scripts/dev_env_scripts_constants/claude_account_worker_constants.py +1 -2
- package/scripts/dev_env_scripts_constants/codex_account_constants.py +57 -2
- package/scripts/dev_env_scripts_constants/test_claude_account_constants.py +0 -6
- package/scripts/test_claude_account_profile.py +115 -3
- package/scripts/test_codex_account_choice.py +334 -1
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""Name the Codex account a runner job uses, and keep each account's home ready.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
``~/.codex-profiles/<name>``.
|
|
4
|
+
Each Codex account signs in under its own Codex home in
|
|
5
|
+
``~/.codex-profiles/<name>``. The account roster names them in try order: the
|
|
6
|
+
``CODEX_ACCOUNT_PROFILES`` environment variable, comma-separated, else the saved
|
|
7
|
+
``account-launchers.json`` list under the profiles root. With neither,
|
|
8
|
+
``codex-1`` through ``codex-4`` are the roster. Jobs try them in that order::
|
|
6
9
|
|
|
7
10
|
first account with more than 10% left -> normal, that account
|
|
8
11
|
none over 10%, the roomiest one over 1% -> luna, stop at 1% left
|
|
@@ -10,7 +13,7 @@ Up to four Codex accounts sign in, each under its own Codex home in
|
|
|
10
13
|
none over 1%, or no meter reads -> wait, name the first reset
|
|
11
14
|
account not signed in, or its meter unread -> skipped
|
|
12
15
|
|
|
13
|
-
|
|
16
|
+
Five commands::
|
|
14
17
|
|
|
15
18
|
python codex_account_choice.py choose
|
|
16
19
|
{"tier": "normal", "account": "codex-1", "codex_home": "C:/Users/me/.codex-profiles/codex-1",
|
|
@@ -21,8 +24,15 @@ Three commands::
|
|
|
21
24
|
exit 0 while codex-2 has more than 1% left, exit 3 once it has not
|
|
22
25
|
|
|
23
26
|
python codex_account_choice.py sync
|
|
24
|
-
links the shared Codex setup (config, rules, skills, plugins) into
|
|
25
|
-
account's home and leaves each sign-in and history its own
|
|
27
|
+
links the shared Codex setup (config, rules, skills, plugins, agents) into
|
|
28
|
+
every roster account's home and leaves each sign-in and history its own
|
|
29
|
+
|
|
30
|
+
python codex_account_choice.py setup
|
|
31
|
+
asks for each account name, saves the roster, and writes one
|
|
32
|
+
codex-<name>.cmd launcher per account; a dropped name's launcher moves aside
|
|
33
|
+
|
|
34
|
+
python codex_account_choice.py install
|
|
35
|
+
reruns the setup without asking: syncs every saved account's home and launcher
|
|
26
36
|
"""
|
|
27
37
|
|
|
28
38
|
from __future__ import annotations
|
|
@@ -36,21 +46,41 @@ from dataclasses import dataclass
|
|
|
36
46
|
from datetime import datetime, timezone
|
|
37
47
|
from pathlib import Path
|
|
38
48
|
|
|
39
|
-
from claude_account_profile import
|
|
49
|
+
from claude_account_profile import (
|
|
50
|
+
launcher_path,
|
|
51
|
+
move_launcher_aside,
|
|
52
|
+
sync_profile,
|
|
53
|
+
sync_report_payload,
|
|
54
|
+
validate_profile_name,
|
|
55
|
+
write_launcher,
|
|
56
|
+
)
|
|
40
57
|
from codex_account_meters import (
|
|
41
58
|
CodexAccountMeters,
|
|
42
59
|
CodexMeterUnreadError,
|
|
43
60
|
read_codex_meters,
|
|
44
61
|
resolve_codex_path,
|
|
45
62
|
)
|
|
63
|
+
from dev_env_scripts_constants.claude_account_constants import (
|
|
64
|
+
ALL_LAUNCHER_DIRECTORY_RELATIVE_PARTS,
|
|
65
|
+
JSON_LAUNCHER_KEY,
|
|
66
|
+
)
|
|
46
67
|
from dev_env_scripts_constants.codex_account_constants import (
|
|
47
68
|
ALL_CODEX_ACCOUNT_NAMES,
|
|
48
69
|
ALL_SHARED_CODEX_HOME_NAMES,
|
|
70
|
+
CODEX_ACCOUNT_LAUNCHERS_FILE_NAME,
|
|
71
|
+
CODEX_ACCOUNT_NAME_SEPARATOR,
|
|
72
|
+
CODEX_ACCOUNT_PROFILES_ENVIRONMENT_VARIABLE,
|
|
49
73
|
CODEX_AUTH_FILE_NAME,
|
|
74
|
+
CODEX_LAUNCHER_PROGRAM,
|
|
50
75
|
CODEX_PROFILES_ROOT_DIRECTORY_NAME,
|
|
51
76
|
CODEX_PROFILES_ROOT_ENVIRONMENT_VARIABLE,
|
|
77
|
+
COMMAND_INSTALL,
|
|
78
|
+
COMMAND_SETUP,
|
|
52
79
|
EXIT_CODE_NO_ROOM,
|
|
53
80
|
EXIT_CODE_ROOM,
|
|
81
|
+
INVALID_ACCOUNT_ROSTER_TEMPLATE,
|
|
82
|
+
JSON_INSTALLED_KEY,
|
|
83
|
+
JSON_RETIRED_KEY,
|
|
54
84
|
LUNA_TIER_SHORT_WINDOW_MINIMUM_PERCENT_LEFT,
|
|
55
85
|
LUNA_TIER_STOP_PERCENT_LEFT,
|
|
56
86
|
MAIN_CODEX_HOME_DIRECTORY_NAME,
|
|
@@ -59,14 +89,28 @@ from dev_env_scripts_constants.codex_account_constants import (
|
|
|
59
89
|
REASON_NORMAL_TEMPLATE,
|
|
60
90
|
REASON_WAIT_TEMPLATE,
|
|
61
91
|
REASON_WAIT_UNREAD,
|
|
92
|
+
ROSTER_JSON_INDENT,
|
|
93
|
+
ROSTER_NOT_A_LIST_TEMPLATE,
|
|
94
|
+
SETUP_NAMES_SEPARATOR,
|
|
95
|
+
SETUP_NO_SAVED_NAMES_TEXT,
|
|
96
|
+
SETUP_PROMPT_TEXT,
|
|
97
|
+
SETUP_SAVED_NAMES_TEMPLATE,
|
|
98
|
+
TEXT_ENCODING,
|
|
62
99
|
TIER_LUNA,
|
|
63
100
|
TIER_NORMAL,
|
|
64
101
|
TIER_WAIT,
|
|
102
|
+
UNKNOWN_ACCOUNT_TEMPLATE,
|
|
65
103
|
UNKNOWN_RESET_TEXT,
|
|
66
104
|
UNREAD_NOT_SIGNED_IN,
|
|
67
105
|
)
|
|
68
106
|
|
|
69
107
|
MeterReader = Callable[[Path], CodexAccountMeters]
|
|
108
|
+
LineReader = Callable[[str], str]
|
|
109
|
+
LineWriter = Callable[[str], None]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class CodexAccountNameError(ValueError):
|
|
113
|
+
"""An account name from the roster or the command line is invalid or unknown."""
|
|
70
114
|
|
|
71
115
|
|
|
72
116
|
@dataclass(frozen=True)
|
|
@@ -120,6 +164,260 @@ def is_account_local_codex_entry(entry_name: str) -> bool:
|
|
|
120
164
|
return entry_name not in ALL_SHARED_CODEX_HOME_NAMES
|
|
121
165
|
|
|
122
166
|
|
|
167
|
+
def _validated_name(name: str, source: str) -> str:
|
|
168
|
+
try:
|
|
169
|
+
return validate_profile_name(name)
|
|
170
|
+
except ValueError as error:
|
|
171
|
+
raise CodexAccountNameError(
|
|
172
|
+
INVALID_ACCOUNT_ROSTER_TEMPLATE.format(source=source, name=name, reason=error)
|
|
173
|
+
) from error
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _roster(all_raw_names: Sequence[str], source: str) -> tuple[str, ...]:
|
|
177
|
+
return tuple(
|
|
178
|
+
dict.fromkeys(_validated_name(each_name, source) for each_name in all_raw_names)
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _environment_roster() -> tuple[str, ...]:
|
|
183
|
+
raw_roster = os.environ.get(CODEX_ACCOUNT_PROFILES_ENVIRONMENT_VARIABLE, "")
|
|
184
|
+
all_stripped = (
|
|
185
|
+
each_part.strip() for each_part in raw_roster.split(CODEX_ACCOUNT_NAME_SEPARATOR)
|
|
186
|
+
)
|
|
187
|
+
all_raw_names = [each_name for each_name in all_stripped if each_name]
|
|
188
|
+
return _roster(all_raw_names, CODEX_ACCOUNT_PROFILES_ENVIRONMENT_VARIABLE)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _saved_roster_file_names(profiles_root: Path) -> tuple[str, ...]:
|
|
192
|
+
"""Read the roster saved under the profiles root, ignoring the environment.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
profiles_root: The directory that holds every account's Codex home.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
The saved names in order, or nothing when no roster file exists.
|
|
199
|
+
|
|
200
|
+
Raises:
|
|
201
|
+
CodexAccountNameError: When the file holds anything but valid names.
|
|
202
|
+
"""
|
|
203
|
+
roster_path = profiles_root / CODEX_ACCOUNT_LAUNCHERS_FILE_NAME
|
|
204
|
+
if not roster_path.is_file():
|
|
205
|
+
return ()
|
|
206
|
+
try:
|
|
207
|
+
document = json.loads(roster_path.read_text(encoding=TEXT_ENCODING))
|
|
208
|
+
except json.JSONDecodeError:
|
|
209
|
+
document = None
|
|
210
|
+
if not isinstance(document, list) or not all(
|
|
211
|
+
isinstance(each_name, str) for each_name in document
|
|
212
|
+
):
|
|
213
|
+
raise CodexAccountNameError(ROSTER_NOT_A_LIST_TEMPLATE.format(source=roster_path))
|
|
214
|
+
return _roster(document, str(roster_path))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def saved_codex_account_names(profiles_root: Path) -> tuple[str, ...]:
|
|
218
|
+
"""Name the account roster, in try order, without duplicates.
|
|
219
|
+
|
|
220
|
+
::
|
|
221
|
+
|
|
222
|
+
CODEX_ACCOUNT_PROFILES="alpha, beta,alpha" -> ("alpha", "beta")
|
|
223
|
+
unset, account-launchers.json ["gamma"] -> ("gamma",)
|
|
224
|
+
unset, no file -> ()
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
profiles_root: The directory that holds every account's Codex home.
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
The environment's names when it names any, else the saved file's names.
|
|
231
|
+
|
|
232
|
+
Raises:
|
|
233
|
+
CodexAccountNameError: When the environment or the file names an invalid account.
|
|
234
|
+
"""
|
|
235
|
+
return _environment_roster() or _saved_roster_file_names(profiles_root)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def codex_account_names(profiles_root: Path) -> tuple[str, ...]:
|
|
239
|
+
"""Name the accounts ``choose``, ``sync``, and ``check`` work on, in try order.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
profiles_root: The directory that holds every account's Codex home.
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
The account roster, else ``codex-1`` through ``codex-4``.
|
|
246
|
+
"""
|
|
247
|
+
return saved_codex_account_names(profiles_root) or ALL_CODEX_ACCOUNT_NAMES
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def save_codex_account_names(profiles_root: Path, all_names: Sequence[str]) -> None:
|
|
251
|
+
"""Write the account roster as a JSON list under the profiles root.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
profiles_root: The directory that holds every account's Codex home.
|
|
255
|
+
all_names: The account names in try order.
|
|
256
|
+
"""
|
|
257
|
+
profiles_root.mkdir(parents=True, exist_ok=True)
|
|
258
|
+
roster_text = json.dumps(list(all_names), indent=ROSTER_JSON_INDENT) + "\n"
|
|
259
|
+
(profiles_root / CODEX_ACCOUNT_LAUNCHERS_FILE_NAME).write_bytes(
|
|
260
|
+
roster_text.encode(TEXT_ENCODING)
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _sync_account(
|
|
265
|
+
*, main_home: Path, profiles_root: Path, name: str, now: datetime
|
|
266
|
+
) -> dict[str, object]:
|
|
267
|
+
report = sync_profile(
|
|
268
|
+
main_home=main_home,
|
|
269
|
+
profile_home=profiles_root / name,
|
|
270
|
+
now=now,
|
|
271
|
+
is_local=is_account_local_codex_entry,
|
|
272
|
+
)
|
|
273
|
+
return sync_report_payload(report)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _install_account(
|
|
277
|
+
*, main_home: Path, profiles_root: Path, launcher_directory: Path, name: str, now: datetime
|
|
278
|
+
) -> dict[str, object]:
|
|
279
|
+
sync_payload = _sync_account(
|
|
280
|
+
main_home=main_home, profiles_root=profiles_root, name=name, now=now
|
|
281
|
+
)
|
|
282
|
+
launcher_path = write_launcher(
|
|
283
|
+
launcher_directory=launcher_directory,
|
|
284
|
+
profile_home=profiles_root / name,
|
|
285
|
+
now=now,
|
|
286
|
+
profile_name=name,
|
|
287
|
+
launcher_program=CODEX_LAUNCHER_PROGRAM,
|
|
288
|
+
)
|
|
289
|
+
return {**sync_payload, JSON_LAUNCHER_KEY: str(launcher_path)}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def install_codex_account_launchers(
|
|
293
|
+
*,
|
|
294
|
+
main_home: Path,
|
|
295
|
+
profiles_root: Path,
|
|
296
|
+
launcher_directory: Path,
|
|
297
|
+
all_names: Sequence[str],
|
|
298
|
+
now: datetime,
|
|
299
|
+
) -> dict[str, dict[str, object]]:
|
|
300
|
+
"""Link each account's home to the main Codex home and write its launcher, idempotently.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
main_home: The Codex home that holds the shared setup.
|
|
304
|
+
profiles_root: The directory that holds every account's Codex home.
|
|
305
|
+
launcher_directory: The directory on PATH that holds the launchers.
|
|
306
|
+
all_names: The account names to install.
|
|
307
|
+
now: The run time that names anything moved aside.
|
|
308
|
+
|
|
309
|
+
Returns:
|
|
310
|
+
Per name, the entries linked, moved aside, and unlinked, and the launcher.
|
|
311
|
+
"""
|
|
312
|
+
return {
|
|
313
|
+
each_name: _install_account(
|
|
314
|
+
main_home=main_home,
|
|
315
|
+
profiles_root=profiles_root,
|
|
316
|
+
launcher_directory=launcher_directory,
|
|
317
|
+
name=each_name,
|
|
318
|
+
now=now,
|
|
319
|
+
)
|
|
320
|
+
for each_name in all_names
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _retire_codex_account_launchers(
|
|
325
|
+
*, launcher_directory: Path, all_names: Sequence[str], now: datetime
|
|
326
|
+
) -> dict[str, str]:
|
|
327
|
+
"""Move each named account's launcher aside, leaving its Codex home alone.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
launcher_directory: The directory on PATH that holds the launchers.
|
|
331
|
+
all_names: The account names dropped from the roster.
|
|
332
|
+
now: The run time that names each moved launcher.
|
|
333
|
+
|
|
334
|
+
Returns:
|
|
335
|
+
Per name with a launcher, the moved launcher's path.
|
|
336
|
+
"""
|
|
337
|
+
all_retired: dict[str, str] = {}
|
|
338
|
+
for each_name in all_names:
|
|
339
|
+
retired_path = launcher_path(launcher_directory, each_name, CODEX_LAUNCHER_PROGRAM)
|
|
340
|
+
if retired_path.is_file():
|
|
341
|
+
all_retired[each_name] = str(move_launcher_aside(retired_path, now))
|
|
342
|
+
return all_retired
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _read_account_name(read_line: LineReader) -> str:
|
|
346
|
+
try:
|
|
347
|
+
return read_line(SETUP_PROMPT_TEXT).strip()
|
|
348
|
+
except EOFError:
|
|
349
|
+
return ""
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _prompt_codex_account_names(
|
|
353
|
+
read_line: LineReader, write_line: LineWriter
|
|
354
|
+
) -> tuple[str, ...]:
|
|
355
|
+
"""Ask for account names until a blank line or the end of input.
|
|
356
|
+
|
|
357
|
+
::
|
|
358
|
+
|
|
359
|
+
"alpha", "bad name", "alpha", "beta", "" -> ("alpha", "beta")
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
read_line: Shows the prompt and returns one typed line.
|
|
363
|
+
write_line: Shows why a typed name was refused.
|
|
364
|
+
|
|
365
|
+
Returns:
|
|
366
|
+
The valid names in the order typed, without duplicates.
|
|
367
|
+
"""
|
|
368
|
+
all_names: list[str] = []
|
|
369
|
+
while entered_name := _read_account_name(read_line):
|
|
370
|
+
try:
|
|
371
|
+
all_names.append(validate_profile_name(entered_name))
|
|
372
|
+
except ValueError as error:
|
|
373
|
+
write_line(str(error))
|
|
374
|
+
return tuple(dict.fromkeys(all_names))
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _ask_roster(
|
|
378
|
+
profiles_root: Path, read_line: LineReader, write_line: LineWriter
|
|
379
|
+
) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
|
380
|
+
all_saved = _saved_roster_file_names(profiles_root)
|
|
381
|
+
write_line(
|
|
382
|
+
SETUP_SAVED_NAMES_TEMPLATE.format(
|
|
383
|
+
names=SETUP_NAMES_SEPARATOR.join(all_saved) or SETUP_NO_SAVED_NAMES_TEXT
|
|
384
|
+
)
|
|
385
|
+
)
|
|
386
|
+
all_names = _prompt_codex_account_names(read_line, write_line)
|
|
387
|
+
all_dropped = tuple(
|
|
388
|
+
each_name for each_name in all_saved if each_name not in all_names
|
|
389
|
+
)
|
|
390
|
+
return all_names, all_dropped
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def update_codex_account_roster(
|
|
394
|
+
*,
|
|
395
|
+
profiles_root: Path,
|
|
396
|
+
launcher_directory: Path,
|
|
397
|
+
now: datetime,
|
|
398
|
+
read_line: LineReader = input,
|
|
399
|
+
write_line: LineWriter = print,
|
|
400
|
+
) -> tuple[tuple[str, ...], dict[str, str]]:
|
|
401
|
+
"""Ask for the account roster, save it, and move dropped launchers aside.
|
|
402
|
+
|
|
403
|
+
Args:
|
|
404
|
+
profiles_root: The directory that holds every account's Codex home.
|
|
405
|
+
launcher_directory: The directory on PATH that holds the launchers.
|
|
406
|
+
now: The run time that names each moved launcher.
|
|
407
|
+
read_line: Shows the prompt and returns one typed line.
|
|
408
|
+
write_line: Shows the saved roster and each refused name.
|
|
409
|
+
|
|
410
|
+
Returns:
|
|
411
|
+
The new roster, and per dropped name, the moved launcher's path.
|
|
412
|
+
"""
|
|
413
|
+
all_names, all_dropped = _ask_roster(profiles_root, read_line, write_line)
|
|
414
|
+
all_retired = _retire_codex_account_launchers(
|
|
415
|
+
launcher_directory=launcher_directory, all_names=all_dropped, now=now
|
|
416
|
+
)
|
|
417
|
+
save_codex_account_names(profiles_root, all_names)
|
|
418
|
+
return all_names, all_retired
|
|
419
|
+
|
|
420
|
+
|
|
123
421
|
def read_account(
|
|
124
422
|
name: str, profiles_root: Path, read_meters: MeterReader
|
|
125
423
|
) -> AccountReading:
|
|
@@ -320,7 +618,7 @@ def _run_choose(arguments: argparse.Namespace) -> int:
|
|
|
320
618
|
read_meters = _meter_reader(arguments.codex_path)
|
|
321
619
|
all_readings = [
|
|
322
620
|
read_account(each_name, arguments.profiles_root, read_meters)
|
|
323
|
-
for each_name in
|
|
621
|
+
for each_name in codex_account_names(arguments.profiles_root)
|
|
324
622
|
]
|
|
325
623
|
print(
|
|
326
624
|
json.dumps(decision_payload(choose_codex_account(all_readings), all_readings))
|
|
@@ -328,7 +626,18 @@ def _run_choose(arguments: argparse.Namespace) -> int:
|
|
|
328
626
|
return 0
|
|
329
627
|
|
|
330
628
|
|
|
629
|
+
def _require_known_account(name: str, profiles_root: Path) -> None:
|
|
630
|
+
all_known = codex_account_names(profiles_root)
|
|
631
|
+
if name not in all_known:
|
|
632
|
+
raise CodexAccountNameError(
|
|
633
|
+
UNKNOWN_ACCOUNT_TEMPLATE.format(
|
|
634
|
+
name=name, known=SETUP_NAMES_SEPARATOR.join(all_known)
|
|
635
|
+
)
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
|
|
331
639
|
def _run_check(arguments: argparse.Namespace) -> int:
|
|
640
|
+
_require_known_account(arguments.account, arguments.profiles_root)
|
|
332
641
|
reading = read_account(
|
|
333
642
|
arguments.account, arguments.profiles_root, _meter_reader(arguments.codex_path)
|
|
334
643
|
)
|
|
@@ -340,23 +649,70 @@ def _run_check(arguments: argparse.Namespace) -> int:
|
|
|
340
649
|
|
|
341
650
|
def _run_sync(arguments: argparse.Namespace) -> int:
|
|
342
651
|
now = datetime.now(timezone.utc)
|
|
343
|
-
all_reports = {
|
|
344
|
-
|
|
345
|
-
report = sync_profile(
|
|
652
|
+
all_reports = {
|
|
653
|
+
each_name: _sync_account(
|
|
346
654
|
main_home=arguments.main_home,
|
|
347
|
-
|
|
655
|
+
profiles_root=arguments.profiles_root,
|
|
656
|
+
name=each_name,
|
|
348
657
|
now=now,
|
|
349
|
-
is_local=is_account_local_codex_entry,
|
|
350
658
|
)
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
659
|
+
for each_name in codex_account_names(arguments.profiles_root)
|
|
660
|
+
}
|
|
661
|
+
print(json.dumps(all_reports))
|
|
662
|
+
return 0
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _run_install(arguments: argparse.Namespace) -> int:
|
|
666
|
+
all_reports = install_codex_account_launchers(
|
|
667
|
+
main_home=arguments.main_home,
|
|
668
|
+
profiles_root=arguments.profiles_root,
|
|
669
|
+
launcher_directory=arguments.launcher_directory,
|
|
670
|
+
all_names=saved_codex_account_names(arguments.profiles_root),
|
|
671
|
+
now=datetime.now(timezone.utc),
|
|
672
|
+
)
|
|
356
673
|
print(json.dumps(all_reports))
|
|
357
674
|
return 0
|
|
358
675
|
|
|
359
676
|
|
|
677
|
+
def _run_setup(arguments: argparse.Namespace) -> int:
|
|
678
|
+
now = datetime.now(timezone.utc)
|
|
679
|
+
all_names, all_retired = update_codex_account_roster(
|
|
680
|
+
profiles_root=arguments.profiles_root,
|
|
681
|
+
launcher_directory=arguments.launcher_directory,
|
|
682
|
+
now=now,
|
|
683
|
+
)
|
|
684
|
+
all_installed = install_codex_account_launchers(
|
|
685
|
+
main_home=arguments.main_home,
|
|
686
|
+
profiles_root=arguments.profiles_root,
|
|
687
|
+
launcher_directory=arguments.launcher_directory,
|
|
688
|
+
all_names=all_names,
|
|
689
|
+
now=now,
|
|
690
|
+
)
|
|
691
|
+
print(json.dumps({JSON_INSTALLED_KEY: all_installed, JSON_RETIRED_KEY: all_retired}))
|
|
692
|
+
return 0
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _add_main_home_argument(command_parser: argparse.ArgumentParser) -> None:
|
|
696
|
+
command_parser.add_argument(
|
|
697
|
+
"--main-home", type=Path, default=Path.home() / MAIN_CODEX_HOME_DIRECTORY_NAME
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _add_launcher_commands(
|
|
702
|
+
all_commands: argparse._SubParsersAction[argparse.ArgumentParser],
|
|
703
|
+
) -> None:
|
|
704
|
+
all_launcher_commands = ((COMMAND_SETUP, _run_setup), (COMMAND_INSTALL, _run_install))
|
|
705
|
+
for each_command, each_run in all_launcher_commands:
|
|
706
|
+
command_parser = all_commands.add_parser(each_command)
|
|
707
|
+
_add_main_home_argument(command_parser)
|
|
708
|
+
command_parser.add_argument(
|
|
709
|
+
"--launcher-directory",
|
|
710
|
+
type=Path,
|
|
711
|
+
default=Path.home().joinpath(*ALL_LAUNCHER_DIRECTORY_RELATIVE_PARTS),
|
|
712
|
+
)
|
|
713
|
+
command_parser.set_defaults(run=each_run)
|
|
714
|
+
|
|
715
|
+
|
|
360
716
|
def _build_argument_parser() -> argparse.ArgumentParser:
|
|
361
717
|
parser = argparse.ArgumentParser(
|
|
362
718
|
description="Name the Codex account a runner job uses."
|
|
@@ -366,16 +722,15 @@ def _build_argument_parser() -> argparse.ArgumentParser:
|
|
|
366
722
|
all_commands = parser.add_subparsers(dest="command", required=True)
|
|
367
723
|
all_commands.add_parser("choose").set_defaults(run=_run_choose)
|
|
368
724
|
check_parser = all_commands.add_parser("check")
|
|
369
|
-
check_parser.add_argument("account"
|
|
725
|
+
check_parser.add_argument("account")
|
|
370
726
|
check_parser.add_argument(
|
|
371
727
|
"--floor", type=float, default=LUNA_TIER_STOP_PERCENT_LEFT
|
|
372
728
|
)
|
|
373
729
|
check_parser.set_defaults(run=_run_check)
|
|
374
730
|
sync_parser = all_commands.add_parser("sync")
|
|
375
|
-
sync_parser
|
|
376
|
-
"--main-home", type=Path, default=Path.home() / MAIN_CODEX_HOME_DIRECTORY_NAME
|
|
377
|
-
)
|
|
731
|
+
_add_main_home_argument(sync_parser)
|
|
378
732
|
sync_parser.set_defaults(run=_run_sync)
|
|
733
|
+
_add_launcher_commands(all_commands)
|
|
379
734
|
return parser
|
|
380
735
|
|
|
381
736
|
|
|
@@ -386,10 +741,16 @@ def main(all_command_arguments: list[str]) -> int:
|
|
|
386
741
|
all_command_arguments: Command-line arguments after the program name.
|
|
387
742
|
|
|
388
743
|
Returns:
|
|
389
|
-
Zero for choose and
|
|
744
|
+
Zero for choose, sync, setup, and install; for check, zero with room and
|
|
745
|
+
three without. An invalid or unknown account name exits through the
|
|
746
|
+
parser with two.
|
|
390
747
|
"""
|
|
391
|
-
|
|
392
|
-
|
|
748
|
+
parser = _build_argument_parser()
|
|
749
|
+
arguments = parser.parse_args(all_command_arguments)
|
|
750
|
+
try:
|
|
751
|
+
return arguments.run(arguments)
|
|
752
|
+
except CodexAccountNameError as error:
|
|
753
|
+
parser.error(str(error))
|
|
393
754
|
|
|
394
755
|
|
|
395
756
|
if __name__ == "__main__":
|
|
@@ -8,8 +8,11 @@ main account so its owner never runs out.
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
from dataclasses import dataclass
|
|
11
12
|
from datetime import timedelta
|
|
12
13
|
|
|
14
|
+
from dev_env_scripts_constants.shared_tree_constants import CLAUDE_CONFIG_DIR_ENV_VAR
|
|
15
|
+
|
|
13
16
|
MAIN_CLAUDE_HOME_DIRECTORY_NAME: str = ".claude"
|
|
14
17
|
"""Directory under the user home that holds the main account's Claude home."""
|
|
15
18
|
|
|
@@ -38,22 +41,39 @@ ALL_WINDOWS_RESERVED_PROFILE_NAMES: frozenset[str] = frozenset(
|
|
|
38
41
|
ALL_LAUNCHER_DIRECTORY_RELATIVE_PARTS: tuple[str, ...] = (".local", "bin")
|
|
39
42
|
"""Path parts under the user home of the directory on PATH that holds the launcher."""
|
|
40
43
|
|
|
41
|
-
|
|
42
|
-
"""
|
|
43
|
-
|
|
44
|
-
LAUNCHER_FILE_NAME: str = LAUNCHER_FILE_NAME_TEMPLATE.format(
|
|
45
|
-
profile_name=SECOND_ACCOUNT_PROFILE_NAME
|
|
46
|
-
)
|
|
47
|
-
"""File name of the launcher that runs Claude under the second account."""
|
|
44
|
+
CLAUDE_BINARY_NAME: str = "claude"
|
|
45
|
+
"""Claude command name looked up on PATH."""
|
|
48
46
|
|
|
49
|
-
|
|
47
|
+
LAUNCHER_BODY_TEMPLATE: str = (
|
|
50
48
|
"@echo off\r\n"
|
|
51
49
|
"setlocal\r\n"
|
|
52
|
-
'set "
|
|
53
|
-
"
|
|
50
|
+
'set "{environment_variable}={profile_home}"\r\n'
|
|
51
|
+
"call {program} %*\r\n"
|
|
54
52
|
"exit /b %ERRORLEVEL%\r\n"
|
|
55
53
|
)
|
|
56
|
-
"""Launcher body: point
|
|
54
|
+
"""Launcher body: point a program at the profile home and pass every argument through.
|
|
55
|
+
|
|
56
|
+
The program resolves to npm's ``<program>.cmd``, whose last line runs ``endLocal``
|
|
57
|
+
and a ``goto`` to a missing label. Chained into without ``call``, that line ends
|
|
58
|
+
this launcher's ``setlocal`` too, and the program starts without the variable.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class LauncherProgram:
|
|
64
|
+
"""The program a profile launcher runs and the variable naming its home."""
|
|
65
|
+
|
|
66
|
+
program: str
|
|
67
|
+
environment_variable: str
|
|
68
|
+
file_name_template: str
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
CLAUDE_LAUNCHER_PROGRAM: LauncherProgram = LauncherProgram(
|
|
72
|
+
program=CLAUDE_BINARY_NAME,
|
|
73
|
+
environment_variable=CLAUDE_CONFIG_DIR_ENV_VAR,
|
|
74
|
+
file_name_template="claude-{profile_name}.cmd",
|
|
75
|
+
)
|
|
76
|
+
"""Launcher that runs Claude with ``CLAUDE_CONFIG_DIR`` set to the profile home."""
|
|
57
77
|
|
|
58
78
|
LAUNCHER_REPLACED_SUFFIX: str = ".replaced-"
|
|
59
79
|
"""Suffix, before the run time, of an older launcher the sync moved aside."""
|
|
@@ -6,6 +6,7 @@ from dev_env_scripts_constants.claude_account_constants import (
|
|
|
6
6
|
CHOICE_MAIN,
|
|
7
7
|
CHOICE_SECOND,
|
|
8
8
|
CHOICE_WAIT,
|
|
9
|
+
CLAUDE_BINARY_NAME,
|
|
9
10
|
CREDENTIALS_FILE_NAME,
|
|
10
11
|
EXTRA_PROFILES_FILE_NAME,
|
|
11
12
|
JSON_ACCOUNT_KEY,
|
|
@@ -15,8 +16,6 @@ from dev_env_scripts_constants.claude_account_constants import (
|
|
|
15
16
|
)
|
|
16
17
|
from dev_env_scripts_constants.shared_tree_constants import CLAUDE_CONFIG_DIR_ENV_VAR
|
|
17
18
|
|
|
18
|
-
CLAUDE_BINARY_NAME: str = "claude"
|
|
19
|
-
|
|
20
19
|
DEFAULT_PERMISSION_MODE: str = "auto"
|
|
21
20
|
|
|
22
21
|
DEFAULT_TIMEOUT_MINUTES: int = 60
|
|
@@ -2,13 +2,60 @@
|
|
|
2
2
|
|
|
3
3
|
Each account signs in under its own Codex home. The picker reads every
|
|
4
4
|
account's rate-limit windows through ``codex app-server`` and names the account
|
|
5
|
-
a job runs on, trying the accounts in
|
|
5
|
+
a job runs on, trying the accounts in roster order.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
from dev_env_scripts_constants.claude_account_constants import LauncherProgram
|
|
11
|
+
|
|
10
12
|
ALL_CODEX_ACCOUNT_NAMES: tuple[str, ...] = ("codex-1", "codex-2", "codex-3", "codex-4")
|
|
11
|
-
"""Codex accounts in
|
|
13
|
+
"""Fallback Codex accounts, in try order, when no account roster is saved or set."""
|
|
14
|
+
|
|
15
|
+
CODEX_ACCOUNT_PROFILES_ENVIRONMENT_VARIABLE: str = "CODEX_ACCOUNT_PROFILES"
|
|
16
|
+
"""Environment variable naming the account roster, comma-separated, over the saved file."""
|
|
17
|
+
|
|
18
|
+
CODEX_ACCOUNT_LAUNCHERS_FILE_NAME: str = "account-launchers.json"
|
|
19
|
+
"""File under the profiles root that holds the saved account roster as a JSON list."""
|
|
20
|
+
|
|
21
|
+
CODEX_ACCOUNT_NAME_SEPARATOR: str = ","
|
|
22
|
+
"""Separator between account names in the roster environment variable."""
|
|
23
|
+
|
|
24
|
+
SETUP_PROMPT_TEXT: str = "Name for this Codex account launcher (blank to finish): "
|
|
25
|
+
"""Prompt the setup command shows for each account name."""
|
|
26
|
+
|
|
27
|
+
SETUP_SAVED_NAMES_TEMPLATE: str = "Saved Codex account launchers: {names}"
|
|
28
|
+
"""Line the setup command prints first, naming the saved roster."""
|
|
29
|
+
|
|
30
|
+
SETUP_NO_SAVED_NAMES_TEXT: str = "none"
|
|
31
|
+
"""Saved-roster text when no account launcher is saved."""
|
|
32
|
+
|
|
33
|
+
SETUP_NAMES_SEPARATOR: str = ", "
|
|
34
|
+
"""Separator between names on the setup command's saved-roster line."""
|
|
35
|
+
|
|
36
|
+
INVALID_ACCOUNT_ROSTER_TEMPLATE: str = "{source} names {name!r}: {reason}"
|
|
37
|
+
"""Error when the roster environment variable or file names an invalid account."""
|
|
38
|
+
|
|
39
|
+
ROSTER_NOT_A_LIST_TEMPLATE: str = "{source} must hold a JSON list of account names"
|
|
40
|
+
"""Error when the roster JSON is anything other than a list of names."""
|
|
41
|
+
|
|
42
|
+
ROSTER_JSON_INDENT: int = 2
|
|
43
|
+
"""Indent of the saved roster JSON, one name per line."""
|
|
44
|
+
|
|
45
|
+
COMMAND_SETUP: str = "setup"
|
|
46
|
+
"""Command that asks for the roster, saves it, and installs every launcher."""
|
|
47
|
+
|
|
48
|
+
COMMAND_INSTALL: str = "install"
|
|
49
|
+
"""Command that installs every saved roster account without asking."""
|
|
50
|
+
|
|
51
|
+
UNKNOWN_ACCOUNT_TEMPLATE: str = "unknown Codex account {name!r}; known: {known}"
|
|
52
|
+
"""Error when ``check`` names an account outside the roster."""
|
|
53
|
+
|
|
54
|
+
JSON_INSTALLED_KEY: str = "installed"
|
|
55
|
+
"""Setup JSON key holding the install report for every roster account."""
|
|
56
|
+
|
|
57
|
+
JSON_RETIRED_KEY: str = "retired"
|
|
58
|
+
"""Setup JSON key holding each dropped account and where its launcher moved."""
|
|
12
59
|
|
|
13
60
|
CODEX_PROFILES_ROOT_DIRECTORY_NAME: str = ".codex-profiles"
|
|
14
61
|
"""Directory under the user home that holds one Codex home per account."""
|
|
@@ -28,6 +75,7 @@ CODEX_AUTH_FILE_NAME: str = "auth.json"
|
|
|
28
75
|
ALL_SHARED_CODEX_HOME_NAMES: frozenset[str] = frozenset(
|
|
29
76
|
{
|
|
30
77
|
"AGENTS.md",
|
|
78
|
+
"agents",
|
|
31
79
|
"config.toml",
|
|
32
80
|
"hooks",
|
|
33
81
|
"hooks.json",
|
|
@@ -71,6 +119,13 @@ ALL_CODEX_BINARY_CANDIDATE_RELATIVE_PARTS: tuple[tuple[str, ...], ...] = (
|
|
|
71
119
|
CODEX_BINARY_NAME: str = "codex"
|
|
72
120
|
"""Codex command name looked up on PATH."""
|
|
73
121
|
|
|
122
|
+
CODEX_LAUNCHER_PROGRAM: LauncherProgram = LauncherProgram(
|
|
123
|
+
program=CODEX_BINARY_NAME,
|
|
124
|
+
environment_variable=CODEX_HOME_ENVIRONMENT_VARIABLE,
|
|
125
|
+
file_name_template="codex-{profile_name}.cmd",
|
|
126
|
+
)
|
|
127
|
+
"""Launcher that runs Codex with ``CODEX_HOME`` set to the account's home."""
|
|
128
|
+
|
|
74
129
|
ALL_APP_SERVER_ARGUMENTS: tuple[str, ...] = ("app-server", "--listen", "stdio://")
|
|
75
130
|
"""Arguments that start Codex as a JSON-RPC server on standard input and output."""
|
|
76
131
|
|