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.
- package/docs/codex-accounts.md +65 -12
- package/package.json +1 -1
- package/scripts/claude_account_profile.py +68 -21
- package/scripts/codex_account_choice.py +389 -24
- package/scripts/dev_env_scripts_constants/codex_account_constants.py +65 -2
- package/scripts/test_claude_account_profile.py +34 -0
- package/scripts/test_codex_account_choice.py +369 -1
package/docs/codex-accounts.md
CHANGED
|
@@ -1,24 +1,31 @@
|
|
|
1
1
|
# Codex accounts
|
|
2
2
|
|
|
3
|
-
The codex_account_choice picker spreads agent work across
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
|
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
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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`.
|
|
37
|
-
|
|
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
|
@@ -309,8 +309,60 @@ def sync_profile(
|
|
|
309
309
|
)
|
|
310
310
|
|
|
311
311
|
|
|
312
|
-
def
|
|
313
|
-
|
|
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
|
-
|
|
341
|
-
profile_name
|
|
396
|
+
target_path = launcher_path(
|
|
397
|
+
launcher_directory, profile_name, launcher_file_name_template
|
|
342
398
|
)
|
|
343
|
-
|
|
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
|
|
347
|
-
return
|
|
348
|
-
if
|
|
349
|
-
|
|
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
|
-
|
|
355
|
-
return
|
|
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
|
-
|
|
5
|
-
``~/.codex-profiles/<name>``.
|
|
4
|
+
Each Codex account signs in under its own Codex home in
|
|
5
|
+
``~/.codex-profiles/<name>``. The account roster names them in try order: the
|
|
6
|
+
``CODEX_ACCOUNT_PROFILES`` environment variable, comma-separated, else the saved
|
|
7
|
+
``account-launchers.json`` list under the profiles root. With neither,
|
|
8
|
+
``codex-1`` through ``codex-4`` are the roster. Jobs try them in that order::
|
|
6
9
|
|
|
7
10
|
first account with more than 10% left -> normal, that account
|
|
8
11
|
none over 10%, the roomiest one over 1% -> luna, stop at 1% left
|
|
@@ -10,7 +13,7 @@ Up to four Codex accounts sign in, each under its own Codex home in
|
|
|
10
13
|
none over 1%, or no meter reads -> wait, name the first reset
|
|
11
14
|
account not signed in, or its meter unread -> skipped
|
|
12
15
|
|
|
13
|
-
|
|
16
|
+
Five commands::
|
|
14
17
|
|
|
15
18
|
python codex_account_choice.py choose
|
|
16
19
|
{"tier": "normal", "account": "codex-1", "codex_home": "C:/Users/me/.codex-profiles/codex-1",
|
|
@@ -21,8 +24,15 @@ Three commands::
|
|
|
21
24
|
exit 0 while codex-2 has more than 1% left, exit 3 once it has not
|
|
22
25
|
|
|
23
26
|
python codex_account_choice.py sync
|
|
24
|
-
links the shared Codex setup (config, rules, skills, plugins) into
|
|
25
|
-
account's home and leaves each sign-in and history its own
|
|
27
|
+
links the shared Codex setup (config, rules, skills, plugins, agents) into
|
|
28
|
+
every roster account's home and leaves each sign-in and history its own
|
|
29
|
+
|
|
30
|
+
python codex_account_choice.py setup
|
|
31
|
+
asks for each account name, saves the roster, and writes one
|
|
32
|
+
codex-<name>.cmd launcher per account; a dropped name's launcher moves aside
|
|
33
|
+
|
|
34
|
+
python codex_account_choice.py install
|
|
35
|
+
reruns the setup without asking: syncs every saved account's home and launcher
|
|
26
36
|
"""
|
|
27
37
|
|
|
28
38
|
from __future__ import annotations
|
|
@@ -36,21 +46,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
|
|
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
|
|
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
|
-
|
|
345
|
-
report = sync_profile(
|
|
656
|
+
all_reports = {
|
|
657
|
+
each_name: _sync_account(
|
|
346
658
|
main_home=arguments.main_home,
|
|
347
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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"
|
|
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
|
|
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
|
|
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
|
-
|
|
392
|
-
|
|
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
|
|
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
|
|
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")
|