claude-dev-env 8.33.2 → 8.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -84,6 +84,7 @@ test('capture and restore recover settings, manifest, files, and hooksPath', ()
84
84
  const env = {
85
85
  ...process.env,
86
86
  CDE_INSTALL_PSTACK: '0',
87
+ CDE_INSTALL_USAGE_WRAPUP: '0',
87
88
  HOME: box.root,
88
89
  USERPROFILE: box.root,
89
90
  GIT_CONFIG_GLOBAL: gitConfigPath,
@@ -145,6 +146,7 @@ test('capture and restore recover an additional host settings file', () => {
145
146
  env: {
146
147
  ...process.env,
147
148
  CDE_INSTALL_PSTACK: '0',
149
+ CDE_INSTALL_USAGE_WRAPUP: '0',
148
150
  HOME: box.root,
149
151
  USERPROFILE: box.root,
150
152
  GIT_CONFIG_GLOBAL: gitConfigPath,
@@ -179,6 +181,7 @@ test('runWithInstallTransaction restores prior state on injected fault', () => {
179
181
  const env = {
180
182
  ...process.env,
181
183
  CDE_INSTALL_PSTACK: '0',
184
+ CDE_INSTALL_USAGE_WRAPUP: '0',
182
185
  HOME: box.root,
183
186
  USERPROFILE: box.root,
184
187
  GIT_CONFIG_GLOBAL: gitConfigPath,
@@ -232,6 +235,7 @@ test('runWithInstallTransaction commits and discards journal on success', () =>
232
235
  env: {
233
236
  ...process.env,
234
237
  CDE_INSTALL_PSTACK: '0',
238
+ CDE_INSTALL_USAGE_WRAPUP: '0',
235
239
  HOME: box.root,
236
240
  USERPROFILE: box.root,
237
241
  GIT_CONFIG_GLOBAL: gitConfigPath,
@@ -274,6 +278,7 @@ function runInstaller(homeDirectory, extraArguments, options = {}) {
274
278
  const childEnvironment = {
275
279
  ...process.env,
276
280
  CDE_INSTALL_PSTACK: '0',
281
+ CDE_INSTALL_USAGE_WRAPUP: '0',
277
282
  HOME: homeDirectory,
278
283
  USERPROFILE: homeDirectory,
279
284
  GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
@@ -308,6 +313,7 @@ test('a later install fault removes newly seeded Codex pstack files', () => {
308
313
  faultPhase: FAULT_PHASES.AFTER_MANIFEST_WRITE,
309
314
  environment: {
310
315
  CDE_INSTALL_PSTACK: '1',
316
+ CDE_INSTALL_USAGE_WRAPUP: '0',
311
317
  CDE_CODEX_EXECUTABLE: codexCommandPath,
312
318
  },
313
319
  });
@@ -450,6 +456,7 @@ test('installer fault after_git_config restores prior core.hooksPath', () => {
450
456
  env: {
451
457
  ...process.env,
452
458
  CDE_INSTALL_PSTACK: '0',
459
+ CDE_INSTALL_USAGE_WRAPUP: '0',
453
460
  HOME: homeDirectory,
454
461
  USERPROFILE: homeDirectory,
455
462
  GIT_CONFIG_GLOBAL: gitConfigPath,
@@ -466,6 +473,7 @@ test('installer fault after_git_config restores prior core.hooksPath', () => {
466
473
  env: {
467
474
  ...process.env,
468
475
  CDE_INSTALL_PSTACK: '0',
476
+ CDE_INSTALL_USAGE_WRAPUP: '0',
469
477
  HOME: homeDirectory,
470
478
  USERPROFILE: homeDirectory,
471
479
  GIT_CONFIG_GLOBAL: gitConfigPath,
@@ -72,6 +72,7 @@ function runInstaller(homeDirectory, extraArguments, options = {}) {
72
72
  const childEnvironment = {
73
73
  ...process.env,
74
74
  CDE_INSTALL_PSTACK: '0',
75
+ CDE_INSTALL_USAGE_WRAPUP: '0',
75
76
  HOME: homeDirectory,
76
77
  USERPROFILE: homeDirectory,
77
78
  GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
@@ -305,6 +306,7 @@ test('uninstall fault after_git_config restores prior core.hooksPath', () => {
305
306
  env: {
306
307
  ...process.env,
307
308
  CDE_INSTALL_PSTACK: '0',
309
+ CDE_INSTALL_USAGE_WRAPUP: '0',
308
310
  HOME: homeDirectory,
309
311
  USERPROFILE: homeDirectory,
310
312
  GIT_CONFIG_GLOBAL: gitConfigPath,
@@ -1,24 +1,31 @@
1
1
  # Codex accounts
2
2
 
3
- The codex_account_choice picker spreads agent work across up to four Codex
4
- accounts on one machine. Each account signs in under
5
- its own Codex home, and every home shares the same Codex setup: config, rules,
6
- skills, plugins and prompts. A job asks the picker which account to use.
3
+ The codex_account_choice picker spreads agent work across the Codex accounts
4
+ on one machine. Each account signs in under its own Codex home, and every home
5
+ shares the same Codex setup: config, rules, skills, plugins, prompts and agents.
6
+ A job asks the picker which account to use.
7
7
 
8
8
  ## Pieces
9
9
 
10
10
  | File | What it does |
11
11
  |---|---|
12
- | `scripts/codex_account_choice.py` | `choose` names the account and tier a job runs on, `check` tells a running job whether its account is still above a floor, `sync` links the shared setup into every account's home |
12
+ | `scripts/codex_account_choice.py` | `choose` names the account and tier a job runs on, `check` tells a running job whether its account is still above a floor, `sync` links the shared setup into every account's home, `setup` and `install` write one launcher per account |
13
13
  | `scripts/codex_account_meters.py` | Reads one account's rate-limit windows through `codex app-server` with `CODEX_HOME` set to that account's home |
14
- | `scripts/dev_env_scripts_constants/codex_account_constants.py` | The account names in try order, the shared entry names, the 10% bar, the 1% Luna stop, and the 20% 5-hour floor for Luna |
14
+ | `scripts/dev_env_scripts_constants/codex_account_constants.py` | The fallback account names, the roster variable and file, the launcher template, the shared entry names, the 10% bar, the 1% Luna stop, and the 20% 5-hour floor for Luna |
15
15
 
16
16
  ## Names and order
17
17
 
18
- The accounts are `codex-1`, `codex-2`, `codex-3` and `codex-4`. Their homes are
19
- `~/.codex-profiles/codex-1` and so on, or under `CODEX_PROFILES_ROOT` when set.
20
- Jobs try them in that order. The names say nothing about a plan, so a plan change
21
- keeps every name. To change the order, sign the accounts into different folders.
18
+ The account roster names the accounts and their order. It comes from the
19
+ `CODEX_ACCOUNT_PROFILES` environment variable, a comma-separated list such as
20
+ `alpha,beta`. When that variable is unset or empty, the roster is the saved list
21
+ in `account-launchers.json` under the profiles root. When neither exists, the
22
+ accounts are `codex-1`, `codex-2`, `codex-3` and `codex-4`.
23
+
24
+ `choose`, `sync` and `check` all work on that list, in that order. `check`
25
+ accepts only a name on it. Each account's home is `~/.codex-profiles/<name>`, or
26
+ `<name>` under `CODEX_PROFILES_ROOT` when set. `~/.codex` holds the shared setup
27
+ and is never an account. A name uses letters, digits, hyphens or underscores.
28
+ `main`, `wait` and the Windows device names are refused.
22
29
 
23
30
  ## Sign in once
24
31
 
@@ -33,8 +40,54 @@ $env:CODEX_HOME = "$HOME\.codex-profiles\codex-1"; codex login
33
40
  ```
34
41
 
35
42
  The sign-in lives in that folder's `auth.json`. Only the entries in
36
- `ALL_SHARED_CODEX_HOME_NAMES` link to `~/.codex`. Sign-in, sessions, history,
37
- logs and state files stay per account.
43
+ `ALL_SHARED_CODEX_HOME_NAMES` link to `~/.codex`. They are `AGENTS.md`, `agents`,
44
+ `config.toml`, `hooks`, `hooks.json`, `plugins`, `prompts`, `rules` and `skills`.
45
+ Sign-in, sessions, history, logs and state files stay per account.
46
+
47
+ ## Named launchers
48
+
49
+ Each roster account gets a launcher, `codex-<name>.cmd`, in `~/.local/bin`. It
50
+ sets `CODEX_HOME` to that account's home and runs Codex with every argument, so
51
+ `codex-alpha exec "fix the test"` runs on the `alpha` account.
52
+
53
+ Run the setup once:
54
+
55
+ ```
56
+ python packages/claude-dev-env/scripts/codex_account_choice.py setup
57
+ ```
58
+
59
+ It prints the saved names, then asks for one name per line. A blank line or the
60
+ end of input finishes. An invalid name prints the reason and asks again. A
61
+ repeated name counts once. The setup saves the list to
62
+ `~/.codex-profiles/account-launchers.json`, links each account's home to
63
+ `~/.codex`, and writes each launcher.
64
+
65
+ Rerun the installer after the shared setup or this package changes:
66
+
67
+ ```
68
+ python packages/claude-dev-env/scripts/codex_account_choice.py install
69
+ ```
70
+
71
+ It reads the roster, links every account's home, and rewrites any launcher that
72
+ differs. A second run changes nothing. With no roster it prints `{}`.
73
+ `CODEX_ACCOUNT_PROFILES` overrides the saved file here too. Both commands take
74
+ `--main-home` and `--launcher-directory`.
75
+
76
+ The launcher runs `call codex %*`. `codex` is npm's `codex.cmd`, and its last
77
+ line ends the batch scope. Without `call`, that line also drops the launcher's
78
+ `CODEX_HOME`, and Codex runs on the main home.
79
+
80
+ The names typed in one setup run become the whole roster. A saved name
81
+ left out has its launcher renamed to `codex-<name>.cmd.replaced-<time>`. Its
82
+ folder under the profiles root stays, with its `auth.json`, so typing the name
83
+ again restores it. A blank first line empties the roster and moves every launcher
84
+ aside.
85
+
86
+ `check <name>` reads a saved name the same way:
87
+
88
+ ```
89
+ python packages/claude-dev-env/scripts/codex_account_choice.py check alpha --floor 1
90
+ ```
38
91
 
39
92
  ## Which account a job uses
40
93
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.33.2",
3
+ "version": "8.35.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -309,8 +309,60 @@ def sync_profile(
309
309
  )
310
310
 
311
311
 
312
- def _moved_launcher_name(launcher_file_name: str, now: datetime) -> str:
313
- return f"{launcher_file_name}{LAUNCHER_REPLACED_SUFFIX}{_stamp(now)}"
312
+ def sync_report_payload(report: ProfileSyncReport) -> dict[str, object]:
313
+ """Turn a sync report into its JSON payload.
314
+
315
+ Args:
316
+ report: The entries one sync linked, moved aside, and unlinked.
317
+
318
+ Returns:
319
+ The linked, moved-aside, and unlinked entries as JSON lists.
320
+ """
321
+ return {
322
+ JSON_LINKED_KEY: list(report.all_linked),
323
+ JSON_MOVED_ASIDE_KEY: list(report.all_moved_aside),
324
+ JSON_UNLINKED_KEY: list(report.all_unlinked),
325
+ }
326
+
327
+
328
+ def move_launcher_aside(launcher_path: Path, now: datetime) -> Path:
329
+ """Rename a launcher to ``<name>.replaced-<time>`` beside it.
330
+
331
+ Args:
332
+ launcher_path: The launcher to move.
333
+ now: The run time that names the moved launcher.
334
+
335
+ Returns:
336
+ The moved launcher's path.
337
+ """
338
+ moved_path = launcher_path.with_name(
339
+ f"{launcher_path.name}{LAUNCHER_REPLACED_SUFFIX}{_stamp(now)}"
340
+ )
341
+ os.replace(launcher_path, moved_path)
342
+ return moved_path
343
+
344
+
345
+ def launcher_path(
346
+ launcher_directory: Path,
347
+ profile_name: str,
348
+ launcher_file_name_template: str = LAUNCHER_FILE_NAME_TEMPLATE,
349
+ ) -> Path:
350
+ """Name the launcher file for a profile.
351
+
352
+ Args:
353
+ launcher_directory: The directory on PATH that holds the launcher.
354
+ profile_name: The name used in the launcher file name.
355
+ launcher_file_name_template: The launcher file name, with ``{profile_name}``.
356
+
357
+ Returns:
358
+ The launcher path.
359
+
360
+ Raises:
361
+ ValueError: When the profile name is not a valid profile name.
362
+ """
363
+ return launcher_directory / launcher_file_name_template.format(
364
+ profile_name=validate_profile_name(profile_name)
365
+ )
314
366
 
315
367
 
316
368
  def write_launcher(
@@ -319,6 +371,8 @@ def write_launcher(
319
371
  profile_home: Path,
320
372
  now: datetime,
321
373
  profile_name: str = SECOND_ACCOUNT_PROFILE_NAME,
374
+ launcher_file_name_template: str = LAUNCHER_FILE_NAME_TEMPLATE,
375
+ launcher_text_template: str = LAUNCHER_TEXT_TEMPLATE,
322
376
  ) -> Path:
323
377
  """Write the launcher that runs Claude under a named profile.
324
378
 
@@ -333,26 +387,24 @@ def write_launcher(
333
387
  profile_home: The named account's Claude home.
334
388
  now: The run time that names a moved older launcher.
335
389
  profile_name: The name used in the launcher file name.
390
+ launcher_file_name_template: The launcher file name, with ``{profile_name}``.
391
+ launcher_text_template: The launcher body, with ``{profile_home}``.
336
392
 
337
393
  Returns:
338
394
  The launcher path.
339
395
  """
340
- launcher_file_name = LAUNCHER_FILE_NAME_TEMPLATE.format(
341
- profile_name=validate_profile_name(profile_name)
396
+ target_path = launcher_path(
397
+ launcher_directory, profile_name, launcher_file_name_template
342
398
  )
343
- launcher_path = launcher_directory / launcher_file_name
344
- launcher_text = LAUNCHER_TEXT_TEMPLATE.format(profile_home=profile_home)
399
+ launcher_text = launcher_text_template.format(profile_home=profile_home)
345
400
  launcher_bytes = launcher_text.encode(TEXT_ENCODING)
346
- if launcher_path.is_file() and launcher_path.read_bytes() == launcher_bytes:
347
- return launcher_path
348
- if launcher_path.is_file():
349
- os.replace(
350
- launcher_path,
351
- launcher_path.with_name(_moved_launcher_name(launcher_file_name, now)),
352
- )
401
+ if target_path.is_file() and target_path.read_bytes() == launcher_bytes:
402
+ return target_path
403
+ if target_path.is_file():
404
+ move_launcher_aside(target_path, now)
353
405
  launcher_directory.mkdir(parents=True, exist_ok=True)
354
- launcher_path.write_bytes(launcher_bytes)
355
- return launcher_path
406
+ target_path.write_bytes(launcher_bytes)
407
+ return target_path
356
408
 
357
409
 
358
410
  def _build_argument_parser() -> argparse.ArgumentParser:
@@ -403,12 +455,7 @@ def _sync_named_profile(arguments: argparse.Namespace) -> dict[str, object]:
403
455
  now=now,
404
456
  profile_name=arguments.profile_name,
405
457
  )
406
- return {
407
- JSON_LINKED_KEY: list(report.all_linked),
408
- JSON_MOVED_ASIDE_KEY: list(report.all_moved_aside),
409
- JSON_UNLINKED_KEY: list(report.all_unlinked),
410
- JSON_LAUNCHER_KEY: str(launcher_path),
411
- }
458
+ return {**sync_report_payload(report), JSON_LAUNCHER_KEY: str(launcher_path)}
412
459
 
413
460
 
414
461
  if __name__ == "__main__":