claude-dev-env 8.34.0 → 8.35.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,24 +1,31 @@
1
1
  # Codex accounts
2
2
 
3
- The codex_account_choice picker spreads agent work across up to four Codex
4
- accounts on one machine. Each account signs in under
5
- its own Codex home, and every home shares the same Codex setup: config, rules,
6
- skills, plugins and prompts. A job asks the picker which account to use.
3
+ The codex_account_choice picker spreads agent work across the Codex accounts
4
+ on one machine. Each account signs in under its own Codex home, and every home
5
+ shares the same Codex setup: config, rules, skills, plugins, prompts and agents.
6
+ A job asks the picker which account to use.
7
7
 
8
8
  ## Pieces
9
9
 
10
10
  | File | What it does |
11
11
  |---|---|
12
- | `scripts/codex_account_choice.py` | `choose` names the account and tier a job runs on, `check` tells a running job whether its account is still above a floor, `sync` links the shared setup into every account's home |
12
+ | `scripts/codex_account_choice.py` | `choose` names the account and tier a job runs on, `check` tells a running job whether its account is still above a floor, `sync` links the shared setup into every account's home, `setup` and `install` write one launcher per account |
13
13
  | `scripts/codex_account_meters.py` | Reads one account's rate-limit windows through `codex app-server` with `CODEX_HOME` set to that account's home |
14
- | `scripts/dev_env_scripts_constants/codex_account_constants.py` | The account names in try order, the shared entry names, the 10% bar, the 1% Luna stop, and the 20% 5-hour floor for Luna |
14
+ | `scripts/dev_env_scripts_constants/codex_account_constants.py` | The fallback account names, the roster variable and file, the launcher template, the shared entry names, the 10% bar, the 1% Luna stop, and the 20% 5-hour floor for Luna |
15
15
 
16
16
  ## Names and order
17
17
 
18
- The accounts are `codex-1`, `codex-2`, `codex-3` and `codex-4`. Their homes are
19
- `~/.codex-profiles/codex-1` and so on, or under `CODEX_PROFILES_ROOT` when set.
20
- Jobs try them in that order. The names say nothing about a plan, so a plan change
21
- keeps every name. To change the order, sign the accounts into different folders.
18
+ The account roster names the accounts and their order. It comes from the
19
+ `CODEX_ACCOUNT_PROFILES` environment variable, a comma-separated list such as
20
+ `alpha,beta`. When that variable is unset or empty, the roster is the saved list
21
+ in `account-launchers.json` under the profiles root. When neither exists, the
22
+ accounts are `codex-1`, `codex-2`, `codex-3` and `codex-4`.
23
+
24
+ `choose`, `sync` and `check` all work on that list, in that order. `check`
25
+ accepts only a name on it. Each account's home is `~/.codex-profiles/<name>`, or
26
+ `<name>` under `CODEX_PROFILES_ROOT` when set. `~/.codex` holds the shared setup
27
+ and is never an account. A name uses letters, digits, hyphens or underscores.
28
+ `main`, `wait` and the Windows device names are refused.
22
29
 
23
30
  ## Sign in once
24
31
 
