claude-dev-env 8.34.0 → 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,24 +1,31 @@
1
1
  # Codex accounts
2
2
 
3
- The codex_account_choice picker spreads agent work across up to four Codex
4
- accounts on one machine. Each account signs in under
5
- its own Codex home, and every home shares the same Codex setup: config, rules,
6
- skills, plugins and prompts. A job asks the picker which account to use.
3
+ The codex_account_choice picker spreads agent work across the Codex accounts
4
+ on one machine. Each account signs in under its own Codex home, and every home
5
+ shares the same Codex setup: config, rules, skills, plugins, prompts and agents.
6
+ A job asks the picker which account to use.
7
7
 
8
8
  ## Pieces
9
9
 
10
10
  | File | What it does |
11
11
  |---|---|
12
- | `scripts/codex_account_choice.py` | `choose` names the account and tier a job runs on, `check` tells a running job whether its account is still above a floor, `sync` links the shared setup into every account's home |
12
+ | `scripts/codex_account_choice.py` | `choose` names the account and tier a job runs on, `check` tells a running job whether its account is still above a floor, `sync` links the shared setup into every account's home, `setup` and `install` write one launcher per account |
13
13
  | `scripts/codex_account_meters.py` | Reads one account's rate-limit windows through `codex app-server` with `CODEX_HOME` set to that account's home |
14
- | `scripts/dev_env_scripts_constants/codex_account_constants.py` | The account names in try order, the shared entry names, the 10% bar, the 1% Luna stop, and the 20% 5-hour floor for Luna |
14
+ | `scripts/dev_env_scripts_constants/codex_account_constants.py` | The fallback account names, the roster variable and file, the launcher template, the shared entry names, the 10% bar, the 1% Luna stop, and the 20% 5-hour floor for Luna |
15
15
 
16
16
  ## Names and order
17
17
 
18
- The accounts are `codex-1`, `codex-2`, `codex-3` and `codex-4`. Their homes are
19
- `~/.codex-profiles/codex-1` and so on, or under `CODEX_PROFILES_ROOT` when set.
20
- Jobs try them in that order. The names say nothing about a plan, so a plan change
21
- keeps every name. To change the order, sign the accounts into different folders.
18
+ The account roster names the accounts and their order. It comes from the
19
+ `CODEX_ACCOUNT_PROFILES` environment variable, a comma-separated list such as
20
+ `alpha,beta`. When that variable is unset or empty, the roster is the saved list
21
+ in `account-launchers.json` under the profiles root. When neither exists, the
22
+ accounts are `codex-1`, `codex-2`, `codex-3` and `codex-4`.
23
+
24
+ `choose`, `sync` and `check` all work on that list, in that order. `check`
25
+ accepts only a name on it. Each account's home is `~/.codex-profiles/<name>`, or
26
+ `<name>` under `CODEX_PROFILES_ROOT` when set. `~/.codex` holds the shared setup
27
+ and is never an account. A name uses letters, digits, hyphens or underscores.
28
+ `main`, `wait` and the Windows device names are refused.
22
29
 
23
30
  ## Sign in once
24
31
 
