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.
@@ -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
- Up to four Codex accounts sign in, each under its own Codex home in
5
- ``~/.codex-profiles/<name>``. Jobs try them in one fixed order::
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
- Three commands::
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 every
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 sync_profile
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 ALL_CODEX_ACCOUNT_NAMES
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
- for each_name in ALL_CODEX_ACCOUNT_NAMES:
345
- report = sync_profile(
652
+ all_reports = {
653
+ each_name: _sync_account(
346
654
  main_home=arguments.main_home,
347
- profile_home=arguments.profiles_root / each_name,
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
- all_reports[each_name] = {
352
- "linked": list(report.all_linked),
353
- "moved_aside": list(report.all_moved_aside),
354
- "unlinked": list(report.all_unlinked),
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", choices=ALL_CODEX_ACCOUNT_NAMES)
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.add_argument(
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 sync; for check, zero with room and three without.
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
- arguments = _build_argument_parser().parse_args(all_command_arguments)
392
- return arguments.run(arguments)
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
- 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
- )
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
- LAUNCHER_TEXT_TEMPLATE: str = (
47
+ LAUNCHER_BODY_TEMPLATE: str = (
50
48
  "@echo off\r\n"
51
49
  "setlocal\r\n"
52
- 'set "CLAUDE_CONFIG_DIR={profile_home}"\r\n'
53
- "claude %*\r\n"
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 Claude at the profile and pass every argument through."""
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 one fixed order.
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 the order jobs try them. The names say nothing about a plan."""
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