claude-dev-env 8.38.2 → 8.39.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.
@@ -1,27 +1,13 @@
1
1
  #!/usr/bin/env python3
2
- """Name the Codex account a runner job uses, and keep each account's home ready.
2
+ """Keep each Codex account's home and launcher ready.
3
3
 
4
4
  Each Codex account signs in under its own Codex home in
5
5
  ``~/.codex-profiles/<name>``. The account roster 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. Jobs try them in that order::
8
+ ``codex-1`` through ``codex-4`` are the roster.
9
9
 
10
- first account with more than 10% left -> normal, that account
11
- none over 10%, the roomiest one over 1% -> luna, stop at 1% left
12
- ... but an account with a 5-hour window needs 20% of it left for luna
13
- none over 1%, or no meter reads -> wait, name the first reset
14
- account not signed in, or its meter unread -> skipped
15
-
16
- Five commands::
17
-
18
- python codex_account_choice.py choose
19
- {"tier": "normal", "account": "codex-1", "codex_home": "C:/Users/me/.codex-profiles/codex-1",
20
- "percent_left": 62.0, "stop_below_percent": null, "reason": "codex-1 has 62% left",
21
- "accounts": [...]}
22
-
23
- python codex_account_choice.py check codex-2 --floor 1
24
- exit 0 while codex-2 has more than 1% left, exit 3 once it has not
10
+ Three commands::
25
11
 
26
12
  python codex_account_choice.py sync
27
13
  links the shared Codex setup (config, rules, skills, plugins, agents) into
@@ -42,7 +28,6 @@ import json
42
28
  import os
43
29
  import sys
44
30
  from collections.abc import Callable, Sequence
45
- from dataclasses import dataclass
46
31
  from datetime import datetime, timezone
47
32
  from pathlib import Path
48
33
 
@@ -54,12 +39,6 @@ from claude_account_profile import (
54
39
  validate_profile_name,
55
40
  write_launcher,
56
41
  )