@@ -33,8 +40,54 @@ $env:CODEX_HOME = "$HOME\.codex-profiles\codex-1"; codex login
33
40
  ```
34
41
 
35
42
  The sign-in lives in that folder's `auth.json`. Only the entries in
36
- `ALL_SHARED_CODEX_HOME_NAMES` link to `~/.codex`. Sign-in, sessions, history,
37
- logs and state files stay per account.
43
+ `ALL_SHARED_CODEX_HOME_NAMES` link to `~/.codex`. They are `AGENTS.md`, `agents`,
44
+ `config.toml`, `hooks`, `hooks.json`, `plugins`, `prompts`, `rules` and `skills`.
45
+ Sign-in, sessions, history, logs and state files stay per account.
46
+
47
+ ## Named launchers
48
+
49
+ Each roster account gets a launcher, `codex-<name>.cmd`, in `~/.local/bin`. It
50
+ sets `CODEX_HOME` to that account's home and runs Codex with every argument, so
51
+ `codex-alpha exec "fix the test"` runs on the `alpha` account.
52
+
53
+ Run the setup once:
54
+
55
+ ```
56
+ python packages/claude-dev-env/scripts/codex_account_choice.py setup
57
+ ```
58
+
59
+ It prints the saved names, then asks for one name per line. A blank line or the
60
+ end of input finishes. An invalid name prints the reason and asks again. A
61
+ repeated name counts once. The setup saves the list to
62
+ `~/.codex-profiles/account-launchers.json`, links each account's home to
63
+ `~/.codex`, and writes each launcher.
64
+
65
+ Rerun the installer after the shared setup or this package changes:
66
+
67
+ ```
68
+ python packages/claude-dev-env/scripts/codex_account_choice.py install
69
+ ```
70
+
71
+ It reads the roster, links every account's home, and rewrites any launcher that
72
+ differs. A second run changes nothing. With no roster it prints `{}`.
73
+ `CODEX_ACCOUNT_PROFILES` overrides the saved file here too. Both commands take
74
+ `--main-home` and `--launcher-directory`.
75
+
76
+ The launcher runs `call codex %*`. `codex` is npm's `codex.cmd`, and its last
77
+ line ends the batch scope. Without `call`, that line also drops the launcher's
78
+ `CODEX_HOME`, and Codex runs on the main home.
79
+
80
+ The names typed in one setup run become the whole roster. A saved name
81
+ left out has its launcher renamed to `codex-<name>.cmd.replaced-<time>`. Its
82
+ folder under the profiles root stays, with its `auth.json`, so typing the name
83
+ again restores it. A blank first line empties the roster and moves every launcher
84
+ aside.
85
+
86
+ `check <name>` reads a saved name the same way:
87
+
88
+ ```
89
+ python packages/claude-dev-env/scripts/codex_account_choice.py check alpha --floor 1
90
+ ```
38
91
 
39
92
  ## Which account a job uses
40
93
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.34.0",
3
+ "version": "8.35.1",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,14 +28,15 @@ from dev_env_scripts_constants.claude_account_constants import (
28
28
  ALL_WINDOWS_JUNCTION_COMMAND_PREFIX,
29
29
  CHOICE_MAIN,
30
30
  CHOICE_WAIT,
31
+ CLAUDE_LAUNCHER_PROGRAM,
31
32
  JSON_LAUNCHER_KEY,
32
33
  JSON_LINKED_KEY,
33
34
  JSON_MOVED_ASIDE_KEY,
34
35
  JSON_UNLINKED_KEY,
35
36
  ALL_LAUNCHER_DIRECTORY_RELATIVE_PARTS,
36
- LAUNCHER_FILE_NAME_TEMPLATE,
37
+ LAUNCHER_BODY_TEMPLATE,
37
38
  LAUNCHER_REPLACED_SUFFIX,
38
- LAUNCHER_TEXT_TEMPLATE,
39
+ LauncherProgram,
39
40
  MAIN_CLAUDE_HOME_DIRECTORY_NAME,
40
41
  PROFILES_ROOT_DIRECTORY_NAME,
41
42
  PROFILES_ROOT_ENVIRONMENT_VARIABLE,
@@ -309,8 +310,58 @@ def sync_profile(
309
310
  )
310
311
 
311
312
 
312
- def _moved_launcher_name(launcher_file_name: str, now: datetime) -> str:
313
- return f"{launcher_file_name}{LAUNCHER_REPLACED_SUFFIX}{_stamp(now)}"
313
+ def sync_report_payload(report: ProfileSyncReport) -> dict[str, object]:
314
+ """Turn a sync report into its JSON payload.
315
+
316
+ Args:
317
+ report: The entries one sync linked, moved aside, and unlinked.
318
+
319
+ Returns:
320
+ The linked, moved-aside, and unlinked entries as JSON lists.
321
+ """
322
+ return {
323
+ JSON_LINKED_KEY: list(report.all_linked),
324
+ JSON_MOVED_ASIDE_KEY: list(report.all_moved_aside),
325
+ JSON_UNLINKED_KEY: list(report.all_unlinked),
326
+ }
327
+
328
+
329
+ def move_launcher_aside(launcher_path: Path, now: datetime) -> Path:
330
+ """Rename a launcher to ``<name>.replaced-<time>`` beside it.
331
+
332
+ Args:
333
+ launcher_path: The launcher to move.
334
+ now: The run time that names the moved launcher.
335
+
336
+ Returns:
337
+ The moved launcher's path.
338
+ """
339
+ moved_path = launcher_path.with_name(
340
+ f"{launcher_path.name}{LAUNCHER_REPLACED_SUFFIX}{_stamp(now)}"
341
+ )
342
+ os.replace(launcher_path, moved_path)
343
+ return moved_path
344
+
345
+
346
+ def launcher_path(
347
+ launcher_directory: Path, profile_name: str, launcher_program: LauncherProgram
348
+ ) -> Path:
349
+ """Name the launcher file for a profile.
350
+
351
+ Args:
352
+ launcher_directory: The directory on PATH that holds the launcher.
353
+ profile_name: The name used in the launcher file name.
354
+ launcher_program: The program the launcher runs, which names the file.
355
+
356
+ Returns:
357
+ The launcher path.
358
+
359
+ Raises:
360
+ ValueError: When the profile name is not a valid profile name.
361
+ """
362
+ return launcher_directory / launcher_program.file_name_template.format(
363
+ profile_name=validate_profile_name(profile_name)
364
+ )
314
365
 
315
366
 
316
367
  def write_launcher(
@@ -318,41 +369,41 @@ def write_launcher(
318
369
  launcher_directory: Path,
319
370
  profile_home: Path,
320
371
  now: datetime,
321
- profile_name: str = SECOND_ACCOUNT_PROFILE_NAME,
372
+ profile_name: str,
373
+ launcher_program: LauncherProgram,
322
374
  ) -> Path:
323
- """Write the launcher that runs Claude under a named profile.
375
+ """Write the launcher that runs a program under a named profile.
324
376
 
325
377
  ::
326
378
 
327
379
  claude-NAME -p "fix the test"
328
- -> CLAUDE_CONFIG_DIR=<profile home>, then claude -p "fix the test"
380
+ -> CLAUDE_CONFIG_DIR=<profile home>, then call claude -p "fix the test"
329
381
  an older claude-NAME.cmd -> claude-NAME.cmd.replaced-<time>
330
382
 
331
383
  Args:
332
384
  launcher_directory: The directory on PATH that holds the launcher.
333
- profile_home: The named account's Claude home.
385
+ profile_home: The named account's home for the program.
334
386
  now: The run time that names a moved older launcher.
335
387
  profile_name: The name used in the launcher file name.
388
+ launcher_program: The program to run and the variable naming its home.
336
389
 
337
390
  Returns:
338
391
  The launcher path.
339
392
  """
340
- launcher_file_name = LAUNCHER_FILE_NAME_TEMPLATE.format(
341
- profile_name=validate_profile_name(profile_name)
393
+ target_path = launcher_path(launcher_directory, profile_name, launcher_program)
394
+ launcher_text = LAUNCHER_BODY_TEMPLATE.format(
395
+ environment_variable=launcher_program.environment_variable,
396
+ profile_home=profile_home,
397
+ program=launcher_program.program,
342
398
  )
343
- launcher_path = launcher_directory / launcher_file_name
344
- launcher_text = LAUNCHER_TEXT_TEMPLATE.format(profile_home=profile_home)
345
399
  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
- )
400
+ if target_path.is_file() and target_path.read_bytes() == launcher_bytes:
401
+ return target_path
402
+ if target_path.is_file():
403
+ move_launcher_aside(target_path, now)
353
404
  launcher_directory.mkdir(parents=True, exist_ok=True)
354
- launcher_path.write_bytes(launcher_bytes)
355
- return launcher_path
405
+ target_path.write_bytes(launcher_bytes)
406
+ return target_path
356
407
 
357
408
 
358
409
  def _build_argument_parser() -> argparse.ArgumentParser:
@@ -402,13 +453,9 @@ def _sync_named_profile(arguments: argparse.Namespace) -> dict[str, object]:
402
453
  profile_home=profile_home,
403
454
  now=now,
404
455
  profile_name=arguments.profile_name,
456
+ launcher_program=CLAUDE_LAUNCHER_PROGRAM,
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__":