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