claude-dev-env 8.98.10 → 8.98.11

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.
@@ -6,7 +6,7 @@ Claude's main home is `~/.claude`. Its `claude-chain.json` file contains a `chai
6
6
 
7
7
  The optional `claude-account-order.json` file in the main home sets the Claude pick priority. It is a JSON list of account names, first choice first, such as `["claude-second", "claude"]`. An entry matches an account's name, its command, or its `claude-<name>` launcher, and `claude` names the main account. A new job goes to the first listed account with room. When no listed account has room, the broker picks the unlisted account with the most room, and it waits when no account has room. A resumed session stays on its bound account while that account has room. A listed name absent from the roster is skipped. Without the file, every job goes to the account with the most room. Edit the file to change the order; the next pick reads it.
8
8
 
9
- Codex uses its configured account list. When no Codex roster is configured, the broker chooses the default Codex home. A new Codex job goes to an account on a Pro plan before any other account in the same tier. The plan comes from the account's meter reply, and `ALL_PRO_PLAN_TYPES` lists the Pro plans: `pro`, `prolite`, and `promax`. Among accounts of the same kind, the one with the most room wins. A Pro account below the `normal` floor still loses to any account with room in the `normal` tier.
9
+ Codex uses its configured account list. A broker run that reads `CODEX_ACCOUNT_PROFILES` saves that list to `account-launchers.json`, so a later run without the variable reads the same roster; [Codex accounts](codex-accounts.md) has the details. When no Codex roster is configured, the broker chooses the default Codex home. A new Codex job goes to an account on a Pro plan before any other account in the same tier. The plan comes from the account's meter reply, and `ALL_PRO_PLAN_TYPES` lists the Pro plans: `pro`, `prolite`, and `promax`. Among accounts of the same kind, the one with the most room wins. A Pro account below the `normal` floor still loses to any account with room in the `normal` tier.
10
10
 
11
11
  ## Commands
12
12
 
@@ -22,6 +22,11 @@ The account list names the accounts and their order. It comes from the
22
22
  in `account-launchers.json` under the profiles root. When neither exists, the
23
23
  accounts are `codex-1`, `codex-2`, `codex-3` and `codex-4`.
24
24
 
25
+ Each read of the variable's list saves it to `account-launchers.json` when the
26
+ file holds a different list. A service, runner, or other process started
27
+ without the variable then reads the same roster from the file. When the file
28
+ cannot be written, the read prints a warning and uses the variable's list.
29
+
25
30
  The broker's `choose` and `check` commands and the setup commands use that list.
26
31
  Each account's home is `~/.codex-profiles/<name>`, or
27
32
  `<name>` under `CODEX_PROFILES_ROOT` when set. `~/.codex` holds the shared setup
@@ -84,6 +89,12 @@ folder under the profiles root stays, with its `auth.json`, so typing the name
84
89
  again restores it. A blank first line empties the roster and moves every launcher
85
90
  aside.
86
91
 
92
+ While `CODEX_ACCOUNT_PROFILES` is set, the variable's list stays the roster. The
93
+ next broker, sync, or install run saves it over the setup list, and setup prints
94
+ a warning when the two differ. Change or clear the variable to change the
95
+ roster. Two processes with different values of the variable each save their own
96
+ list, so a process without the variable reads whichever list was saved last.
97
+
87
98
  ## Which account a job uses
88
99
 
89
100
  Room is the least percent left across an account's windows, such as the 5-hour window and the week.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.98.10",
3
+ "version": "8.98.11",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -5,7 +5,9 @@ Each Codex account signs in under its own Codex home in
5
5
  ``~/.codex-profiles/<name>``. The account list names them in try order: the
6
6
  ``CODEX_ACCOUNT_PROFILES`` environment variable, comma-separated, else the saved
7
7
  ``account-launchers.json`` list under the profiles root. With neither,
8
- ``codex-1`` through ``codex-4`` are the roster.
8
+ ``codex-1`` through ``codex-4`` are the roster. An environment roster that
9
+ differs from the saved file is saved to it, so a process without the variable
10
+ reads the same roster.
9
11
 
10
12
  Three commands::
11
13
 
@@ -27,6 +29,7 @@ import argparse
27
29
  import json
28
30
  import os
29
31
  import sys
32
+ import time
30
33
  from collections.abc import Callable, Sequence
31
34
  from datetime import datetime, timezone
32
35
  from pathlib import Path
