claude-dev-env 8.49.3 → 8.50.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.
@@ -27,7 +27,7 @@ export const USAGE_WRAPUP_PLUGIN_SPEC = Object.freeze({
27
27
  label: 'Usage-wrapup',
28
28
  marketplaceRepository: 'jl-cmd/claude-dev-env',
29
29
  marketplaceName: 'claude-dev-env',
30
- marketplaceAddArguments: Object.freeze(['--sparse', '.claude-plugin']),
30
+ marketplaceAddArguments: Object.freeze(['--sparse', '.claude-plugin', 'packages/usage-wrapup']),
31
31
  pluginIdentifier: 'usage-wrapup@claude-dev-env',
32
32
  hosts: Object.freeze(['claude']),
33
33
  optOutFlag: '--no-usage-wrapup',
@@ -213,14 +213,12 @@ test('a non-zero exit that is not command-not-found still reads as a failure', (
213
213
  assert.match(outcome.hosts[0].warning, /rejected the catalog/);
214
214
  });
215
215
 
216
- const USAGE_WRAPUP_PINNED_COMMIT = 'd78c314e5787786e866bf5c4967107365854dc78';
217
-
218
216
  test('the usage-wrapup plan adds this repository marketplace and installs the plugin on Claude', () => {
219
217
  const plan = marketplacePluginPlan(USAGE_WRAPUP_PLUGIN_SPEC, 'claude');
220
218
  assert.equal(plan.executable, 'claude');
221
219
  assert.equal(plan.homeVariable, 'CLAUDE_CONFIG_DIR');
222
220
  assert.deepEqual(plan.commands, [
223
- ['plugin', 'marketplace', 'add', 'jl-cmd/claude-dev-env', '--sparse', '.claude-plugin'],
221
+ ['plugin', 'marketplace', 'add', 'jl-cmd/claude-dev-env', '--sparse', '.claude-plugin', 'packages/usage-wrapup'],
224
222
  ['plugin', 'install', 'usage-wrapup@claude-dev-env'],
225
223
  ]);
226
224
  });
@@ -238,7 +236,7 @@ test('installing usage-wrapup runs only the Claude commands, inside the managed
238
236
  assert.deepEqual(runner.calls.map(call => [call.executable, ...call.commandArguments]), [
239
237
  ['claude', 'plugin', 'uninstall', 'usage-wrapup@claude-dev-env'],
240
238
  ['claude', 'plugin', 'marketplace', 'remove', 'claude-dev-env'],
241
- ['claude', 'plugin', 'marketplace', 'add', 'jl-cmd/claude-dev-env', '--sparse', '.claude-plugin'],
239
+ ['claude', 'plugin', 'marketplace', 'add', 'jl-cmd/claude-dev-env', '--sparse', '.claude-plugin', 'packages/usage-wrapup'],
242
240
  ['claude', 'plugin', 'install', 'usage-wrapup@claude-dev-env'],
243
241
  ]);
244
242
  for (const call of runner.calls) {
@@ -270,7 +268,7 @@ test('the usage-wrapup opt-out flag and variable turn off only usage-wrapup', ()
270
268
  );
271
269
  });
272
270
 
273
- test('the repository marketplace lists usage-wrapup from the fork, pinned to the reviewed commit', () => {
271
+ test('the repository marketplace lists usage-wrapup from its package folder in this repository', () => {
274
272
  const marketplace = JSON.parse(readFileSync(
275
273
  new URL('../../../.claude-plugin/marketplace.json', import.meta.url),
276
274
  'utf8',
@@ -279,12 +277,7 @@ test('the repository marketplace lists usage-wrapup from the fork, pinned to the
279
277
  assert.equal(marketplace.name, marketplaceName);
280
278
  const entry = marketplace.plugins.find(plugin => plugin.name === pluginName);
281
279
  assert.ok(entry, `the marketplace lists ${pluginName}`);
282
- assert.deepEqual(entry.source, {
283
- source: 'github',
284
- repo: 'jl-cmd/usage-wrapup',
285
- ref: 'main',
286
- sha: USAGE_WRAPUP_PINNED_COMMIT,
287
- });
280
+ assert.equal(entry.source, './packages/usage-wrapup');
288
281
  });
289
282
 
290
283
  test('a clean install deletes the orphaned pstack versions and keeps the installed one', () => {
@@ -3317,7 +3317,7 @@ test('a pstack install after a --no-pstack install seeds the Codex model sheet',
3317
3317
  const USAGE_WRAPUP_CLAUDE_COMMANDS = Object.freeze([
3318
3318
  'claude plugin uninstall usage-wrapup@claude-dev-env',
3319
3319
  'claude plugin marketplace remove claude-dev-env',
3320
- 'claude plugin marketplace add jl-cmd/claude-dev-env --sparse .claude-plugin',
3320
+ 'claude plugin marketplace add jl-cmd/claude-dev-env --sparse .claude-plugin packages/usage-wrapup',
3321
3321
  'claude plugin install usage-wrapup@claude-dev-env',
3322
3322
  ]);
3323
3323
 
@@ -2,7 +2,11 @@
2
2
 
3
3
  `account_broker.py` reads the Claude and Codex account rosters, checks each account's session and weekly usage, and chooses an account with room. It does not select a model or sleep. A Codex decision names the `normal` or `luna` tier for the caller.
4
4
 
5
- Claude's main home is `~/.claude`. Its `claude-chain.json` file contains a `chain` list; each entry's `credentials_path` field identifies the credentials file whose parent is that account's home folder. The optional `extra-profiles.json` file in the main home lists additional profile names. Codex uses its configured account roster. When no Codex roster is configured, the broker chooses the default Codex home.
5
+ Claude's main home is `~/.claude`. Its `claude-chain.json` file contains a `chain` list; each entry's `credentials_path` field identifies the credentials file whose parent is that account's home folder. The optional `extra-profiles.json` file in the main home lists additional profile names.
6
+
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
+
9
+ Codex uses its configured account roster. When no Codex roster is configured, the broker chooses the default Codex home.
6
10
 
7
11
  ## Commands
8
12
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.49.3",
3
+ "version": "8.50.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -121,6 +121,7 @@ def readings_payload(all_readings: Sequence[Reading]) -> list[dict[str, object]]
121
121
  "name": each_reading.account.name,
122
122
  "home": str(each_reading.account.home),
123
123
  "is_main": each_reading.account.is_main,
124
+ "priority": each_reading.account.priority,
124
125
  "meters": _meter_payload(each_reading.meters),
125
126
  }
126
127
  for each_reading in all_readings
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import dataclasses
5
6
  import errno
6
7
  import json
7
8
  import os
@@ -47,6 +48,8 @@ from dev_env_scripts_constants.account_broker_constants import (
47
48
  utc_time_text,
48
49
  )
49
50
  from dev_env_scripts_constants.claude_account_constants import (
51
+ CLAUDE_ACCOUNT_ORDER_FILE_NAME,
52
+ CLAUDE_LAUNCHER_PROGRAM,
50
53
  CREDENTIALS_FILE_NAME,
51
54
  EXTRA_PROFILES_FILE_NAME,
52
55
  FULL_PERCENT,
@@ -64,7 +67,7 @@ from dev_env_scripts_constants.shared_tree_constants import CLAUDE_CONFIG_DIR_EN
64
67
 
65
68
  def _read_list(path: Path) -> object:
66
69
  try:
67
- return json.loads(path.read_text(encoding="utf-8"))
70
+ return json.loads(path.read_text(encoding="utf-8-sig"))
68
71
  except (OSError, UnicodeError, json.JSONDecodeError) as error:
69
72
  raise BrokerConfigurationError(f"cannot read account list {path}: {error}") from error
70
73
 
@@ -121,11 +124,34 @@ def _append_claude_account(
121
124
  all_seen.add(key)
122
125
 
123
126
 
127
+ def _account_order(main_home: Path) -> tuple[str, ...]:
128
+ order_path = main_home / CLAUDE_ACCOUNT_ORDER_FILE_NAME
129
+ if not order_path.exists():
130
+ return ()
131
+ all_names = _read_list(order_path)
132
+ if not isinstance(all_names, list) or any(not isinstance(each_name, str) for each_name in all_names):
133
+ raise BrokerConfigurationError(f"invalid account list {order_path}")
134
+ return tuple(each_name.casefold() for each_name in all_names)
135
+
136
+
137
+ def _order_names(account: Account) -> frozenset[str]:
138
+ launcher_name = Path(CLAUDE_LAUNCHER_PROGRAM.file_name_template.format(profile_name=account.name)).stem
139
+ return frozenset(each_name.casefold() for each_name in (account.name, account.command, launcher_name) if each_name)
140
+
141
+
142
+ def _with_priorities(all_accounts: Sequence[Account], all_ordered_names: Sequence[str]) -> tuple[Account, ...]:
143
+ def priority_of(account: Account) -> int | None:
144
+ all_names = _order_names(account)
145
+ return next((index for index, each_name in enumerate(all_ordered_names) if each_name in all_names), None)
146
+
147
+ return tuple(dataclasses.replace(each_account, priority=priority_of(each_account)) for each_account in all_accounts)
148
+
149
+
124
150
  def load_claude_accounts() -> tuple[Account, ...]:
125
151
  """Read the Claude account roster.
126
152
 
127
153
  Returns:
128
- Main and additional accounts in configured order.
154
+ Main and additional accounts in configured order, each with its place in the account order file.
129
155
 
130
156
  Raises:
131
157
  BrokerConfigurationError: An account list is unreadable.
@@ -138,7 +164,7 @@ def load_claude_accounts() -> tuple[Account, ...]:
138
164
  for each_home in _extra_homes(main_home):
139
165
  home = each_home.resolve()
140
166
  _append_claude_account(all_accounts, all_seen, Account(Product.CLAUDE, home.name, home, command=home.name), main_home)
141
- return tuple(all_accounts)
167
+ return _with_priorities(all_accounts, _account_order(main_home))
142
168
 
143
169
 
144
170
  def load_codex_accounts() -> tuple[Account, ...]:
@@ -137,6 +137,7 @@ class Account:
137
137
  home: Path
138
138
  is_main: bool = False
139
139
  command: str | None = None
140
+ priority: int | None = None
140
141
 
141
142
 
142
143
  @dataclass(frozen=True)
@@ -257,9 +258,10 @@ def _account_reset(reading: Reading, is_spent: bool = False) -> datetime | None:
257
258
  return max(all_blocking_resets, default=None)
258
259
 
259
260
 
260
- def _rank_key(reading: Reading) -> float:
261
+ def _rank_key(reading: Reading) -> tuple[bool, int, float]:
261
262
  room = reading.meters.tightest_percent_left if reading.meters else None
262
- return -(room if room is not None else -1.0)
263
+ priority = reading.account.priority
264
+ return (priority is None, priority or 0, -(room if room is not None else -1.0))
263
265
 
264
266
 
265
267
  def _main_has_room(meters: Meters | None) -> bool:
@@ -284,10 +286,13 @@ def _choose_claude(all_available: Sequence[Reading], preferred_command: str | No
284
286
  is_bound = preferred_command in (each_reading.account.command, each_reading.account.name)
285
287
  if preferred_command is not None and is_bound:
286
288
  return Decision("run", each_reading.account, None, "resume affinity", TIER_NORMAL)
287
- roomiest = min(all_with_room, key=_rank_key, default=None)
288
- if roomiest is None:
289
+ chosen = min(all_with_room, key=_rank_key, default=None)
290
+ if chosen is None:
289
291
  return None
290
- return Decision("run", roomiest.account, None, f"{roomiest.account.name} has {roomiest.meters.tightest_percent_left:g}% left", TIER_NORMAL)
292
+ room_text = f"{chosen.account.name} has {chosen.meters.tightest_percent_left:g}% left"
293
+ if chosen.account.priority is not None:
294
+ room_text = f"{room_text}; priority {chosen.account.priority + 1} in the account order"
295
+ return Decision("run", chosen.account, None, room_text, TIER_NORMAL)
291
296
 
292
297
 
293
298
  def _choose_codex(all_available: Sequence[Reading]) -> Decision | None:
@@ -27,6 +27,9 @@ SECOND_ACCOUNT_PROFILE_NAME: str = "ev"
27
27
  EXTRA_PROFILES_FILE_NAME: str = "extra-profiles.json"
28
28
  """Local main-home file listing extra profile names in selection order."""
29
29
 
30
+ CLAUDE_ACCOUNT_ORDER_FILE_NAME: str = "claude-account-order.json"
31
+ """Optional main-home file listing Claude accounts in pick priority, first choice first."""
32
+
30
33
  PROFILE_NAME_PATTERN: str = r"[A-Za-z0-9][A-Za-z0-9_-]*"
31
34
  """Allowed profile names for directories and command launchers."""
32
35
 
@@ -302,6 +302,59 @@ def test_should_use_claude_extra_floors_and_wait_for_unread() -> None:
302
302
  assert decision.account is None
303
303
 
304
304
 
305
+ def _ranked_claude_account(name: str, priority: int | None) -> Account:
306
+ return Account(Product.CLAUDE, name, Path("/profiles") / name, False, name, priority)
307
+
308
+
309
+ def test_should_pick_the_first_claude_account_in_priority_order_with_room() -> None:
310
+ readings = (
311
+ Reading(_ranked_claude_account("roomy", 2), _meters(90, 90)),
312
+ Reading(_ranked_claude_account("spent", 0), _meters(5, 90)),
313
+ Reading(_ranked_claude_account("preferred", 1), _meters(20, 20)),
314
+ Reading(_ranked_claude_account("unranked", None), _meters(99, 99)),
315
+ )
316
+
317
+ decision = choose_from_readings(Product.CLAUDE, readings, now=NOW)
318
+
319
+ assert decision.account.name == "preferred"
320
+ assert "priority 2" in decision.reason
321
+
322
+
323
+ def test_should_fall_back_to_the_roomiest_unranked_claude_account() -> None:
324
+ readings = (
325
+ Reading(_ranked_claude_account("ranked_spent", 0), _meters(5, 5)),
326
+ Reading(_ranked_claude_account("tight", None), _meters(30, 30)),
327
+ Reading(_ranked_claude_account("roomy", None), _meters(60, 60)),
328
+ )
329
+
330
+ decision = choose_from_readings(Product.CLAUDE, readings, now=NOW)
331
+
332
+ assert decision.account.name == "roomy"
333
+
334
+
335
+ def test_should_keep_resume_affinity_ahead_of_priority() -> None:
336
+ readings = (
337
+ Reading(_ranked_claude_account("first", 0), _meters(90, 90)),
338
+ Reading(_ranked_claude_account("bound", 1), _meters(40, 40)),
339
+ )
340
+
341
+ decision = choose_from_readings(Product.CLAUDE, readings, now=NOW, preferred_command="bound")
342
+
343
+ assert decision.account.name == "bound"
344
+ assert decision.reason == "resume affinity"
345
+
346
+
347
+ def test_should_wait_when_every_ranked_claude_account_is_spent() -> None:
348
+ readings = (
349
+ Reading(_ranked_claude_account("first", 0), _meters(5, 5)),
350
+ Reading(_ranked_claude_account("second", 1), _meters(0, 50)),
351
+ )
352
+
353
+ decision = choose_from_readings(Product.CLAUDE, readings, now=NOW)
354
+
355
+ assert decision.action == "wait"
356
+
357
+
305
358
  @pytest.mark.parametrize("count", (1, 5))
306
359
  def test_should_consider_every_account_in_the_roster(count: int) -> None:
307
360
  readings = tuple(
@@ -34,6 +34,64 @@ def test_should_load_claude_roster_from_both_lists(
34
34
  assert accounts[1].home == (tmp_path / "alternate").resolve()
35
35
 
36
36
 
37
+ def _claude_home_with_extras(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, extras: list[str]) -> Path:
38
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
39
+ monkeypatch.setattr(support, "default_profile_home", lambda name="extra": tmp_path / "profiles" / name)
40
+ main_home = tmp_path / ".claude"
41
+ main_home.mkdir()
42
+ (main_home / "extra-profiles.json").write_text(json.dumps(extras), encoding="utf-8")
43
+ return main_home
44
+
45
+
46
+ def test_should_leave_claude_priorities_unset_without_an_order_file(
47
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
48
+ ) -> None:
49
+ _claude_home_with_extras(monkeypatch, tmp_path, ["first", "second"])
50
+
51
+ accounts = support.load_claude_accounts()
52
+
53
+ assert [each_account.priority for each_account in accounts] == [None, None, None]
54
+
55
+
56
+ def test_should_assign_claude_priorities_from_the_order_file(
57
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
58
+ ) -> None:
59
+ main_home = _claude_home_with_extras(monkeypatch, tmp_path, ["first", "second", "unlisted"])
60
+ (main_home / "claude-account-order.json").write_text(
61
+ json.dumps(["claude-second", "missing", "claude", "FIRST"]), encoding="utf-8"
62
+ )
63
+
64
+ accounts = support.load_claude_accounts()
65
+
66
+ assert {each_account.name: each_account.priority for each_account in accounts} == {
67
+ "main": 2,
68
+ "first": 3,
69
+ "second": 0,
70
+ "unlisted": None,
71
+ }
72
+
73
+
74
+ def test_should_read_an_order_file_saved_with_a_byte_order_mark(
75
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
76
+ ) -> None:
77
+ main_home = _claude_home_with_extras(monkeypatch, tmp_path, ["first"])
78
+ (main_home / "claude-account-order.json").write_text('["first"]', encoding="utf-8-sig")
79
+
80
+ accounts = support.load_claude_accounts()
81
+
82
+ assert {each_account.name: each_account.priority for each_account in accounts} == {"main": None, "first": 0}
83
+
84
+
85
+ def test_should_reject_an_order_file_that_is_not_a_list_of_names(
86
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
87
+ ) -> None:
88
+ main_home = _claude_home_with_extras(monkeypatch, tmp_path, ["first"])
89
+ (main_home / "claude-account-order.json").write_text('{"order": ["first"]}', encoding="utf-8")
90
+
91
+ with pytest.raises(support.BrokerConfigurationError, match="claude-account-order.json"):
92
+ support.load_claude_accounts()
93
+
94
+
37
95
  def test_should_load_codex_roster_without_meter_reads(
38
96
  monkeypatch: pytest.MonkeyPatch, tmp_path: Path
39
97
  ) -> None: