claude-dev-env 8.11.1 → 8.13.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.
Files changed (105) hide show
  1. package/.agents/skills/e-code-review/SKILL.md +24 -71
  2. package/.agents/skills/e-code-review/reference/fix.md +7 -78
  3. package/.agents/skills/e-code-review/reference/high.md +129 -0
  4. package/.agents/skills/e-code-review/reference/low.md +20 -51
  5. package/.agents/skills/e-code-review/reference/max.md +169 -0
  6. package/.agents/skills/e-code-review/reference/medium.md +7 -191
  7. package/.agents/skills/e-code-review/reference/xhigh.md +47 -91
  8. package/.agents/skills/pull-request/scripts/test_pull_request.py +1 -1
  9. package/.agents/skills-archived/pr-converge/scripts/test_check_convergence.py +4 -4
  10. package/_shared/advisor/reference/astra-rung.md +7 -7
  11. package/_shared/advisor/reference/third-party-bind.md +2 -2
  12. package/_shared/advisor/scripts/codex_astra_advisor.py +33 -26
  13. package/_shared/advisor/scripts/codex_astra_preflight.py +57 -55
  14. package/_shared/advisor/scripts/config/advisor_scripts_constants/astra_advisor_constants.py +8 -8
  15. package/_shared/advisor/scripts/tests/test_codex_astra_advisor.py +69 -23
  16. package/_shared/advisor/scripts/tests/test_codex_astra_preflight.py +4 -4
  17. package/_shared/pr-loop/scripts/check_convergence.py +54 -49
  18. package/_shared/pr-loop/scripts/codex_review_scripts_constants/run_constants.py +4 -0
  19. package/_shared/pr-loop/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +10 -1
  20. package/_shared/pr-loop/scripts/run_codex_review.py +4 -6
  21. package/_shared/pr-loop/scripts/test_check_convergence.py +6 -6
  22. package/_shared/pr-loop/scripts/test_check_convergence_codex.py +73 -31
  23. package/_shared/pr-loop/scripts/test_check_convergence_contract.py +2 -2
  24. package/_shared/pr-loop/scripts/tests/test__claude_permissions_common.py +2 -2
  25. package/bin/git_hooks_installer.test.mjs +1 -1
  26. package/bin/install-constants.mjs +1 -6
  27. package/bin/install.agents-home.test.mjs +9 -9
  28. package/bin/install.cursor-rules.test.mjs +24 -51
  29. package/bin/install.mjs +16 -70
  30. package/commands/sr-loop.md +3 -1
  31. package/docs/codex-accounts.md +79 -0
  32. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +5 -5
  33. package/hooks/blocking/test_pii_scanner.py +1 -1
  34. package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +2 -2
  35. package/hooks/git-hooks/conftest.py +16 -0
  36. package/hooks/git-hooks/git_hooks_constants/verification_notice_constants.py +1 -1
  37. package/hooks/git-hooks/test_post_commit.py +1 -1
  38. package/hooks/git-hooks/test_pre_commit.py +1 -1
  39. package/hooks/git-hooks/test_pre_push.py +2 -2
  40. package/hooks/git-hooks/test_verification_start.py +1 -1
  41. package/hooks/git-hooks/verification_notice.py +2 -2
  42. package/hooks/git-hooks/verification_notice_context.py +21 -2
  43. package/hooks/git-hooks/verification_start.py +2 -3
  44. package/hooks/hooks_constants/code_rules_path_utils_constants.py +0 -1
  45. package/package.json +1 -1
  46. package/rules/durable-post-artifacts.md +4 -3
  47. package/scripts/claude_account_profile.py +21 -9
  48. package/scripts/codex_account_choice.py +396 -0
  49. package/scripts/codex_account_meters.py +372 -0
  50. package/scripts/dev_env_scripts_constants/codex_account_constants.py +141 -0
  51. package/scripts/dev_env_scripts_constants/private_term_constants.py +17 -2
  52. package/scripts/private_term_scan.py +6 -2
  53. package/scripts/repository_checks/config/constants.py +1 -0
  54. package/scripts/repository_checks/tracked_private_terms.py +7 -0
  55. package/scripts/test_claude_account_profile.py +12 -0
  56. package/scripts/test_codex_account_choice.py +276 -0
  57. package/scripts/test_codex_account_meters.py +205 -0
  58. package/scripts/tests/test_automatic_advisory.py +3 -3
  59. package/scripts/tests/test_automatic_advisory_polling.py +1 -1
  60. package/scripts/tests/test_automatic_advisory_recovery.py +1 -1
  61. package/scripts/tests/test_closed_pr_label.py +1 -1
  62. package/scripts/tests/test_private_term_scan.py +28 -0
  63. package/scripts/tests/test_review_closure.py +2 -2
  64. package/scripts/tests/test_review_closure_model.py +2 -2
  65. package/scripts/tests/test_rules.py +2 -83
  66. package/scripts/tests/test_tracked_private_terms.py +15 -0
  67. package/scripts/tests/test_verification_notice.py +29 -13
  68. package/.agents/skills/cert-classification-rule/SKILL.md +0 -224
  69. package/.agents/skills/cert-classification-rule/scripts/resort_cert_folders.py +0 -158
  70. package/.agents/skills/e-code-review/preflight-proposal.contract.test.mjs +0 -20
  71. package/.agents/skills/e-code-review/reference/effort-evaluation.md +0 -35
  72. package/.agents/skills/e-code-review/reference/loop.md +0 -261
  73. package/.agents/skills/e-code-review/reference/mode-contract.test.mjs +0 -67
  74. package/.agents/skills/e-code-review/reference/preflight-proposal.md +0 -75
  75. package/.agents/skills/e-code-review/reference/runner-selection.md +0 -40
  76. package/.agents/skills/e-code-review/scripts/config/e_code_review_effort_constants/__init__.py +0 -41
  77. package/.agents/skills/e-code-review/scripts/config/e_code_review_effort_constants/effort_constants.py +0 -40
  78. package/.agents/skills/e-code-review/scripts/e_code_review_scripts_constants/__init__.py +0 -1
  79. package/.agents/skills/e-code-review/scripts/e_code_review_scripts_constants/finding_pipeline_constants.py +0 -49
  80. package/.agents/skills/e-code-review/scripts/e_code_review_scripts_constants/grok_code_review_constants.py +0 -55
  81. package/.agents/skills/e-code-review/scripts/effort_defaults_evidence.json +0 -186
  82. package/.agents/skills/e-code-review/scripts/effort_evaluation.py +0 -362
  83. package/.agents/skills/e-code-review/scripts/finding_pipeline.py +0 -140
  84. package/.agents/skills/e-code-review/scripts/fixtures/demanding.json +0 -26
  85. package/.agents/skills/e-code-review/scripts/fixtures/easy.json +0 -14
  86. package/.agents/skills/e-code-review/scripts/fixtures/medium.json +0 -20
  87. package/.agents/skills/e-code-review/scripts/grok_code_review.py +0 -230
  88. package/.agents/skills/e-code-review/scripts/test_effort_evaluation.py +0 -180
  89. package/.agents/skills/e-code-review/scripts/test_finding_pipeline.py +0 -197
  90. package/.agents/skills/e-code-review/scripts/test_grok_code_review.py +0 -289
  91. package/_shared/pr-loop/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +0 -82
  92. package/_shared/pr-loop/scripts/codex_usage_probe.py +0 -573
  93. package/_shared/pr-loop/scripts/test_codex_usage_probe.py +0 -683
  94. package/scripts/sync_to_cursor/AGENTS.md +0 -1
  95. package/scripts/sync_to_cursor/CLAUDE.md +0 -1
  96. package/scripts/sync_to_cursor/__init__.py +0 -13
  97. package/scripts/sync_to_cursor/canonical_docs.py +0 -66
  98. package/scripts/sync_to_cursor/config/__init__.py +0 -8
  99. package/scripts/sync_to_cursor/engine.py +0 -219
  100. package/scripts/sync_to_cursor/hashing.py +0 -7
  101. package/scripts/sync_to_cursor/paths.py +0 -18
  102. package/scripts/sync_to_cursor/rules.py +0 -494
  103. package/scripts/sync_to_cursor.py +0 -22
  104. package/scripts/tests/test_engine.py +0 -101
  105. package/scripts/tests/test_sync_to_cursor.py +0 -315