@@ -33,8 +40,54 @@ $env:CODEX_HOME = "$HOME\.codex-profiles\codex-1"; codex login
33
40
  ```
34
41
 
35
42
  The sign-in lives in that folder's `auth.json`. Only the entries in
36
- `ALL_SHARED_CODEX_HOME_NAMES` link to `~/.codex`. Sign-in, sessions, history,
37
- logs and state files stay per account.
43
+ `ALL_SHARED_CODEX_HOME_NAMES` link to `~/.codex`. They are `AGENTS.md`, `agents`,
44
+ `config.toml`, `hooks`, `hooks.json`, `plugins`, `prompts`, `rules` and `skills`.
45
+ Sign-in, sessions, history, logs and state files stay per account.
46
+
47
+ ## Named launchers
48
+
49
+ Each roster account gets a launcher, `codex-<name>.cmd`, in `~/.local/bin`. It
50
+ sets `CODEX_HOME` to that account's home and runs Codex with every argument, so
51
+ `codex-alpha exec "fix the test"` runs on the `alpha` account.
52
+
53
+ Run the setup once:
54
+
55
+ ```
56
+ python packages/claude-dev-env/scripts/codex_account_choice.py setup
57
+ ```
58
+
59
+ It prints the saved names, then asks for one name per line. A blank line or the
60
+ end of input finishes. An invalid name prints the reason and asks again. A
61
+ repeated name counts once. The setup saves the list to
62
+ `~/.codex-profiles/account-launchers.json`, links each account's home to
63
+ `~/.codex`, and writes each launcher.
64
+
65
+ Rerun the installer after the shared setup or this package changes:
66
+
67
+ ```
68
+ python packages/claude-dev-env/scripts/codex_account_choice.py install
69
+ ```
70
+
71
+ It reads the roster, links every account's home, and rewrites any launcher that
72
+ differs. A second run changes nothing. With no roster it prints `{}`.
73
+ `CODEX_ACCOUNT_PROFILES` overrides the saved file here too. Both commands take
74
+ `--main-home` and `--launcher-directory`.
75
+
76
+ The launcher runs `call codex %*`. `codex` is npm's `codex.cmd`, and its last
77
+ line ends the batch scope. Without `call`, that line also drops the launcher's
78
+ `CODEX_HOME`, and Codex runs on the main home.
79
+
80
+ The names typed in one setup run become the whole roster. A saved name
81
+ left out has its launcher renamed to `codex-<name>.cmd.replaced-<time>`. Its
82
+ folder under the profiles root stays, with its `auth.json`, so typing the name
83
+ again restores it. A blank first line empties the roster and moves every launcher
84
+ aside.
85
+
86
+ `check <name>` reads a saved name the same way:
87
+
88
+ ```
89
+ python packages/claude-dev-env/scripts/codex_account_choice.py check alpha --floor 1
90
+ ```
38
91
 
39
92
  ## Which account a job uses
40
93
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.34.0",
3
+ "version": "8.35.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -309,8 +309,60 @@ def sync_profile(
309
309
  )
310
310
 
311
311
 
312
- def _moved_launcher_name(launcher_file_name: str, now: datetime) -> str:
313
- return f"{launcher_file_name}{LAUNCHER_REPLACED_SUFFIX}{_stamp(now)}"
312
+ def sync_report_payload(report: ProfileSyncReport) -> dict[str, object]:
313
+ """Turn a sync report into its JSON payload.
314
+
315
+ Args:
316
+ report: The entries one sync linked, moved aside, and unlinked.
317
+
318
+ Returns:
319
+ The linked, moved-aside, and unlinked entries as JSON lists.
320
+ """
321
+ return {
322
+ JSON_LINKED_KEY: list(report.all_linked),
323
+ JSON_MOVED_ASIDE_KEY: list(report.all_moved_aside),
324
+ JSON_UNLINKED_KEY: list(report.all_unlinked),
325
+ }
326
+
327
+
328
+ def move_launcher_aside(launcher_path: Path, now: datetime) -> Path:
329
+ """Rename a launcher to ``<name>.replaced-<time>`` beside it.
330
+
331
+ Args:
332
+ launcher_path: The launcher to move.
333
+ now: The run time that names the moved launcher.
334
+
335
+ Returns:
336
+ The moved launcher's path.
337
+ """
338
+ moved_path = launcher_path.with_name(
339
+ f"{launcher_path.name}{LAUNCHER_REPLACED_SUFFIX}{_stamp(now)}"
340
+ )
341
+ os.replace(launcher_path, moved_path)
342
+ return moved_path
343
+
344
+
345
+ def launcher_path(
346
+ launcher_directory: Path,
347
+ profile_name: str,
348
+ launcher_file_name_template: str = LAUNCHER_FILE_NAME_TEMPLATE,
349
+ ) -> Path:
350
+ """Name the launcher file for a profile.
351
+
352
+ Args:
353
+ launcher_directory: The directory on PATH that holds the launcher.
354
+ profile_name: The name used in the launcher file name.
355
+ launcher_file_name_template: The launcher file name, with ``{profile_name}``.
356
+
357
+ Returns:
358
+ The launcher path.
359
+
360
+ Raises:
361
+ ValueError: When the profile name is not a valid profile name.
362
+ """
363
+ return launcher_directory / launcher_file_name_template.format(
364
+ profile_name=validate_profile_name(profile_name)
365
+ )
314
366
 
315
367
 
316
368
  def write_launcher(
@@ -319,6 +371,8 @@ def write_launcher(
319
371
  profile_home: Path,
320
372
  now: datetime,
321
373
  profile_name: str = SECOND_ACCOUNT_PROFILE_NAME,
374
+ launcher_file_name_template: str = LAUNCHER_FILE_NAME_TEMPLATE,
375
+ launcher_text_template: str = LAUNCHER_TEXT_TEMPLATE,
322
376
  ) -> Path:
323
377
  """Write the launcher that runs Claude under a named profile.
324
378
 
@@ -333,26 +387,24 @@ def write_launcher(
333
387
  profile_home: The named account's Claude home.
334
388
  now: The run time that names a moved older launcher.
335
389
  profile_name: The name used in the launcher file name.
390
+ launcher_file_name_template: The launcher file name, with ``{profile_name}``.
391
+ launcher_text_template: The launcher body, with ``{profile_home}``.
336
392
 
337
393
  Returns:
338
394
  The launcher path.
339
395
  """
340
- launcher_file_name = LAUNCHER_FILE_NAME_TEMPLATE.format(
341
- profile_name=validate_profile_name(profile_name)
396
+ target_path = launcher_path(
397
+ launcher_directory, profile_name, launcher_file_name_template
342
398
  )
343
- launcher_path = launcher_directory / launcher_file_name
344
- launcher_text = LAUNCHER_TEXT_TEMPLATE.format(profile_home=profile_home)
399
+ launcher_text = launcher_text_template.format(profile_home=profile_home)
345
400
  launcher_bytes = launcher_text.encode(TEXT_ENCODING)
346
- if launcher_path.is_file() and launcher_path.read_bytes() == launcher_bytes:
347
- return launcher_path
348
- if launcher_path.is_file():
349
- os.replace(
350
- launcher_path,
351
- launcher_path.with_name(_moved_launcher_name(launcher_file_name, now)),
352
- )
401
+ if target_path.is_file() and target_path.read_bytes() == launcher_bytes:
402
+ return target_path
403
+ if target_path.is_file():
404
+ move_launcher_aside(target_path, now)
353
405
  launcher_directory.mkdir(parents=True, exist_ok=True)
354
- launcher_path.write_bytes(launcher_bytes)
355
- return launcher_path
406
+ target_path.write_bytes(launcher_bytes)
407
+ return target_path
356
408
 
357
409
 
358
410
  def _build_argument_parser() -> argparse.ArgumentParser:
@@ -403,12 +455,7 @@ def _sync_named_profile(arguments: argparse.Namespace) -> dict[str, object]:
403
455
  now=now,
404
456
  profile_name=arguments.profile_name,
405
457
  )
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
- }
458
+ return {**sync_report_payload(report), JSON_LAUNCHER_KEY: str(launcher_path)}
412
459
 
413
460
 
414
461
  if __name__ == "__main__":
@@ -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(
@@ -4,15 +4,85 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  import os
7
+ import subprocess
8
+ from collections.abc import Callable, Iterator
7
9
  from datetime import datetime, timedelta, timezone
8
10
  from pathlib import Path
9
11
 
10
12
  import pytest
11
13
 
12
14
  import codex_account_choice as choice
15
+ from claude_account_profile import links_to
13
16
  from codex_account_meters import CodexAccountMeters, CodexMeterUnreadError, UsageWindow
14
17
 
15
18
  NOW = datetime(2026, 9, 23, 17, 0, tzinfo=timezone.utc)
19
+ ALL_SHARED_ENTRY_NAMES = (
20
+ "AGENTS.md",
21
+ "agents",
22
+ "config.toml",
23
+ "hooks",
24
+ "hooks.json",
25
+ "plugins",
26
+ "rules",
27
+ "skills",
28
+ )
29
+ NPM_CODEX_SHIM_TEXT = (
30
+ "@ECHO off\r\n"
31
+ "GOTO start\r\n"
32
+ ":find_dp0\r\n"
33
+ "SET dp0=%~dp0\r\n"
34
+ "EXIT /b\r\n"
35
+ ":start\r\n"
36
+ "SETLOCAL\r\n"
37
+ "CALL :find_dp0\r\n"
38
+ 'SET "_prog=cmd"\r\n'
39
+ "endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% &"
40
+ ' "%_prog%" /d /c "echo %%CODEX_HOME%%"\r\n'
41
+ )
42
+
43
+
44
+ @pytest.fixture(autouse=True)
45
+ def clear_account_roster_environment(monkeypatch: pytest.MonkeyPatch) -> None:
46
+ monkeypatch.delenv("CODEX_ACCOUNT_PROFILES", raising=False)
47
+
48
+
49
+ def build_codex_main_home(tmp_path: Path) -> Path:
50
+ main_home = tmp_path / "main"
51
+ for each_directory in ("agents", "hooks", "plugins", "rules", "skills", "sessions"):
52
+ (main_home / each_directory).mkdir(parents=True)
53
+ for each_file in ("AGENTS.md", "config.toml", "hooks.json", "auth.json", "history.jsonl"):
54
+ (main_home / each_file).write_text(each_file, encoding="utf-8")
55
+ return main_home
56
+
57
+
58
+ def save_roster(profiles_root: Path, document: object) -> None:
59
+ profiles_root.mkdir(parents=True, exist_ok=True)
60
+ (profiles_root / "account-launchers.json").write_text(
61
+ json.dumps(document), encoding="utf-8"
62
+ )
63
+
64
+
65
+ def typed_lines(*all_lines: str) -> Callable[[str], str]:
66
+ remaining_lines: Iterator[str] = iter(all_lines)
67
+
68
+ def read_line(prompt: str) -> str:
69
+ assert prompt == "Name for this Codex account launcher (blank to finish): "
70
+ try:
71
+ return next(remaining_lines)
72
+ except StopIteration as error:
73
+ raise EOFError from error
74
+
75
+ return read_line
76
+
77
+
78
+ def install(main_home: Path, tmp_path: Path, *all_names: str) -> dict[str, dict[str, object]]:
79
+ return choice.install_codex_account_launchers(
80
+ main_home=main_home,
81
+ profiles_root=tmp_path / "profiles",
82
+ launcher_directory=tmp_path / "bin",
83
+ all_names=all_names,
84
+ now=NOW,
85
+ )
16
86
 
17
87
 
18
88
  def reading(
@@ -251,7 +321,7 @@ class TestSync:
251
321
  class TestSmallHelpers:
252
322
  @pytest.mark.parametrize(
253
323
  ("entry_name", "is_local"),
254
- [("auth.json", True), ("sessions", True), ("state_5.sqlite", True), ("config.toml", False), ("plugins", False)],
324
+ [("auth.json", True), ("sessions", True), ("state_5.sqlite", True), ("config.toml", False), ("plugins", False), ("agents", False)],
255
325
  )
256
326
  def should_keep_every_entry_outside_the_shared_set_per_account(
257
327
  self, entry_name: str, is_local: bool
@@ -274,3 +344,301 @@ class TestSmallHelpers:
274
344
  payload = choice.reading_payload(reading("codex-1", weekly_used=40.0, short_used=5.0))
275
345
  assert payload["percent_left"] == 60.0
276
346
  assert [each["minutes"] for each in payload["windows"]] == [300, 10080]
347
+
348
+
349
+ class TestAccountRoster:
350
+ def should_take_the_environment_roster_over_the_saved_file(
351
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
352
+ ) -> None:
353
+ save_roster(tmp_path, ["gamma"])
354
+ monkeypatch.setenv("CODEX_ACCOUNT_PROFILES", " beta , alpha,, ")
355
+ assert choice.saved_codex_account_names(tmp_path) == ("beta", "alpha")
356
+
357
+ def should_read_the_saved_file_when_the_environment_is_unset(
358
+ self, tmp_path: Path
359
+ ) -> None:
360
+ save_roster(tmp_path, ["gamma", "alpha"])
361
+ assert choice.saved_codex_account_names(tmp_path) == ("gamma", "alpha")
362
+
363
+ def should_name_no_account_when_neither_is_set(self, tmp_path: Path) -> None:
364
+ assert choice.saved_codex_account_names(tmp_path) == ()
365
+
366
+ def should_refuse_an_invalid_name_from_the_environment(
367
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
368
+ ) -> None:
369
+ monkeypatch.setenv("CODEX_ACCOUNT_PROFILES", "alpha,bad name")
370
+ with pytest.raises(ValueError, match="CODEX_ACCOUNT_PROFILES names 'bad name'"):
371
+ choice.saved_codex_account_names(tmp_path)
372
+
373
+ @pytest.mark.parametrize("document", [["alpha", "con"], ["alpha", 3], {"alpha": 1}])
374
+ def should_refuse_a_saved_file_with_an_invalid_name(
375
+ self, tmp_path: Path, document: object
376
+ ) -> None:
377
+ save_roster(tmp_path, document)
378
+ with pytest.raises(ValueError, match="account-launchers.json"):
379
+ choice.saved_codex_account_names(tmp_path)
380
+
381
+ def should_refuse_a_saved_file_that_is_not_json(
382
+ self, tmp_path: Path, capsys: pytest.CaptureFixture[str]
383
+ ) -> None:
384
+ (tmp_path / "account-launchers.json").write_text('["alpha",', encoding="utf-8")
385
+ with pytest.raises(SystemExit) as exit_info:
386
+ choice.main(["--profiles-root", str(tmp_path), "check", "alpha"])
387
+ assert exit_info.value.code == 2
388
+ assert "account-launchers.json" in capsys.readouterr().err
389
+
390
+ def should_drop_duplicates_and_keep_the_first_order(self, tmp_path: Path) -> None:
391
+ save_roster(tmp_path, ["beta", "alpha", "beta", "gamma", "alpha"])
392
+ assert choice.saved_codex_account_names(tmp_path) == ("beta", "alpha", "gamma")
393
+
394
+ def should_save_the_roster_as_a_json_list_ending_in_a_newline(
395
+ self, tmp_path: Path
396
+ ) -> None:
397
+ profiles_root = tmp_path / "profiles"
398
+ choice.save_codex_account_names(profiles_root, ("alpha", "beta"))
399
+ assert (profiles_root / "account-launchers.json").read_bytes() == (
400
+ b'[\n "alpha",\n "beta"\n]\n'
401
+ )
402
+ assert choice.saved_codex_account_names(profiles_root) == ("alpha", "beta")
403
+
404
+ def should_fall_back_to_the_four_fixed_accounts_without_a_roster(
405
+ self, tmp_path: Path
406
+ ) -> None:
407
+ assert choice.codex_account_names(tmp_path) == (
408
+ "codex-1",
409
+ "codex-2",
410
+ "codex-3",
411
+ "codex-4",
412
+ )
413
+
414
+
415
+ class TestRosterDrivesTheJobCommands:
416
+ def should_try_the_roster_in_order_and_skip_the_fixed_accounts(
417
+ self,
418
+ tmp_path: Path,
419
+ monkeypatch: pytest.MonkeyPatch,
420
+ capsys: pytest.CaptureFixture[str],
421
+ ) -> None:
422
+ all_weekly_used = {"beta": 95.0, "alpha": 50.0, "codex-1": 0.0}
423
+ for each_name in all_weekly_used:
424
+ (tmp_path / each_name).mkdir()
425
+ (tmp_path / each_name / "auth.json").write_text("{}")
426
+ monkeypatch.setenv("CODEX_ACCOUNT_PROFILES", "beta,alpha")
427
+ monkeypatch.setattr(
428
+ choice,
429
+ "_meter_reader",
430
+ lambda codex_path: (
431
+ lambda codex_home: CodexAccountMeters(
432
+ (UsageWindow(10080, all_weekly_used[codex_home.name], None),)
433
+ )
434
+ ),
435
+ )
436
+ assert choice.main(["--profiles-root", str(tmp_path), "choose"]) == 0
437
+ payload = json.loads(capsys.readouterr().out)
438
+ assert (payload["tier"], payload["account"]) == ("normal", "alpha")
439
+ assert [each["name"] for each in payload["accounts"]] == ["beta", "alpha"]
440
+
441
+ def should_choose_among_the_four_fixed_accounts_without_a_roster(
442
+ self,
443
+ tmp_path: Path,
444
+ monkeypatch: pytest.MonkeyPatch,
445
+ capsys: pytest.CaptureFixture[str],
446
+ ) -> None:
447
+ monkeypatch.setattr(choice, "_meter_reader", lambda codex_path: None)
448
+ assert choice.main(["--profiles-root", str(tmp_path), "choose"]) == 0
449
+ payload = json.loads(capsys.readouterr().out)
450
+ assert [each["name"] for each in payload["accounts"]] == [
451
+ "codex-1",
452
+ "codex-2",
453
+ "codex-3",
454
+ "codex-4",
455
+ ]
456
+
457
+ def should_sync_only_the_roster_accounts(
458
+ self,
459
+ tmp_path: Path,
460
+ monkeypatch: pytest.MonkeyPatch,
461
+ capsys: pytest.CaptureFixture[str],
462
+ ) -> None:
463
+ main_home = build_codex_main_home(tmp_path)
464
+ profiles_root = tmp_path / "profiles"
465
+ monkeypatch.setenv("CODEX_ACCOUNT_PROFILES", "alpha")
466
+ all_arguments = ["--profiles-root", str(profiles_root), "sync"]
467
+ assert choice.main([*all_arguments, "--main-home", str(main_home)]) == 0
468
+ assert list(json.loads(capsys.readouterr().out)) == ["alpha"]
469
+ assert links_to(profiles_root / "alpha" / "agents", main_home / "agents")
470
+ assert not (profiles_root / "codex-1").exists()
471
+
472
+ def should_check_a_saved_roster_account(
473
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
474
+ ) -> None:
475
+ save_roster(tmp_path, ["alpha"])
476
+ (tmp_path / "alpha").mkdir()
477
+ (tmp_path / "alpha" / "auth.json").write_text("{}")
478
+ monkeypatch.setattr(
479
+ choice,
480
+ "_meter_reader",
481
+ lambda codex_path: (
482
+ lambda codex_home: CodexAccountMeters((UsageWindow(10080, 40.0, None),))
483
+ ),
484
+ )
485
+ assert choice.main(["--profiles-root", str(tmp_path), "check", "alpha"]) == 0
486
+
487
+ @pytest.mark.parametrize("account", ["gamma", "codex-1"])
488
+ def should_refuse_to_check_an_account_outside_the_roster(
489
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, account: str
490
+ ) -> None:
491
+ save_roster(tmp_path, ["alpha"])
492
+ monkeypatch.setattr(choice, "_meter_reader", lambda codex_path: None)
493
+ with pytest.raises(SystemExit) as exit_info:
494
+ choice.main(["--profiles-root", str(tmp_path), "check", account])
495
+ assert exit_info.value.code == 2
496
+
497
+
498
+ class TestInstallLaunchers:
499
+ def should_link_the_shared_setup_and_keep_each_sign_in_per_account(
500
+ self, tmp_path: Path
501
+ ) -> None:
502
+ main_home = build_codex_main_home(tmp_path)
503
+ report = install(main_home, tmp_path, "alpha", "beta")
504
+ assert list(report) == ["alpha", "beta"]
505
+ for each_name in ("alpha", "beta"):
506
+ each_home = tmp_path / "profiles" / each_name
507
+ for each_entry in ALL_SHARED_ENTRY_NAMES:
508
+ assert links_to(each_home / each_entry, main_home / each_entry)
509
+ for each_local_entry in ("auth.json", "history.jsonl", "sessions"):
510
+ assert not os.path.lexists(each_home / each_local_entry)
511
+ assert report[each_name]["launcher"] == str(
512
+ tmp_path / "bin" / f"codex-{each_name}.cmd"
513
+ )
514
+
515
+ def should_change_nothing_on_a_second_run(self, tmp_path: Path) -> None:
516
+ main_home = build_codex_main_home(tmp_path)
517
+ install(main_home, tmp_path, "alpha")
518
+ launcher_path = tmp_path / "bin" / "codex-alpha.cmd"
519
+ first_launcher_bytes = launcher_path.read_bytes()
520
+ second_report = install(main_home, tmp_path, "alpha")
521
+ assert second_report["alpha"]["linked"] == []
522
+ assert second_report["alpha"]["moved_aside"] == []
523
+ assert second_report["alpha"]["unlinked"] == []
524
+ assert launcher_path.read_bytes() == first_launcher_bytes
525
+ assert sorted(each.name for each in (tmp_path / "bin").iterdir()) == [
526
+ "codex-alpha.cmd"
527
+ ]
528
+
529
+ def should_set_codex_home_and_call_codex_in_the_launcher(
530
+ self, tmp_path: Path
531
+ ) -> None:
532
+ install(build_codex_main_home(tmp_path), tmp_path, "alpha")
533
+ launcher_lines = (tmp_path / "bin" / "codex-alpha.cmd").read_bytes().split(b"\r\n")
534
+ profile_home = tmp_path / "profiles" / "alpha"
535
+ assert f'set "CODEX_HOME={profile_home}"'.encode() in launcher_lines
536
+ assert b"call codex %*" in launcher_lines
537
+
538
+ def should_install_the_environment_roster_from_the_command_line(
539
+ self,
540
+ tmp_path: Path,
541
+ monkeypatch: pytest.MonkeyPatch,
542
+ capsys: pytest.CaptureFixture[str],
543
+ ) -> None:
544
+ main_home = build_codex_main_home(tmp_path)
545
+ save_roster(tmp_path / "profiles", ["gamma"])
546
+ monkeypatch.setenv("CODEX_ACCOUNT_PROFILES", "beta")
547
+ all_arguments = [
548
+ "--profiles-root",
549
+ str(tmp_path / "profiles"),
550
+ "install",
551
+ "--main-home",
552
+ str(main_home),
553
+ "--launcher-directory",
554
+ str(tmp_path / "bin"),
555
+ ]
556
+ assert choice.main(all_arguments) == 0
557
+ assert list(json.loads(capsys.readouterr().out)) == ["beta"]
558
+ assert (tmp_path / "bin" / "codex-beta.cmd").is_file()
559
+ assert not (tmp_path / "bin" / "codex-gamma.cmd").exists()
560
+
561
+ def should_print_an_empty_report_for_an_empty_roster(
562
+ self, tmp_path: Path, capsys: pytest.CaptureFixture[str]
563
+ ) -> None:
564
+ all_arguments = ["--profiles-root", str(tmp_path / "profiles"), "install"]
565
+ assert choice.main([*all_arguments, "--launcher-directory", str(tmp_path / "bin")]) == 0
566
+ assert json.loads(capsys.readouterr().out) == {}
567
+ assert not (tmp_path / "bin").exists()
568
+
569
+
570
+ class TestSetupRoster:
571
+ def should_save_each_valid_name_once_in_typed_order(self, tmp_path: Path) -> None:
572
+ all_shown: list[str] = []
573
+ all_names, all_retired = choice.update_codex_account_roster(
574
+ profiles_root=tmp_path,
575
+ launcher_directory=tmp_path / "bin",
576
+ now=NOW,
577
+ read_line=typed_lines("alpha", "bad name", "alpha", "beta", "", "gamma"),
578
+ write_line=all_shown.append,
579
+ )
580
+ assert (all_names, all_retired) == (("alpha", "beta"), {})
581
+ assert choice.saved_codex_account_names(tmp_path) == ("alpha", "beta")
582
+ assert all_shown == [
583
+ "Saved Codex account launchers: none",
584
+ "profile name must use letters, digits, hyphens, or underscores",
585
+ ]
586
+
587
+ def should_finish_at_the_end_of_input(self, tmp_path: Path) -> None:
588
+ all_names, _ = choice.update_codex_account_roster(
589
+ profiles_root=tmp_path,
590
+ launcher_directory=tmp_path / "bin",
591
+ now=NOW,
592
+ read_line=typed_lines("gamma"),
593
+ write_line=lambda line: None,
594
+ )
595
+ assert all_names == ("gamma",)
596
+
597
+ def should_move_a_dropped_launcher_aside_and_keep_its_home(
598
+ self, tmp_path: Path
599
+ ) -> None:
600
+ main_home = build_codex_main_home(tmp_path)
601
+ profiles_root = tmp_path / "profiles"
602
+ choice.save_codex_account_names(profiles_root, ("alpha", "beta"))
603
+ install(main_home, tmp_path, "alpha", "beta")
604
+ (profiles_root / "beta" / "auth.json").write_text("beta sign-in", encoding="utf-8")
605
+ all_shown: list[str] = []
606
+ all_names, all_retired = choice.update_codex_account_roster(
607
+ profiles_root=profiles_root,
608
+ launcher_directory=tmp_path / "bin",
609
+ now=NOW,
610
+ read_line=typed_lines("alpha", ""),
611
+ write_line=all_shown.append,
612
+ )
613
+ moved_launcher = tmp_path / "bin" / "codex-beta.cmd.replaced-20260923T170000Z"
614
+ assert all_names == ("alpha",)
615
+ assert all_retired == {"beta": str(moved_launcher)}
616
+ assert all_shown == ["Saved Codex account launchers: alpha, beta"]
617
+ assert moved_launcher.is_file()
618
+ assert not (tmp_path / "bin" / "codex-beta.cmd").exists()
619
+ assert (tmp_path / "bin" / "codex-alpha.cmd").is_file()
620
+ assert (profiles_root / "beta" / "auth.json").read_text(encoding="utf-8") == (
621
+ "beta sign-in"
622
+ )
623
+ assert choice.saved_codex_account_names(profiles_root) == ("alpha",)
624
+
625
+
626
+ class TestCodexLauncher:
627
+ @pytest.mark.skipif(os.name != "nt", reason="runs the launcher through cmd.exe")
628
+ def should_hand_codex_home_to_the_npm_codex_shim(self, tmp_path: Path) -> None:
629
+ install(build_codex_main_home(tmp_path), tmp_path, "alpha")
630
+ launcher_path = tmp_path / "bin" / "codex-alpha.cmd"
631
+ fake_directory = tmp_path / "fake"
632
+ fake_directory.mkdir()
633
+ (fake_directory / "codex.cmd").write_bytes(NPM_CODEX_SHIM_TEXT.encode("utf-8"))
634
+ environment = dict(os.environ)
635
+ environment["PATH"] = str(fake_directory) + os.pathsep + environment["PATH"]
636
+ environment["CODEX_HOME"] = str(tmp_path / "decoy")
637
+ completed = subprocess.run(
638
+ ["cmd", "/c", str(launcher_path)],
639
+ env=environment,
640
+ capture_output=True,
641
+ text=True,
642
+ check=False,
643
+ )
644
+ assert completed.stdout.strip() == str(tmp_path / "profiles" / "alpha")