@@ -60,8 +63,12 @@ from dev_env_scripts_constants.codex_account_constants import (
60
63
  MAIN_CODEX_HOME_DIRECTORY_NAME,
61
64
  ROSTER_JSON_INDENT,
62
65
  ROSTER_NOT_A_LIST_TEMPLATE,
66
+ ROSTER_REPLACE_ATTEMPTS,
67
+ ROSTER_REPLACE_RETRY_SECONDS,
68
+ SAVE_ENVIRONMENT_ROSTER_FAILED_TEMPLATE,
63
69
  SETUP_NAMES_SEPARATOR,
64
70
  SETUP_NO_SAVED_NAMES_TEXT,
71
+ SETUP_OVERRIDDEN_BY_ENVIRONMENT_TEMPLATE,
65
72
  SETUP_PROMPT_TEXT,
66
73
  SETUP_SAVED_NAMES_TEMPLATE,
67
74
  TEXT_ENCODING,
@@ -156,12 +163,44 @@ def _saved_roster_file_names(profiles_root: Path) -> tuple[str, ...]:
156
163
  return _roster(document, str(roster_path))
157
164
 
158
165
 
166
+ def _saved_roster_or_nothing(profiles_root: Path) -> tuple[str, ...]:
167
+ try:
168
+ return _saved_roster_file_names(profiles_root)
169
+ except (CodexAccountNameError, OSError, UnicodeError):
170
+ return ()
171
+
172
+
173
+ def _save_environment_roster(
174
+ profiles_root: Path, all_environment_names: tuple[str, ...]
175
+ ) -> None:
176
+ """Copy the environment roster into the saved file when the two differ.
177
+
178
+ A process started without ``CODEX_ACCOUNT_PROFILES`` then reads the same
179
+ roster from the file.
180
+
181
+ Args:
182
+ profiles_root: The directory that holds every account's Codex home.
183
+ all_environment_names: The validated names the environment gives.
184
+ """
185
+ if _saved_roster_or_nothing(profiles_root) == all_environment_names:
186
+ return
187
+ try:
188
+ save_codex_account_names(profiles_root, all_environment_names)
189
+ except OSError as error:
190
+ print(
191
+ SAVE_ENVIRONMENT_ROSTER_FAILED_TEMPLATE.format(
192
+ path=profiles_root / CODEX_ACCOUNT_LAUNCHERS_FILE_NAME, error=error
193
+ ),
194
+ file=sys.stderr,
195
+ )
196
+
197
+
159
198
  def saved_codex_account_names(profiles_root: Path) -> tuple[str, ...]:
160
199
  """Name the account list, in try order, without duplicates.
161
200
 
162
201
  ::
163
202
 
164
- CODEX_ACCOUNT_PROFILES="alpha, beta,alpha" -> ("alpha", "beta")
203
+ CODEX_ACCOUNT_PROFILES="alpha, beta,alpha" -> ("alpha", "beta"), saved to the file
165
204
  unset, account-launchers.json ["gamma"] -> ("gamma",)
166
205
  unset, no file -> ()
167
206
 
@@ -170,11 +209,16 @@ def saved_codex_account_names(profiles_root: Path) -> tuple[str, ...]:
170
209
 
171
210
  Returns:
172
211
  The environment's names when it names any, else the saved file's names.
212
+ Environment names that differ from the file are saved to it.
173
213
 
174
214
  Raises:
175
215
  CodexAccountNameError: When the environment or the file names an invalid account.
176
216
  """
177
- return _environment_roster() or _saved_roster_file_names(profiles_root)
217
+ all_environment_names = _environment_roster()
218
+ if all_environment_names:
219
+ _save_environment_roster(profiles_root, all_environment_names)
220
+ return all_environment_names
221
+ return _saved_roster_file_names(profiles_root)
178
222
 
179
223
 
180
224
  def codex_account_names(profiles_root: Path) -> tuple[str, ...]:
@@ -192,15 +236,48 @@ def codex_account_names(profiles_root: Path) -> tuple[str, ...]:
192
236
  def save_codex_account_names(profiles_root: Path, all_names: Sequence[str]) -> None:
193
237
  """Write the account list as a JSON list under the profiles root.
194
238
 
239
+ The list goes to a staged file first, and ``os.replace`` moves it over the
240
+ saved file in one step, so a concurrent reader sees the old list or the new one.
241
+
195
242
  Args:
196
243
  profiles_root: The directory that holds every account's Codex home.
197
244
  all_names: The account names in try order.
198
245
  """
199
246
  profiles_root.mkdir(parents=True, exist_ok=True)
200
247
  roster_text = json.dumps(list(all_names), indent=ROSTER_JSON_INDENT) + "\n"
201
- (profiles_root / CODEX_ACCOUNT_LAUNCHERS_FILE_NAME).write_bytes(
202
- roster_text.encode(TEXT_ENCODING)
203
- )
248
+ roster_path = profiles_root / CODEX_ACCOUNT_LAUNCHERS_FILE_NAME
249
+ staged_path = roster_path.with_name(f"{roster_path.name}.{os.getpid()}.tmp")
250
+ try:
251
+ staged_path.write_bytes(roster_text.encode(TEXT_ENCODING))
252
+ _replace_with_retries(staged_path, roster_path)
253
+ except OSError:
254
+ staged_path.unlink(missing_ok=True)
255
+ raise
256
+
257
+
258
+ def _replace_with_retries(staged_path: Path, roster_path: Path) -> None:
259
+ """Replace the saved roster, retrying while a Windows reader holds it open.
260
+
261
+ Args:
262
+ staged_path: The fully written new roster file.
263
+ roster_path: The saved roster file to replace.
264
+
265
+ Raises:
266
+ PermissionError: When the saved file stays locked through every attempt.
267
+ """
268
+ for _ in range(ROSTER_REPLACE_ATTEMPTS - 1):
269
+ if _replaced(staged_path, roster_path):
270
+ return
271
+ time.sleep(ROSTER_REPLACE_RETRY_SECONDS)
272
+ os.replace(staged_path, roster_path)
273
+
274
+
275
+ def _replaced(staged_path: Path, roster_path: Path) -> bool:
276
+ try:
277
+ os.replace(staged_path, roster_path)
278
+ except PermissionError:
279
+ return False
280
+ return True
204
281
 
205
282
 
206
283
  def _sync_account(
@@ -403,9 +480,24 @@ def _run_setup(arguments: argparse.Namespace) -> int:
403
480
  now=now,
404
481
  )
405
482
  print(json.dumps({JSON_INSTALLED_KEY: all_installed, JSON_RETIRED_KEY: all_retired}))
483
+ _warn_when_the_environment_replaces(all_names)
406
484
  return 0
407
485
 
408
486
 
487
+ def _warn_when_the_environment_replaces(all_setup_names: tuple[str, ...]) -> None:
488
+ try:
489
+ all_environment_names = _environment_roster()
490
+ except CodexAccountNameError:
491
+ return
492
+ if all_environment_names and all_environment_names != all_setup_names:
493
+ print(
494
+ SETUP_OVERRIDDEN_BY_ENVIRONMENT_TEMPLATE.format(
495
+ names=SETUP_NAMES_SEPARATOR.join(all_environment_names)
496
+ ),
497
+ file=sys.stderr,
498
+ )
499
+
500
+
409
501
  def _add_main_home_argument(command_parser: argparse.ArgumentParser) -> None:
410
502
  command_parser.add_argument(
411
503
  "--main-home", type=Path, default=Path.home() / MAIN_CODEX_HOME_DIRECTORY_NAME
@@ -39,6 +39,23 @@ INVALID_ACCOUNT_ROSTER_TEMPLATE: str = "{source} names {name!r}: {reason}"
39
39
  ROSTER_NOT_A_LIST_TEMPLATE: str = "{source} must hold a JSON list of account names"
40
40
  """Error when the roster JSON is anything other than a list of names."""
41
41
 
42
+ SAVE_ENVIRONMENT_ROSTER_FAILED_TEMPLATE: str = (
43
+ "warning: cannot save the CODEX_ACCOUNT_PROFILES roster to {path}: {error}"
44
+ )
45
+ """Warning when the environment roster cannot be copied into the saved file."""
46
+
47
+ ROSTER_REPLACE_ATTEMPTS: int = 5
48
+ """Tries at replacing the saved roster while a Windows reader holds it open."""
49
+
50
+ ROSTER_REPLACE_RETRY_SECONDS: float = 0.05
51
+ """Pause between tries at replacing the saved roster."""
52
+
53
+ SETUP_OVERRIDDEN_BY_ENVIRONMENT_TEMPLATE: str = (
54
+ "warning: CODEX_ACCOUNT_PROFILES names {names}; the next broker, sync or install run"
55
+ " saves that list over this setup list"
56
+ )
57
+ """Warning when setup saves a list that the environment roster will replace."""
58
+
42
59
  ROSTER_JSON_INDENT: int = 2
43
60
  """Indent of the saved roster JSON, one name per line."""
44
61
 
@@ -850,6 +850,18 @@ def test_should_use_default_codex_home_without_roster(
850
850
  assert "no roster" in decision.reason
851
851
 
852
852
 
853
+ def test_should_keep_the_codex_roster_for_a_process_without_the_roster_variable(
854
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
855
+ ) -> None:
856
+ monkeypatch.setattr(account_broker.support.codex_account_choice, "default_profiles_root", lambda: tmp_path / "profiles")
857
+ monkeypatch.setenv("CODEX_ACCOUNT_PROFILES", "team,personal")
858
+ assert [each_account.name for each_account in account_broker.load_codex_accounts()] == ["team", "personal"]
859
+
860
+ monkeypatch.delenv("CODEX_ACCOUNT_PROFILES")
861
+
862
+ assert [each_account.name for each_account in account_broker.load_codex_accounts()] == ["team", "personal"]
863
+
864
+
853
865
  def test_should_reuse_meter_cache_for_60_seconds() -> None:
854
866
  account = _account("cached")
855
867
  calls: list[str] = []
@@ -144,6 +144,79 @@ class TestAccountRoster:
144
144
  monkeypatch.setenv("CODEX_ACCOUNT_PROFILES", " beta , alpha,, ")
145
145
  assert choice.saved_codex_account_names(tmp_path) == ("beta", "alpha")
146
146
 
147
+ def should_save_the_environment_roster_for_a_process_without_it(
148
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
149
+ ) -> None:
150
+ monkeypatch.setenv("CODEX_ACCOUNT_PROFILES", "beta,alpha")
151
+ assert choice.saved_codex_account_names(tmp_path) == ("beta", "alpha")
152
+ monkeypatch.delenv("CODEX_ACCOUNT_PROFILES")
153
+ assert choice.saved_codex_account_names(tmp_path) == ("beta", "alpha")
154
+
155
+ @pytest.mark.parametrize("saved_bytes", [b'["alpha",', b"\xff\xfe\x00"])
156
+ def should_replace_an_unreadable_saved_file_with_the_environment_roster(
157
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, saved_bytes: bytes
158
+ ) -> None:
159
+ (tmp_path / "account-launchers.json").write_bytes(saved_bytes)
160
+ monkeypatch.setenv("CODEX_ACCOUNT_PROFILES", "alpha")
161
+ assert choice.saved_codex_account_names(tmp_path) == ("alpha",)
162
+ assert json.loads((tmp_path / "account-launchers.json").read_text(encoding="utf-8")) == ["alpha"]
163
+
164
+ def should_keep_the_environment_roster_when_the_saved_file_cannot_be_read(
165
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
166
+ ) -> None:
167
+ def refuse_read(_: Path) -> tuple[str, ...]:
168
+ raise PermissionError("locked")
169
+
170
+ monkeypatch.setattr(choice, "_saved_roster_file_names", refuse_read)
171
+ monkeypatch.setenv("CODEX_ACCOUNT_PROFILES", "alpha")
172
+ assert choice.saved_codex_account_names(tmp_path) == ("alpha",)
173
+
174
+ def should_remove_the_staged_roster_when_the_write_fails(
175
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
176
+ ) -> None:
177
+ original_write_bytes = Path.write_bytes
178
+
179
+ def fail_after_partial_write(path: Path, data: bytes) -> int:
180
+ original_write_bytes(path, data[:3])
181
+ raise OSError("disk full")
182
+
183
+ monkeypatch.setattr(Path, "write_bytes", fail_after_partial_write)
184
+ with pytest.raises(OSError, match="disk full"):
185
+ choice.save_codex_account_names(tmp_path, ("alpha",))
186
+ assert list(tmp_path.iterdir()) == []
187
+
188
+ @pytest.mark.parametrize("document", [["gamma"], ["alpha", "bad name"], {"alpha": 1}])
189
+ def should_replace_a_saved_file_that_differs_from_the_environment(
190
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, document: object
191
+ ) -> None:
192
+ save_roster(tmp_path, document)
193
+ monkeypatch.setenv("CODEX_ACCOUNT_PROFILES", "alpha")
194
+ choice.saved_codex_account_names(tmp_path)
195
+ assert json.loads((tmp_path / "account-launchers.json").read_text(encoding="utf-8")) == ["alpha"]
196
+
197
+ def should_leave_a_saved_file_that_matches_the_environment_untouched(
198
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
199
+ ) -> None:
200
+ save_roster(tmp_path, ["beta", "alpha"])
201
+ saved_bytes = (tmp_path / "account-launchers.json").read_bytes()
202
+ monkeypatch.setenv("CODEX_ACCOUNT_PROFILES", "beta,alpha")
203
+ choice.saved_codex_account_names(tmp_path)
204
+ assert (tmp_path / "account-launchers.json").read_bytes() == saved_bytes
205
+
206
+ def should_keep_the_environment_roster_when_the_file_cannot_be_written(
207
+ self,
208
+ tmp_path: Path,
209
+ monkeypatch: pytest.MonkeyPatch,
210
+ capsys: pytest.CaptureFixture[str],
211
+ ) -> None:
212
+ def refuse_save(*_: object) -> None:
213
+ raise PermissionError("read-only")
214
+
215
+ monkeypatch.setattr(choice, "save_codex_account_names", refuse_save)
216
+ monkeypatch.setenv("CODEX_ACCOUNT_PROFILES", "alpha")
217
+ assert choice.saved_codex_account_names(tmp_path) == ("alpha",)
218
+ assert "cannot save the CODEX_ACCOUNT_PROFILES roster" in capsys.readouterr().err
219
+
147
220
  def should_read_the_saved_file_when_the_environment_is_unset(
148
221
  self, tmp_path: Path
149
222
  ) -> None:
@@ -190,6 +263,37 @@ class TestAccountRoster:
190
263
  b'[\n "alpha",\n "beta"\n]\n'
191
264
  )
192
265
  assert choice.saved_codex_account_names(profiles_root) == ("alpha", "beta")
266
+ assert [each.name for each in profiles_root.iterdir()] == ["account-launchers.json"]
267
+
268
+ def should_retry_a_replace_while_a_reader_holds_the_roster(
269
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
270
+ ) -> None:
271
+ original_replace = os.replace
272
+ all_attempts: list[object] = []
273
+
274
+ def refuse_first_replace(source: Path, target: Path) -> None:
275
+ all_attempts.append(source)
276
+ if len(all_attempts) == 1:
277
+ raise PermissionError("in use")
278
+ original_replace(source, target)
279
+
280
+ monkeypatch.setattr(choice.os, "replace", refuse_first_replace)
281
+ monkeypatch.setattr(choice.time, "sleep", lambda _: None)
282
+ choice.save_codex_account_names(tmp_path, ("alpha",))
283
+ assert len(all_attempts) == 2
284
+ assert json.loads((tmp_path / "account-launchers.json").read_text(encoding="utf-8")) == ["alpha"]
285
+
286
+ def should_remove_the_staged_roster_when_the_replace_fails(
287
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
288
+ ) -> None:
289
+ def refuse_replace(*_: object) -> None:
290
+ raise PermissionError("in use")
291
+
292
+ monkeypatch.setattr(choice.os, "replace", refuse_replace)
293
+ monkeypatch.setattr(choice.time, "sleep", lambda _: None)
294
+ with pytest.raises(PermissionError):
295
+ choice.save_codex_account_names(tmp_path, ("alpha",))
296
+ assert list(tmp_path.iterdir()) == []
193
297
 
194
298
  def should_fall_back_to_the_four_fixed_accounts_without_a_roster(
195
299
  self, tmp_path: Path
@@ -308,6 +412,23 @@ class TestSetupRoster:
308
412
  "profile name must use letters, digits, hyphens, or underscores",
309
413
  ]
310
414
 
415
+ @pytest.mark.parametrize(("environment_roster", "is_warned"), [("alpha,beta", True), ("alpha", False), ("", False), ("bad name", False)])
416
+ def should_warn_when_the_environment_roster_will_replace_the_setup_list(
417
+ self,
418
+ tmp_path: Path,
419
+ monkeypatch: pytest.MonkeyPatch,
420
+ capsys: pytest.CaptureFixture[str],
421
+ environment_roster: str,
422
+ is_warned: bool,
423
+ ) -> None:
424
+ monkeypatch.setattr(choice, "update_codex_account_roster", lambda **_: (("alpha",), {}))
425
+ monkeypatch.setenv("CODEX_ACCOUNT_PROFILES", environment_roster)
426
+ all_arguments = ["--profiles-root", str(tmp_path / "profiles"), "setup"]
427
+ main_home = build_codex_main_home(tmp_path)
428
+ assert choice.main([*all_arguments, "--main-home", str(main_home), "--launcher-directory", str(tmp_path / "bin")]) == 0
429
+ assert ("CODEX_ACCOUNT_PROFILES names alpha" in capsys.readouterr().err) is is_warned
430
+ assert (tmp_path / "bin" / "codex-alpha.cmd").is_file()
431
+
311
432
  def should_finish_at_the_end_of_input(self, tmp_path: Path) -> None:
312
433
  all_names, _ = choice.update_codex_account_roster(
313
434
  profiles_root=tmp_path,