@@ -31,6 +31,7 @@ import json
31
31
  import os
32
32
  import subprocess
33
33
  import sys
34
+ from collections.abc import Callable
34
35
  from dataclasses import dataclass
35
36
  from datetime import datetime, timezone
36
37
  from pathlib import Path
@@ -214,11 +215,11 @@ def _unlink_orphaned_links(main_home: Path, profile_home: Path) -> tuple[str, ..
214
215
 
215
216
 
216
217
  def _unlink_local_names_linked_to_main(
217
- main_home: Path, profile_home: Path
218
+ main_home: Path, profile_home: Path, is_local: Callable[[str], bool]
218
219
  ) -> tuple[str, ...]:
219
220
  all_unlinked: list[str] = []
220
221
  for each_entry in sorted(profile_home.iterdir()):
221
- if not is_account_local(each_entry.name):
222
+ if not is_local(each_entry.name):
222
223
  continue
223
224
  if not links_to(each_entry, main_home / each_entry.name):
224
225
  continue
@@ -237,13 +238,16 @@ def _clear_entry(entry_path: Path, source_path: Path, replaced_directory: Path)
237
238
 
238
239
 
239
240
  def _link_shared_entries(
240
- main_home: Path, profile_home: Path, replaced_directory: Path
241
+ main_home: Path,
242
+ profile_home: Path,
243
+ replaced_directory: Path,
244
+ is_local: Callable[[str], bool],
241
245
  ) -> tuple[tuple[str, ...], tuple[str, ...]]:
242
246
  all_linked: list[str] = []
243
247
  all_moved_aside: list[str] = []
244
248
  for each_source in sorted(main_home.iterdir()):
245
249
  entry_path = profile_home / each_source.name
246
- if is_account_local(each_source.name) or links_to(entry_path, each_source):
250
+ if is_local(each_source.name) or links_to(entry_path, each_source):
247
251
  continue
248
252
  if os.path.lexists(entry_path) and _clear_entry(
249
253
  entry_path, each_source, replaced_directory
@@ -255,14 +259,19 @@ def _link_shared_entries(
255
259
 
256
260
 
257
261
  def sync_profile(
258
- *, main_home: Path, profile_home: Path, now: datetime
262
+ *,
263
+ main_home: Path,
264
+ profile_home: Path,
265
+ now: datetime,
266
+ is_local: Callable[[str], bool] = is_account_local,
259
267
  ) -> ProfileSyncReport:
260
268
  """Link every shared main-home entry into the profile home.
261
269
 
262
270
  Args:
263
- main_home: The main account's Claude home.
264
- profile_home: The second account's Claude home.
271
+ main_home: The main account's home.
272
+ profile_home: The profile account's home.
265
273
  now: The run time that names the folder stale entries move into.
274
+ is_local: Tells whether a top-level entry belongs to one account only.
266
275
 
267
276
  Returns:
268
277
  The entries the run linked, moved aside, and unlinked.
@@ -271,11 +280,14 @@ def sync_profile(
271
280
  all_unlinked = tuple(
272
281
  sorted(
273
282
  _unlink_orphaned_links(main_home, profile_home)
274
- + _unlink_local_names_linked_to_main(main_home, profile_home)
283
+ + _unlink_local_names_linked_to_main(main_home, profile_home, is_local)
275
284
  )
276
285
  )
277
286
  all_linked, all_moved_aside = _link_shared_entries(
278
- main_home, profile_home, profile_home / REPLACED_DIRECTORY_NAME / _stamp(now)
287
+ main_home,
288
+ profile_home,
289
+ profile_home / REPLACED_DIRECTORY_NAME / _stamp(now),
290
+ is_local,
279
291
  )
280
292
  return ProfileSyncReport(
281
293
  all_linked=all_linked,
@@ -0,0 +1,396 @@
1
+ #!/usr/bin/env python3
2
+ """Name the Codex account a runner job uses, and keep each account's home ready.
3
+
4
+ Up to four Codex accounts sign in, each under its own Codex home in
5
+ ``~/.codex-profiles/<name>``. Jobs try them in one fixed order::
6
+
7
+ first account with more than 10% left -> normal, that account
8
+ none over 10%, the roomiest one over 1% -> luna, stop at 1% left
9
+ ... but an account with a 5-hour window needs 20% of it left for luna
10
+ none over 1%, or no meter reads -> wait, name the first reset
11
+ account not signed in, or its meter unread -> skipped
12
+
13
+ Three commands::
14
+
15
+ python codex_account_choice.py choose
16
+ {"tier": "normal", "account": "codex-1", "codex_home": "C:/Users/me/.codex-profiles/codex-1",
17
+ "percent_left": 62.0, "stop_below_percent": null, "reason": "codex-1 has 62% left",
18
+ "accounts": [...]}
19
+
20
+ python codex_account_choice.py check codex-2 --floor 1
21
+ exit 0 while codex-2 has more than 1% left, exit 3 once it has not
22
+
23
+ python codex_account_choice.py sync
24
+ links the shared Codex setup (config, rules, skills, plugins) into every
25
+ account's home and leaves each sign-in and history its own
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import json
32
+ import os
33
+ import sys
34
+ from collections.abc import Callable, Sequence
35
+ from dataclasses import dataclass
36
+ from datetime import datetime, timezone
37
+ from pathlib import Path
38
+
39
+ from claude_account_profile import sync_profile
40
+ from codex_account_meters import (
41
+ CodexAccountMeters,
42
+ CodexMeterUnreadError,
43
+ read_codex_meters,
44
+ resolve_codex_path,
45
+ )
46
+ from dev_env_scripts_constants.codex_account_constants import (
47
+ ALL_CODEX_ACCOUNT_NAMES,
48
+ ALL_SHARED_CODEX_HOME_NAMES,
49
+ CODEX_AUTH_FILE_NAME,
50
+ CODEX_PROFILES_ROOT_DIRECTORY_NAME,
51
+ CODEX_PROFILES_ROOT_ENVIRONMENT_VARIABLE,
52
+ EXIT_CODE_NO_ROOM,
53
+ EXIT_CODE_ROOM,
54
+ LUNA_TIER_SHORT_WINDOW_MINIMUM_PERCENT_LEFT,
55
+ LUNA_TIER_STOP_PERCENT_LEFT,
56
+ MAIN_CODEX_HOME_DIRECTORY_NAME,
57
+ NORMAL_TIER_MINIMUM_PERCENT_LEFT,
58
+ REASON_LUNA_TEMPLATE,
59
+ REASON_NORMAL_TEMPLATE,
60
+ REASON_WAIT_TEMPLATE,
61
+ REASON_WAIT_UNREAD,
62
+ TIER_LUNA,
63
+ TIER_NORMAL,
64
+ TIER_WAIT,
65
+ UNKNOWN_RESET_TEXT,
66
+ UNREAD_NOT_SIGNED_IN,
67
+ )
68
+
69
+ MeterReader = Callable[[Path], CodexAccountMeters]
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class AccountReading:
74
+ """One account's meters, or why they could not be read."""
75
+
76
+ name: str
77
+ codex_home: Path
78
+ meters: CodexAccountMeters | None
79
+ unread_reason: str | None = None
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class CodexAccountDecision:
84
+ """The tier and account a job runs on, and the plain-words reason."""
85
+
86
+ tier: str
87
+ reading: AccountReading | None
88
+ stop_below_percent: float | None
89
+ reason: str
90
+
91
+
92
+ def default_profiles_root() -> Path:
93
+ """Locate the directory that holds one Codex home per account.
94
+
95
+ Returns:
96
+ The root the environment names, else ``~/.codex-profiles``.
97
+ """
98
+ named_root = os.environ.get(CODEX_PROFILES_ROOT_ENVIRONMENT_VARIABLE)
99
+ return (
100
+ Path(named_root)
101
+ if named_root
102
+ else Path.home() / CODEX_PROFILES_ROOT_DIRECTORY_NAME
103
+ )
104
+
105
+
106
+ def is_account_local_codex_entry(entry_name: str) -> bool:
107
+ """Tell whether a Codex home entry belongs to one account only.
108
+
109
+ ::
110
+
111
+ "auth.json", "sessions", "history.jsonl", "state_5.sqlite" -> True
112
+ "config.toml", "rules", "skills", "plugins" -> False
113
+
114
+ Args:
115
+ entry_name: A top-level entry name in a Codex home.
116
+
117
+ Returns:
118
+ True for every entry outside the shared set.
119
+ """
120
+ return entry_name not in ALL_SHARED_CODEX_HOME_NAMES
121
+
122
+
123
+ def read_account(
124
+ name: str, profiles_root: Path, read_meters: MeterReader
125
+ ) -> AccountReading:
126
+ """Read one account's meters from its Codex home.
127
+
128
+ Args:
129
+ name: The account name.
130
+ profiles_root: The directory that holds every account's Codex home.
131
+ read_meters: Reads the meters under one Codex home.
132
+
133
+ Returns:
134
+ The meters, or the reason they are unread.
135
+ """
136
+ codex_home = profiles_root / name
137
+ if not (codex_home / CODEX_AUTH_FILE_NAME).is_file():
138
+ return AccountReading(name, codex_home, None, UNREAD_NOT_SIGNED_IN)
139
+ try:
140
+ return AccountReading(name, codex_home, read_meters(codex_home))
141
+ except CodexMeterUnreadError as error:
142
+ return AccountReading(name, codex_home, None, str(error))
143
+
144
+
145
+ def _first_reset(
146
+ all_read: Sequence[AccountReading],
147
+ ) -> tuple[datetime, AccountReading] | None:
148
+ all_resets = [
149
+ (each_reset, each_reading)
150
+ for each_reading in all_read
151
+ if each_reading.meters is not None
152
+ and (
153
+ each_reset := each_reading.meters.resets_before_room(
154
+ NORMAL_TIER_MINIMUM_PERCENT_LEFT
155
+ )
156
+ )
157
+ is not None
158
+ ]
159
+ if not all_resets:
160
+ return None
161
+ return min(all_resets, key=lambda each_pair: each_pair[0])
162
+
163
+
164
+ def _wait_decision(all_read: Sequence[AccountReading]) -> CodexAccountDecision:
165
+ if not all_read:
166
+ return CodexAccountDecision(TIER_WAIT, None, None, REASON_WAIT_UNREAD)
167
+ first_reset = _first_reset(all_read)
168
+ if first_reset is None:
169
+ reset_text, first_reading = UNKNOWN_RESET_TEXT, all_read[0]
170
+ else:
171
+ reset_text, first_reading = first_reset[0].isoformat(), first_reset[1]
172
+ return CodexAccountDecision(
173
+ TIER_WAIT,
174
+ first_reading,
175
+ None,
176
+ REASON_WAIT_TEMPLATE.format(account=first_reading.name, reset=reset_text),
177
+ )
178
+
179
+
180
+ def _percent_left(reading: AccountReading) -> float:
181
+ return reading.meters.percent_left if reading.meters is not None else 0.0
182
+
183
+
184
+ def _normal_decision(all_read: Sequence[AccountReading]) -> CodexAccountDecision | None:
185
+ for each_reading in all_read:
186
+ percent_left = _percent_left(each_reading)
187
+ if percent_left > NORMAL_TIER_MINIMUM_PERCENT_LEFT:
188
+ return CodexAccountDecision(
189
+ TIER_NORMAL,
190
+ each_reading,
191
+ None,
192
+ REASON_NORMAL_TEMPLATE.format(
193
+ account=each_reading.name, percent_left=percent_left
194
+ ),
195
+ )
196
+ return None
197
+
198
+
199
+ def _can_run_luna(reading: AccountReading) -> bool:
200
+ if reading.meters is None or reading.meters.percent_left <= LUNA_TIER_STOP_PERCENT_LEFT:
201
+ return False
202
+ short_window_left = reading.meters.short_window_percent_left
203
+ return (
204
+ short_window_left is None
205
+ or short_window_left >= LUNA_TIER_SHORT_WINDOW_MINIMUM_PERCENT_LEFT
206
+ )
207
+
208
+
209
+ def _luna_decision(all_read: Sequence[AccountReading]) -> CodexAccountDecision | None:
210
+ all_candidates = [each_reading for each_reading in all_read if _can_run_luna(each_reading)]
211
+ if not all_candidates:
212
+ return None
213
+ roomiest = max(all_candidates, key=_percent_left)
214
+ return CodexAccountDecision(
215
+ TIER_LUNA,
216
+ roomiest,
217
+ LUNA_TIER_STOP_PERCENT_LEFT,
218
+ REASON_LUNA_TEMPLATE.format(
219
+ bar=NORMAL_TIER_MINIMUM_PERCENT_LEFT,
220
+ account=roomiest.name,
221
+ percent_left=_percent_left(roomiest),
222
+ stop=LUNA_TIER_STOP_PERCENT_LEFT,
223
+ ),
224
+ )
225
+
226
+
227
+ def choose_codex_account(all_readings: Sequence[AccountReading]) -> CodexAccountDecision:
228
+ """Pick the tier and account a job runs on.
229
+
230
+ ::
231
+
232
+ codex-1 8%, codex-2 40% -> normal on codex-2
233
+ codex-1 8%, codex-2 3%, others 0 -> luna on codex-1, stop at 1%
234
+ codex-1 week 8%, 5-hour 15% left -> no luna on codex-1, 5-hour under 20%
235
+ every account 1% or less -> wait, naming the account that resets first
236
+ codex-1 unread, codex-2 40% -> normal on codex-2
237
+
238
+ Args:
239
+ all_readings: Every account's reading, in try order.
240
+
241
+ Returns:
242
+ The tier, the account, where a Luna run stops, and the reason.
243
+ """
244
+ all_read = [
245
+ each_reading for each_reading in all_readings if each_reading.meters is not None
246
+ ]
247
+ return (
248
+ _normal_decision(all_read)
249
+ or _luna_decision(all_read)
250
+ or _wait_decision(all_read)
251
+ )
252
+
253
+
254
+ def _iso_or_none(moment: datetime | None) -> str | None:
255
+ return moment.isoformat() if moment else None
256
+
257
+
258
+ def reading_payload(reading: AccountReading) -> dict[str, object]:
259
+ """Shape one account's reading as the JSON object a report prints.
260
+
261
+ Args:
262
+ reading: One account's reading.
263
+
264
+ Returns:
265
+ The name, percent left, each window, and any unread reason.
266
+ """
267
+ if reading.meters is None:
268
+ return {
269
+ "name": reading.name,
270
+ "percent_left": None,
271
+ "unread": reading.unread_reason,
272
+ }
273
+ return {
274
+ "name": reading.name,
275
+ "percent_left": reading.meters.percent_left,
276
+ "windows": [
277
+ {
278
+ "minutes": each_window.duration_minutes,
279
+ "used_percent": each_window.used_percent,
280
+ "resets_at": _iso_or_none(each_window.resets_at),
281
+ }
282
+ for each_window in reading.meters.all_windows
283
+ ],
284
+ }
285
+
286
+
287
+ def decision_payload(
288
+ decision: CodexAccountDecision, all_readings: Sequence[AccountReading]
289
+ ) -> dict[str, object]:
290
+ """Shape a decision as the JSON object the runner job reads.
291
+
292
+ Args:
293
+ decision: The chosen tier and account.
294
+ all_readings: Every account's reading, for the report.
295
+
296
+ Returns:
297
+ The tier, account, Codex home, room, stop point, reason, and all readings.
298
+ """
299
+ is_runnable = decision.tier != TIER_WAIT and decision.reading is not None
300
+ chosen = decision.reading if is_runnable else None
301
+ return {
302
+ "tier": decision.tier,
303
+ "account": chosen.name if chosen else None,
304
+ "codex_home": str(chosen.codex_home) if chosen else None,
305
+ "percent_left": chosen.meters.percent_left
306
+ if chosen and chosen.meters
307
+ else None,
308
+ "stop_below_percent": decision.stop_below_percent,
309
+ "reason": decision.reason,
310
+ "accounts": [reading_payload(each_reading) for each_reading in all_readings],
311
+ }
312
+
313
+
314
+ def _meter_reader(codex_path_argument: Path | None) -> MeterReader:
315
+ codex_path = resolve_codex_path(codex_path_argument)
316
+ return lambda codex_home: read_codex_meters(codex_path, codex_home)
317
+
318
+
319
+ def _run_choose(arguments: argparse.Namespace) -> int:
320
+ read_meters = _meter_reader(arguments.codex_path)
321
+ all_readings = [
322
+ read_account(each_name, arguments.profiles_root, read_meters)
323
+ for each_name in ALL_CODEX_ACCOUNT_NAMES
324
+ ]
325
+ print(
326
+ json.dumps(decision_payload(choose_codex_account(all_readings), all_readings))
327
+ )
328
+ return 0
329
+
330
+
331
+ def _run_check(arguments: argparse.Namespace) -> int:
332
+ reading = read_account(
333
+ arguments.account, arguments.profiles_root, _meter_reader(arguments.codex_path)
334
+ )
335
+ print(json.dumps(reading_payload(reading)))
336
+ if reading.meters is not None and reading.meters.percent_left > arguments.floor:
337
+ return EXIT_CODE_ROOM
338
+ return EXIT_CODE_NO_ROOM
339
+
340
+
341
+ def _run_sync(arguments: argparse.Namespace) -> int:
342
+ now = datetime.now(timezone.utc)
343
+ all_reports = {}
344
+ for each_name in ALL_CODEX_ACCOUNT_NAMES:
345
+ report = sync_profile(
346
+ main_home=arguments.main_home,
347
+ profile_home=arguments.profiles_root / each_name,
348
+ now=now,
349
+ is_local=is_account_local_codex_entry,
350
+ )
351
+ all_reports[each_name] = {
352
+ "linked": list(report.all_linked),
353
+ "moved_aside": list(report.all_moved_aside),
354
+ "unlinked": list(report.all_unlinked),
355
+ }
356
+ print(json.dumps(all_reports))
357
+ return 0
358
+
359
+
360
+ def _build_argument_parser() -> argparse.ArgumentParser:
361
+ parser = argparse.ArgumentParser(
362
+ description="Name the Codex account a runner job uses."
363
+ )
364
+ parser.add_argument("--profiles-root", type=Path, default=default_profiles_root())
365
+ parser.add_argument("--codex-path", type=Path, default=None)
366
+ all_commands = parser.add_subparsers(dest="command", required=True)
367
+ all_commands.add_parser("choose").set_defaults(run=_run_choose)
368
+ check_parser = all_commands.add_parser("check")
369
+ check_parser.add_argument("account", choices=ALL_CODEX_ACCOUNT_NAMES)
370
+ check_parser.add_argument(
371
+ "--floor", type=float, default=LUNA_TIER_STOP_PERCENT_LEFT
372
+ )
373
+ check_parser.set_defaults(run=_run_check)
374
+ sync_parser = all_commands.add_parser("sync")
375
+ sync_parser.add_argument(
376
+ "--main-home", type=Path, default=Path.home() / MAIN_CODEX_HOME_DIRECTORY_NAME
377
+ )
378
+ sync_parser.set_defaults(run=_run_sync)
379
+ return parser
380
+
381
+
382
+ def main(all_command_arguments: list[str]) -> int:
383
+ """Run one command and print its JSON report.
384
+
385
+ Args:
386
+ all_command_arguments: Command-line arguments after the program name.
387
+
388
+ Returns:
389
+ Zero for choose and sync; for check, zero with room and three without.
390
+ """
391
+ arguments = _build_argument_parser().parse_args(all_command_arguments)
392
+ return arguments.run(arguments)
393
+
394
+
395
+ if __name__ == "__main__":
396
+ sys.exit(main(sys.argv[1:]))