claude-dev-env 8.37.3 → 8.38.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.
@@ -0,0 +1,445 @@
1
+ """Choose and run Claude or Codex jobs through one account roster."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ import tempfile
10
+ import threading
11
+ from contextlib import contextmanager
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+ from typing import Iterator, Sequence
15
+
16
+ if sys.platform == "win32":
17
+ import msvcrt
18
+ else:
19
+ import fcntl
20
+
21
+ import codex_account_choice
22
+ import codex_account_meters
23
+ from claude_account_profile import default_profile_home, validate_profile_name
24
+ from claude_chain_usage import WeeklyUtilizationProbeError, probe_account_meters
25
+ from dev_env_scripts_constants.account_broker_constants import (
26
+ BROKER_STATE_DIRECTORY_NAME,
27
+ BROKER_STATE_FILE_NAME,
28
+ BROKER_STATE_LOCK_SUFFIX,
29
+ BROKER_STATE_TEMP_SUFFIX,
30
+ Account,
31
+ BrokerConfigurationError,
32
+ Decision,
33
+ JobOutcome,
34
+ Meters,
35
+ Product,
36
+ ProductAdapter,
37
+ Reading,
38
+ Report,
39
+ REPORT_INDENT_SPACES,
40
+ SubprocessRunner,
41
+ codex_usage_limit_signatures,
42
+ parse_utc_time,
43
+ utc_time_text,
44
+ )
45
+ from dev_env_scripts_constants.claude_account_constants import (
46
+ CREDENTIALS_FILE_NAME,
47
+ EXTRA_PROFILES_FILE_NAME,
48
+ FULL_PERCENT,
49
+ MAIN_CLAUDE_HOME_DIRECTORY_NAME,
50
+ )
51
+ from dev_env_scripts_constants.claude_chain_constants import ALL_USAGE_LIMIT_SIGNATURES
52
+ from dev_env_scripts_constants.codex_account_constants import (
53
+ CODEX_ACCOUNT_LAUNCHERS_FILE_NAME,
54
+ CODEX_ACCOUNT_PROFILES_ENVIRONMENT_VARIABLE,
55
+ CODEX_HOME_ENVIRONMENT_VARIABLE,
56
+ WEEKLY_WINDOW_MINUTES,
57
+ )
58
+ from dev_env_scripts_constants.shared_tree_constants import CLAUDE_CONFIG_DIR_ENV_VAR
59
+
60
+
61
+ def _read_list(path: Path) -> object:
62
+ try:
63
+ return json.loads(path.read_text(encoding="utf-8"))
64
+ except (OSError, UnicodeError, json.JSONDecodeError) as error:
65
+ raise BrokerConfigurationError(f"cannot read account list {path}: {error}") from error
66
+
67
+
68
+ def _chain_entry(raw_entry: object, main_home: Path, chain_path: Path) -> Account:
69
+ if not isinstance(raw_entry, dict):
70
+ raise BrokerConfigurationError(f"invalid account list {chain_path}")
71
+ command = raw_entry.get("command")
72
+ credential = raw_entry.get("credentials_path")
73
+ extra_args = raw_entry.get("extra_args", [])
74
+ if not isinstance(command, str) or not command:
75
+ raise BrokerConfigurationError(f"invalid account list {chain_path}")
76
+ if credential is not None and (not isinstance(credential, str) or not credential):
77
+ raise BrokerConfigurationError(f"invalid account list {chain_path}")
78
+ if not isinstance(extra_args, list) or any(not isinstance(each_arg, str) for each_arg in extra_args):
79
+ raise BrokerConfigurationError(f"invalid account list {chain_path}")
80
+ home = Path(credential).expanduser().parent.resolve() if credential else main_home
81
+ return Account(Product.CLAUDE, command, home, command=command)
82
+
83
+
84
+ def _chain_entries(chain_path: Path, main_home: Path) -> tuple[Account, ...]:
85
+ if not chain_path.exists():
86
+ return ()
87
+ document = _read_list(chain_path)
88
+ all_entries = document.get("chain") if isinstance(document, dict) else None
89
+ if not isinstance(all_entries, list) or not all_entries:
90
+ raise BrokerConfigurationError(f"invalid account list {chain_path}")
91
+ return tuple(_chain_entry(each_entry, main_home, chain_path) for each_entry in all_entries)
92
+
93
+
94
+ def _extra_homes(main_home: Path) -> tuple[Path, ...]:
95
+ extras_path = main_home / EXTRA_PROFILES_FILE_NAME
96
+ if not extras_path.exists():
97
+ return (default_profile_home(),)
98
+ all_names = _read_list(extras_path)
99
+ if not isinstance(all_names, list) or not all_names or any(not isinstance(each_name, str) for each_name in all_names):
100
+ raise BrokerConfigurationError(f"invalid account list {extras_path}")
101
+ if len({each_name.casefold() for each_name in all_names}) != len(all_names):
102
+ raise BrokerConfigurationError(f"duplicate account in {extras_path}")
103
+ try:
104
+ return tuple(default_profile_home(validate_profile_name(each_name)) for each_name in all_names)
105
+ except ValueError as error:
106
+ raise BrokerConfigurationError(f"invalid account list {extras_path}: {error}") from error
107
+
108
+
109
+ def _append_claude_account(
110
+ all_accounts: list[Account], all_seen: set[str], account: Account, main_home: Path
111
+ ) -> None:
112
+ key = str(account.home).casefold()
113
+ if key == str(main_home).casefold():
114
+ all_accounts[0] = Account(Product.CLAUDE, "main", main_home, True, account.command)
115
+ elif key not in all_seen:
116
+ all_accounts.append(account)
117
+ all_seen.add(key)
118
+
119
+
120
+ def load_claude_accounts() -> tuple[Account, ...]:
121
+ """Read the Claude account roster.
122
+
123
+ Returns:
124
+ Main and additional accounts in configured order.
125
+
126
+ Raises:
127
+ BrokerConfigurationError: An account list is unreadable.
128
+ """
129
+ main_home = (Path.home() / MAIN_CLAUDE_HOME_DIRECTORY_NAME).resolve()
130
+ all_accounts = [Account(Product.CLAUDE, "main", main_home, True, "claude")]
131
+ all_seen = {str(main_home).casefold()}
132
+ for each_entry in _chain_entries(main_home / "claude-chain.json", main_home):
133
+ _append_claude_account(all_accounts, all_seen, each_entry, main_home)
134
+ for each_home in _extra_homes(main_home):
135
+ home = each_home.resolve()
136
+ _append_claude_account(all_accounts, all_seen, Account(Product.CLAUDE, home.name, home, command=home.name), main_home)
137
+ return tuple(all_accounts)
138
+
139
+
140
+ def load_codex_accounts() -> tuple[Account, ...]:
141
+ """Read the Codex account roster.
142
+
143
+ Returns:
144
+ Configured accounts, or an empty tuple when none are configured.
145
+
146
+ Raises:
147
+ BrokerConfigurationError: The configured roster is unreadable.
148
+ """
149
+ profiles_root = codex_account_choice.default_profiles_root().resolve()
150
+ roster_path = profiles_root / CODEX_ACCOUNT_LAUNCHERS_FILE_NAME
151
+ configured = bool(os.environ.get(CODEX_ACCOUNT_PROFILES_ENVIRONMENT_VARIABLE, "").strip())
152
+ if not configured and not roster_path.exists():
153
+ return ()
154
+ if not configured and _read_list(roster_path) == []:
155
+ return ()
156
+ try:
157
+ names = codex_account_choice.codex_account_names(profiles_root)
158
+ except (OSError, ValueError) as error:
159
+ raise BrokerConfigurationError(f"cannot read account list {roster_path}: {error}") from error
160
+ return tuple(Account(Product.CODEX, name, (profiles_root / name).resolve()) for name in names)
161
+
162
+
163
+ def read_claude_meters(account: Account) -> Meters | None:
164
+ """Probe one Claude account.
165
+
166
+ Args:
167
+ account: Account whose meter is read.
168
+
169
+ Returns:
170
+ Its meters, or None when the probe fails.
171
+ """
172
+ try:
173
+ usage = probe_account_meters(account.home / CREDENTIALS_FILE_NAME)
174
+ except (WeeklyUtilizationProbeError, OSError):
175
+ return None
176
+ return Meters(
177
+ FULL_PERCENT - usage.session_utilization if usage.session_utilization is not None else None,
178
+ usage.session_resets_at,
179
+ FULL_PERCENT - usage.weekly_utilization if usage.weekly_utilization is not None else None,
180
+ usage.weekly_resets_at,
181
+ )
182
+
183
+
184
+ def read_codex_account_meters(account: Account) -> Meters | None:
185
+ """Probe one Codex account.
186
+
187
+ Args:
188
+ account: Account whose meter is read.
189
+
190
+ Returns:
191
+ Its meters, or None when the probe fails.
192
+ """
193
+ try:
194
+ codex_path = codex_account_meters.resolve_codex_path(None)
195
+ usage = codex_account_meters.read_codex_meters(codex_path, account.home)
196
+ except (codex_account_meters.CodexMeterUnreadError, OSError):
197
+ return None
198
+ weekly = [window for window in usage.all_windows if window.duration_minutes is None or window.duration_minutes >= WEEKLY_WINDOW_MINUTES]
199
+ short = [window for window in usage.all_windows if window.duration_minutes is not None and window.duration_minutes < WEEKLY_WINDOW_MINUTES]
200
+ weekly_window = max(weekly, key=lambda window: window.used_percent) if weekly else None
201
+ short_window = max(short, key=lambda window: window.used_percent) if short else None
202
+ return Meters(
203
+ usage.short_window_percent_left,
204
+ short_window.resets_at if short_window else None,
205
+ FULL_PERCENT - weekly_window.used_percent if weekly_window else None,
206
+ weekly_window.resets_at if weekly_window else None,
207
+ )
208
+
209
+
210
+ all_product_adapters = {
211
+ Product.CLAUDE: ProductAdapter(load_claude_accounts, read_claude_meters, CLAUDE_CONFIG_DIR_ENV_VAR, ALL_USAGE_LIMIT_SIGNATURES, True),
212
+ Product.CODEX: ProductAdapter(load_codex_accounts, read_codex_account_meters, CODEX_HOME_ENVIRONMENT_VARIABLE, codex_usage_limit_signatures(), False),
213
+ }
214
+
215
+
216
+ def broker_state_path() -> Path:
217
+ return Path.home() / MAIN_CLAUDE_HOME_DIRECTORY_NAME / BROKER_STATE_DIRECTORY_NAME / BROKER_STATE_FILE_NAME
218
+
219
+
220
+ def _load_state(path: Path) -> dict[str, object]:
221
+ if not path.exists():
222
+ return {"meters": {}, "spent": {}, "affinity": {}}
223
+ try:
224
+ parsed_state = json.loads(path.read_text(encoding="utf-8"))
225
+ except (OSError, UnicodeError, json.JSONDecodeError):
226
+ return {"meters": {}, "spent": {}, "affinity": {}}
227
+ if not isinstance(parsed_state, dict):
228
+ return {"meters": {}, "spent": {}, "affinity": {}}
229
+ return {key: parsed_state.get(key) if isinstance(parsed_state.get(key), dict) else {} for key in ("meters", "spent", "affinity")}
230
+
231
+
232
+ if sys.platform == "win32":
233
+
234
+ def _acquire_state_lock(lock_descriptor: int) -> None:
235
+ os.lseek(lock_descriptor, 0, os.SEEK_SET)
236
+ msvcrt.locking(lock_descriptor, msvcrt.LK_LOCK, 1)
237
+
238
+ def _release_state_lock(lock_descriptor: int) -> None:
239
+ os.lseek(lock_descriptor, 0, os.SEEK_SET)
240
+ msvcrt.locking(lock_descriptor, msvcrt.LK_UNLCK, 1)
241
+
242
+ else:
243
+
244
+ def _acquire_state_lock(lock_descriptor: int) -> None:
245
+ fcntl.flock(lock_descriptor, fcntl.LOCK_EX)
246
+
247
+ def _release_state_lock(lock_descriptor: int) -> None:
248
+ fcntl.flock(lock_descriptor, fcntl.LOCK_UN)
249
+
250
+
251
+ @contextmanager
252
+ def _state_lock(path: Path) -> Iterator[None]:
253
+ """Hold an exclusive operating-system lock on the state file's sibling lock file."""
254
+ lock_descriptor = os.open(path.with_name(path.name + BROKER_STATE_LOCK_SUFFIX), os.O_CREAT | os.O_RDWR)
255
+ try:
256
+ _acquire_state_lock(lock_descriptor)
257
+ try:
258
+ yield
259
+ finally:
260
+ _release_state_lock(lock_descriptor)
261
+ finally:
262
+ os.close(lock_descriptor)
263
+
264
+
265
+ def _read_at(entry: object) -> float:
266
+ if not isinstance(entry, dict):
267
+ return float("-inf")
268
+ read_at = entry.get("read_at")
269
+ return float(read_at) if isinstance(read_at, (int, float)) else float("-inf")
270
+
271
+
272
+ def _merged_state(all_saved_state: dict[str, object], all_unsaved_state: dict[str, object]) -> dict[str, object]:
273
+ """Combine two state documents so each writer keeps the other writer's entries.
274
+
275
+ A spent mark keeps its latest reset, a meter entry keeps its newest read,
276
+ and the unsaved session bindings take their keys.
277
+ """
278
+ all_meters = dict(all_saved_state["meters"])
279
+ for each_key, each_entry in all_unsaved_state["meters"].items():
280
+ if _read_at(each_entry) >= _read_at(all_meters.get(each_key)):
281
+ all_meters[each_key] = each_entry
282
+ all_spent = dict(all_saved_state["spent"])
283
+ for each_key, each_reset in all_unsaved_state["spent"].items():
284
+ saved_reset = all_spent.get(each_key)
285
+ if not isinstance(saved_reset, (int, float)) or (isinstance(each_reset, (int, float)) and each_reset > saved_reset):
286
+ all_spent[each_key] = each_reset
287
+ return {"meters": all_meters, "spent": all_spent, "affinity": {**all_saved_state["affinity"], **all_unsaved_state["affinity"]}}
288
+
289
+
290
+ def _save_state(path: Path, all_state: dict[str, object]) -> None:
291
+ """Merge the caller's state into the saved file under a lock, then refresh the caller's copy."""
292
+ path.parent.mkdir(parents=True, exist_ok=True)
293
+ with _state_lock(path):
294
+ all_state.update(_merged_state(_load_state(path), all_state))
295
+ temporary_name: str | None = None
296
+ try:
297
+ with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=path.parent, prefix="state-", suffix=BROKER_STATE_TEMP_SUFFIX, delete=False, newline="\n") as stream:
298
+ temporary_name = stream.name
299
+ json.dump(all_state, stream, indent=REPORT_INDENT_SPACES, sort_keys=True)
300
+ stream.write("\n")
301
+ os.replace(temporary_name, path)
302
+ finally:
303
+ if temporary_name is not None and os.path.exists(temporary_name):
304
+ os.unlink(temporary_name)
305
+
306
+
307
+ def _state_key(account: Account) -> str:
308
+ return f"{account.product.value}:{account.name}:{account.home}"
309
+
310
+
311
+ def _meter_payload(meters: Meters | None) -> dict[str, object] | None:
312
+ if meters is None:
313
+ return None
314
+ return {
315
+ "session_percent_left": meters.session_percent_left,
316
+ "session_resets_at": utc_time_text(meters.session_resets_at),
317
+ "weekly_percent_left": meters.weekly_percent_left,
318
+ "weekly_resets_at": utc_time_text(meters.weekly_resets_at),
319
+ }
320
+
321
+
322
+ def _meters_from_payload(raw_meters: object) -> Meters | None:
323
+ if not isinstance(raw_meters, dict):
324
+ return None
325
+ all_percentages = (raw_meters.get("session_percent_left"), raw_meters.get("weekly_percent_left"))
326
+ if any(each_percent is not None and not isinstance(each_percent, (int, float)) for each_percent in all_percentages):
327
+ return None
328
+ return Meters(all_percentages[0], parse_utc_time(raw_meters.get("session_resets_at")), all_percentages[1], parse_utc_time(raw_meters.get("weekly_resets_at")))
329
+
330
+
331
+ def _read_one_account(account: Account, adapter: ProductAdapter, all_cache: dict[str, object], now: datetime) -> tuple[Reading, bool]:
332
+ key = _state_key(account)
333
+ cached = all_cache.get(key)
334
+ if isinstance(cached, dict) and isinstance(cached.get("read_at"), (int, float)) and 0 <= now.timestamp() - cached["read_at"] < 60:
335
+ return Reading(account, _meters_from_payload(cached.get("meters"))), False
336
+ try:
337
+ meters = adapter.read_meters(account)
338
+ except (OSError, ValueError, RuntimeError, subprocess.SubprocessError):
339
+ meters = None
340
+ all_cache[key] = {"read_at": now.timestamp(), "meters": _meter_payload(meters)}
341
+ return Reading(account, meters), True
342
+
343
+
344
+ def read_accounts(product: Product, adapter: ProductAdapter | None = None, *, all_state: dict[str, object] | None = None, now: datetime | None = None) -> tuple[Reading, ...]:
345
+ """Read account meters with a 60-second cache.
346
+
347
+ Args:
348
+ product: Product whose accounts are read.
349
+ adapter: Injected account and meter readers.
350
+ all_state: Mutable broker state document.
351
+ now: Clock instant used for cache age.
352
+
353
+ Returns:
354
+ Account readings in roster order.
355
+ """
356
+ active = adapter or all_product_adapters[product]
357
+ current = now or datetime.now(timezone.utc)
358
+ all_cache = all_state.get("meters", {}) if all_state is not None else {}
359
+ all_readings: list[Reading] = []
360
+ is_updated = False
361
+ for each_account in active.load_accounts():
362
+ reading, did_read = _read_one_account(each_account, active, all_cache, current)
363
+ all_readings.append(reading)
364
+ is_updated = is_updated or did_read
365
+ if is_updated and all_state is not None:
366
+ _save_state(broker_state_path(), all_state)
367
+ return tuple(all_readings)
368
+
369
+
370
+ def _session_id_from_json(value: str) -> str | None:
371
+ try:
372
+ payload = json.loads(value)
373
+ except json.JSONDecodeError:
374
+ return None
375
+ session_id = payload.get("session_id") if isinstance(payload, dict) else None
376
+ return session_id if isinstance(session_id, str) and session_id else None
377
+
378
+
379
+ def extract_session_id_from_stdout(stdout: str) -> str | None:
380
+ """Find a session id in JSON or newline-delimited JSON.
381
+
382
+ Args:
383
+ stdout: Captured command output.
384
+
385
+ Returns:
386
+ Session id, if present.
387
+ """
388
+ return _session_id_from_json(stdout.strip()) or next(
389
+ (found for line in stdout.splitlines() if (found := _session_id_from_json(line.strip())) is not None),
390
+ None,
391
+ )
392
+
393
+
394
+ def _resume_id(all_argv: Sequence[str]) -> str | None:
395
+ for each_index, each_argument in enumerate(all_argv):
396
+ if each_argument == "--resume" and each_index + 1 < len(all_argv):
397
+ candidate = all_argv[each_index + 1]
398
+ return candidate if candidate and not candidate.startswith("-") else None
399
+ return None
400
+
401
+
402
+ def _run_captured_subprocess(all_argv: Sequence[str], **options: object) -> subprocess.CompletedProcess[str]:
403
+ encoding = str(options.get("encoding") or "utf-8")
404
+ errors = str(options.get("errors") or "replace")
405
+ stdin_bytes = options.get("input")
406
+ with tempfile.TemporaryFile() as stdout_file, tempfile.TemporaryFile() as stderr_file:
407
+ completion = subprocess.run(
408
+ list(all_argv),
409
+ input=stdin_bytes,
410
+ stdout=stdout_file,
411
+ stderr=stderr_file,
412
+ env=options.get("env"),
413
+ cwd=options.get("cwd"),
414
+ timeout=options.get("timeout"),
415
+ check=False,
416
+ )
417
+ stdout_file.seek(0)
418
+ stderr_file.seek(0)
419
+ stdout = stdout_file.read().decode(encoding, errors).replace("\r\n", "\n").replace("\r", "\n")
420
+ stderr = stderr_file.read().decode(encoding, errors).replace("\r\n", "\n").replace("\r", "\n")
421
+ return subprocess.CompletedProcess(list(all_argv), completion.returncode, stdout, stderr)
422
+
423
+
424
+ subprocess_runner: SubprocessRunner = _run_captured_subprocess
425
+ _subprocess_runner_lock = threading.Lock()
426
+
427
+
428
+ @contextmanager
429
+ def override_subprocess_runner(runner: SubprocessRunner) -> Iterator[SubprocessRunner]:
430
+ """Replace the captured subprocess runner within a context.
431
+
432
+ Args:
433
+ runner: Replacement runner.
434
+
435
+ Yields:
436
+ Runner that was active before replacement.
437
+ """
438
+ global subprocess_runner
439
+ with _subprocess_runner_lock:
440
+ previous = subprocess_runner
441
+ subprocess_runner = runner
442
+ try:
443
+ yield previous
444
+ finally:
445
+ subprocess_runner = previous