57
- from codex_account_meters import (
58
- CodexAccountMeters,
59
- CodexMeterUnreadError,
60
- read_codex_meters,
61
- resolve_codex_path,
62
- )
63
42
  from dev_env_scripts_constants.claude_account_constants import (
64
43
  ALL_LAUNCHER_DIRECTORY_RELATIVE_PARTS,
65
44
  JSON_LAUNCHER_KEY,
@@ -70,25 +49,15 @@ from dev_env_scripts_constants.codex_account_constants import (
70
49
  CODEX_ACCOUNT_LAUNCHERS_FILE_NAME,
71
50
  CODEX_ACCOUNT_NAME_SEPARATOR,
72
51
  CODEX_ACCOUNT_PROFILES_ENVIRONMENT_VARIABLE,
73
- CODEX_AUTH_FILE_NAME,
74
52
  CODEX_LAUNCHER_PROGRAM,
75
53
  CODEX_PROFILES_ROOT_DIRECTORY_NAME,
76
54
  CODEX_PROFILES_ROOT_ENVIRONMENT_VARIABLE,
77
55
  COMMAND_INSTALL,
78
56
  COMMAND_SETUP,
79
- EXIT_CODE_NO_ROOM,
80
- EXIT_CODE_ROOM,
81
57
  INVALID_ACCOUNT_ROSTER_TEMPLATE,
82
58
  JSON_INSTALLED_KEY,
83
59
  JSON_RETIRED_KEY,
84
- LUNA_TIER_SHORT_WINDOW_MINIMUM_PERCENT_LEFT,
85
- LUNA_TIER_STOP_PERCENT_LEFT,
86
60
  MAIN_CODEX_HOME_DIRECTORY_NAME,
87
- NORMAL_TIER_MINIMUM_PERCENT_LEFT,
88
- REASON_LUNA_TEMPLATE,
89
- REASON_NORMAL_TEMPLATE,
90
- REASON_WAIT_TEMPLATE,
91
- REASON_WAIT_UNREAD,
92
61
  ROSTER_JSON_INDENT,
93
62
  ROSTER_NOT_A_LIST_TEMPLATE,
94
63
  SETUP_NAMES_SEPARATOR,
@@ -96,41 +65,14 @@ from dev_env_scripts_constants.codex_account_constants import (
96
65
  SETUP_PROMPT_TEXT,
97
66
  SETUP_SAVED_NAMES_TEMPLATE,
98
67
  TEXT_ENCODING,
99
- TIER_LUNA,
100
- TIER_NORMAL,
101
- TIER_WAIT,
102
- UNKNOWN_ACCOUNT_TEMPLATE,
103
- UNKNOWN_RESET_TEXT,
104
- UNREAD_NOT_SIGNED_IN,
105
68
  )
106
69
 
107
- MeterReader = Callable[[Path], CodexAccountMeters]
108
70
  LineReader = Callable[[str], str]
109
71
  LineWriter = Callable[[str], None]
110
72
 
111
73
 
112
74
  class CodexAccountNameError(ValueError):
113
- """An account name from the roster or the command line is invalid or unknown."""
114
-
115
-
116
- @dataclass(frozen=True)
117
- class AccountReading:
118
- """One account's meters, or why they could not be read."""
119
-
120
- name: str
121
- codex_home: Path
122
- meters: CodexAccountMeters | None
123
- unread_reason: str | None = None
124
-
125
-
126
- @dataclass(frozen=True)
127
- class CodexAccountDecision:
128
- """The tier and account a job runs on, and the plain-words reason."""
129
-
130
- tier: str
131
- reading: AccountReading | None
132
- stop_below_percent: float | None
133
- reason: str
75
+ """An account name from the roster is invalid or unknown."""
134
76
 
135
77
 
136
78
  def default_profiles_root() -> Path:
@@ -236,7 +178,7 @@ def saved_codex_account_names(profiles_root: Path) -> tuple[str, ...]:
236
178
 
237
179
 
238
180
  def codex_account_names(profiles_root: Path) -> tuple[str, ...]:
239
- """Name the accounts ``choose``, ``sync``, and ``check`` work on, in try order.
181
+ """Name the accounts the broker and setup commands use, in try order.
240
182
 
241
183
  Args:
242
184
  profiles_root: The directory that holds every account's Codex home.
@@ -418,234 +360,6 @@ def update_codex_account_roster(
418
360
  return all_names, all_retired
419
361
 
420
362
 
421
- def read_account(
422
- name: str, profiles_root: Path, read_meters: MeterReader
423
- ) -> AccountReading:
424
- """Read one account's meters from its Codex home.
425
-
426
- Args:
427
- name: The account name.
428
- profiles_root: The directory that holds every account's Codex home.
429
- read_meters: Reads the meters under one Codex home.
430
-
431
- Returns:
432
- The meters, or the reason they are unread.
433
- """
434
- codex_home = profiles_root / name
435
- if not (codex_home / CODEX_AUTH_FILE_NAME).is_file():
436
- return AccountReading(name, codex_home, None, UNREAD_NOT_SIGNED_IN)
437
- try:
438
- return AccountReading(name, codex_home, read_meters(codex_home))
439
- except CodexMeterUnreadError as error:
440
- return AccountReading(name, codex_home, None, str(error))
441
-
442
-
443
- def _first_reset(
444
- all_read: Sequence[AccountReading],
445
- ) -> tuple[datetime, AccountReading] | None:
446
- all_resets = [
447
- (each_reset, each_reading)
448
- for each_reading in all_read
449
- if each_reading.meters is not None
450
- and (
451
- each_reset := each_reading.meters.resets_before_room(
452
- NORMAL_TIER_MINIMUM_PERCENT_LEFT
453
- )
454
- )
455
- is not None
456
- ]
457
- if not all_resets:
458
- return None
459
- return min(all_resets, key=lambda each_pair: each_pair[0])
460
-
461
-
462
- def _wait_decision(all_read: Sequence[AccountReading]) -> CodexAccountDecision:
463
- if not all_read:
464
- return CodexAccountDecision(TIER_WAIT, None, None, REASON_WAIT_UNREAD)
465
- first_reset = _first_reset(all_read)
466
- if first_reset is None:
467
- reset_text, first_reading = UNKNOWN_RESET_TEXT, all_read[0]
468
- else:
469
- reset_text, first_reading = first_reset[0].isoformat(), first_reset[1]
470
- return CodexAccountDecision(
471
- TIER_WAIT,
472
- first_reading,
473
- None,
474
- REASON_WAIT_TEMPLATE.format(account=first_reading.name, reset=reset_text),
475
- )
476
-
477
-
478
- def _percent_left(reading: AccountReading) -> float:
479
- return reading.meters.percent_left if reading.meters is not None else 0.0
480
-
481
-
482
- def _normal_decision(all_read: Sequence[AccountReading]) -> CodexAccountDecision | None:
483
- for each_reading in all_read:
484
- percent_left = _percent_left(each_reading)
485
- if percent_left > NORMAL_TIER_MINIMUM_PERCENT_LEFT:
486
- return CodexAccountDecision(
487
- TIER_NORMAL,
488
- each_reading,
489
- None,
490
- REASON_NORMAL_TEMPLATE.format(
491
- account=each_reading.name, percent_left=percent_left
492
- ),
493
- )
494
- return None
495
-
496
-
497
- def _can_run_luna(reading: AccountReading) -> bool:
498
- if reading.meters is None or reading.meters.percent_left <= LUNA_TIER_STOP_PERCENT_LEFT:
499
- return False
500
- short_window_left = reading.meters.short_window_percent_left
501
- return (
502
- short_window_left is None
503
- or short_window_left >= LUNA_TIER_SHORT_WINDOW_MINIMUM_PERCENT_LEFT
504
- )
505
-
506
-
507
- def _luna_decision(all_read: Sequence[AccountReading]) -> CodexAccountDecision | None:
508
- all_candidates = [each_reading for each_reading in all_read if _can_run_luna(each_reading)]
509
- if not all_candidates:
510
- return None
511
- roomiest = max(all_candidates, key=_percent_left)
512
- return CodexAccountDecision(
513
- TIER_LUNA,
514
- roomiest,
515
- LUNA_TIER_STOP_PERCENT_LEFT,
516
- REASON_LUNA_TEMPLATE.format(
517
- bar=NORMAL_TIER_MINIMUM_PERCENT_LEFT,
518
- account=roomiest.name,
519
- percent_left=_percent_left(roomiest),
520
- stop=LUNA_TIER_STOP_PERCENT_LEFT,
521
- ),
522
- )
523
-
524
-
525
- def choose_codex_account(all_readings: Sequence[AccountReading]) -> CodexAccountDecision:
526
- """Pick the tier and account a job runs on.
527
-
528
- ::
529
-
530
- codex-1 8%, codex-2 40% -> normal on codex-2
531
- codex-1 8%, codex-2 3%, others 0 -> luna on codex-1, stop at 1%
532
- codex-1 week 8%, 5-hour 15% left -> no luna on codex-1, 5-hour under 20%
533
- every account 1% or less -> wait, naming the account that resets first
534
- codex-1 unread, codex-2 40% -> normal on codex-2
535
-
536
- Args:
537
- all_readings: Every account's reading, in try order.
538
-
539
- Returns:
540
- The tier, the account, where a Luna run stops, and the reason.
541
- """
542
- all_read = [
543
- each_reading for each_reading in all_readings if each_reading.meters is not None
544
- ]
545
- return (
546
- _normal_decision(all_read)
547
- or _luna_decision(all_read)
548
- or _wait_decision(all_read)
549
- )
550
-
551
-
552
- def _iso_or_none(moment: datetime | None) -> str | None:
553
- return moment.isoformat() if moment else None
554
-
555
-
556
- def reading_payload(reading: AccountReading) -> dict[str, object]:
557
- """Shape one account's reading as the JSON object a report prints.
558
-
559
- Args:
560
- reading: One account's reading.
561
-
562
- Returns:
563
- The name, percent left, each window, and any unread reason.
564
- """
565
- if reading.meters is None:
566
- return {
567
- "name": reading.name,
568
- "percent_left": None,
569
- "unread": reading.unread_reason,
570
- }
571
- return {
572
- "name": reading.name,
573
- "percent_left": reading.meters.percent_left,
574
- "windows": [
575
- {
576
- "minutes": each_window.duration_minutes,
577
- "used_percent": each_window.used_percent,
578
- "resets_at": _iso_or_none(each_window.resets_at),
579
- }
580
- for each_window in reading.meters.all_windows
581
- ],
582
- }
583
-
584
-
585
- def decision_payload(
586
- decision: CodexAccountDecision, all_readings: Sequence[AccountReading]
587
- ) -> dict[str, object]:
588
- """Shape a decision as the JSON object the runner job reads.
589
-
590
- Args:
591
- decision: The chosen tier and account.
592
- all_readings: Every account's reading, for the report.
593
-
594
- Returns:
595
- The tier, account, Codex home, room, stop point, reason, and all readings.
596
- """
597
- is_runnable = decision.tier != TIER_WAIT and decision.reading is not None
598
- chosen = decision.reading if is_runnable else None
599
- return {
600
- "tier": decision.tier,
601
- "account": chosen.name if chosen else None,
602
- "codex_home": str(chosen.codex_home) if chosen else None,
603
- "percent_left": chosen.meters.percent_left
604
- if chosen and chosen.meters
605
- else None,
606
- "stop_below_percent": decision.stop_below_percent,
607
- "reason": decision.reason,
608
- "accounts": [reading_payload(each_reading) for each_reading in all_readings],
609
- }
610
-
611
-
612
- def _meter_reader(codex_path_argument: Path | None) -> MeterReader:
613
- codex_path = resolve_codex_path(codex_path_argument)
614
- return lambda codex_home: read_codex_meters(codex_path, codex_home)
615
-
616
-
617
- def _run_choose(arguments: argparse.Namespace) -> int:
618
- read_meters = _meter_reader(arguments.codex_path)
619
- all_readings = [
620
- read_account(each_name, arguments.profiles_root, read_meters)
621
- for each_name in codex_account_names(arguments.profiles_root)
622
- ]
623
- print(
624
- json.dumps(decision_payload(choose_codex_account(all_readings), all_readings))
625
- )
626
- return 0
627
-
628
-
629
- def _require_known_account(name: str, profiles_root: Path) -> None:
630
- all_known = codex_account_names(profiles_root)
631
- if name not in all_known:
632
- raise CodexAccountNameError(
633
- UNKNOWN_ACCOUNT_TEMPLATE.format(
634
- name=name, known=SETUP_NAMES_SEPARATOR.join(all_known)
635
- )
636
- )
637
-
638
-
639
- def _run_check(arguments: argparse.Namespace) -> int:
640
- _require_known_account(arguments.account, arguments.profiles_root)
641
- reading = read_account(
642
- arguments.account, arguments.profiles_root, _meter_reader(arguments.codex_path)
643
- )
644
- print(json.dumps(reading_payload(reading)))
645
- if reading.meters is not None and reading.meters.percent_left > arguments.floor:
646
- return EXIT_CODE_ROOM
647
- return EXIT_CODE_NO_ROOM
648
-
649
363
 
650
364
  def _run_sync(arguments: argparse.Namespace) -> int:
651
365
  now = datetime.now(timezone.utc)
@@ -715,18 +429,10 @@ def _add_launcher_commands(
715
429
 
716
430
  def _build_argument_parser() -> argparse.ArgumentParser:
717
431
  parser = argparse.ArgumentParser(
718
- description="Name the Codex account a runner job uses."
432
+ description="Keep Codex account homes and launchers ready."
719
433
  )
720
434
  parser.add_argument("--profiles-root", type=Path, default=default_profiles_root())
721
- parser.add_argument("--codex-path", type=Path, default=None)
722
435
  all_commands = parser.add_subparsers(dest="command", required=True)
723
- all_commands.add_parser("choose").set_defaults(run=_run_choose)
724
- check_parser = all_commands.add_parser("check")
725
- check_parser.add_argument("account")
726
- check_parser.add_argument(
727
- "--floor", type=float, default=LUNA_TIER_STOP_PERCENT_LEFT
728
- )
729
- check_parser.set_defaults(run=_run_check)
730
436
  sync_parser = all_commands.add_parser("sync")
731
437
  _add_main_home_argument(sync_parser)
732
438
  sync_parser.set_defaults(run=_run_sync)
@@ -741,9 +447,8 @@ def main(all_command_arguments: list[str]) -> int:
741
447
  all_command_arguments: Command-line arguments after the program name.
742
448
 
743
449
  Returns:
744
- Zero for choose, sync, setup, and install; for check, zero with room and
745
- three without. An invalid or unknown account name exits through the
746
- parser with two.
450
+ Zero for sync, setup, and install. An invalid account name exits
451
+ through the parser with two.
747
452
  """
748
453
  parser = _build_argument_parser()
749
454
  arguments = parser.parse_args(all_command_arguments)