claude-dev-env 8.43.4 → 8.43.6
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.
- package/_shared/advisor/advisor-protocol.md +2 -2
- package/_shared/advisor/reference/cli-chain.md +9 -12
- package/_shared/advisor/reference/lifecycle.md +1 -1
- package/_shared/advisor/reference/third-party-bind.md +3 -3
- package/bin/install.mjs +39 -3
- package/bin/install.shared-settings.test.mjs +46 -0
- package/bin/install.test.mjs +1 -1
- package/docs/second-claude-account.md +11 -13
- package/package.json +1 -1
- package/scripts/_code_review_test_support.py +29 -39
- package/scripts/claude_account_worker.py +46 -297
- package/scripts/claude_account_worker_process.py +18 -154
- package/scripts/claude_account_worker_report.py +8 -4
- package/scripts/claude_chain_usage.py +7 -271
- package/scripts/codec_forwarding_test_support.py +11 -65
- package/scripts/dev_env_scripts_constants/claude_account_worker_constants.py +6 -24
- package/scripts/invoke_code_review.py +34 -56
- package/scripts/policy_lint/config/approved_test_pairs.py +1 -0
- package/scripts/resolve_worker_spawn.py +40 -72
- package/scripts/test_account_broker_guard.py +0 -4
- package/scripts/test_claude_account_worker.py +105 -350
- package/scripts/test_claude_chain_usage.py +50 -510
- package/scripts/test_dispatcher_profile_import.py +5 -9
- package/scripts/test_invoke_code_review.py +7 -6
- package/scripts/test_invoke_code_review_chain.py +26 -0
- package/scripts/test_invoke_code_review_cli.py +8 -8
- package/scripts/test_invoke_code_review_codec.py +3 -11
- package/scripts/test_invoke_code_review_contract.py +5 -1
- package/scripts/test_resolve_worker_spawn.py +118 -265
- package/scripts/test_resolve_worker_spawn_codec.py +9 -21
- package/scripts/tests/test_code_review_constants.py +2 -2
- package/scripts/tests/test_policy_lint_rules_pairing.py +13 -0
- package/scripts/claude_account_choice.py +0 -422
- package/scripts/claude_chain_runner.py +0 -1289
- package/scripts/test_claude_account_choice.py +0 -355
- package/scripts/test_claude_chain_runner.py +0 -1839
|
@@ -1,1289 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Run a ``claude`` invocation through a fallback chain of account binaries.
|
|
3
|
-
|
|
4
|
-
An automation that shells out to a single ``claude -p ...`` fails outright when
|
|
5
|
-
that account hits a usage limit. Other logged-in installs sit idle meanwhile.
|
|
6
|
-
By default this module probes remaining weekly usage once per call, ranks chain
|
|
7
|
-
accounts highest remaining first, and tries that order. It falls over to the
|
|
8
|
-
next ranked binary only on a usage-limit failure. Every other outcome returns
|
|
9
|
-
to the caller unchanged.
|
|
10
|
-
|
|
11
|
-
Ordered-account mode (``--routing-mode ordered_account``) walks the chain in
|
|
12
|
-
config order instead, still falling over only on a usage-limit signature.
|
|
13
|
-
Authentication, timeout, and other non-usage failures stop immediately with
|
|
14
|
-
``terminal_status=advisor_blocked``.
|
|
15
|
-
|
|
16
|
-
The chain lives in ``~/.claude/claude-chain.json``. Copy the committed
|
|
17
|
-
``claude-chain.example.json`` template there and list your account binaries.
|
|
18
|
-
Default try order comes from weekly remaining via ``claude_chain_usage``
|
|
19
|
-
(usage-pause OAuth probe), not from list position alone::
|
|
20
|
-
|
|
21
|
-
{"chain": [{"command": "claude", "extra_args": []},
|
|
22
|
-
{"command": "claude-profile-c", "extra_args": []}]}
|
|
23
|
-
|
|
24
|
-
A usage-limited first try falls over to the next ranked binary::
|
|
25
|
-
|
|
26
|
-
first try (highest remaining) -> exit 1, "usage limit reached" (falls over)
|
|
27
|
-
next ranked binary -> exit 0 (served)
|
|
28
|
-
|
|
29
|
-
When stdin is piped (not a TTY), the runner reads it once and forwards the
|
|
30
|
-
same text to every chain attempt so a piped ``-p`` charter body reaches each
|
|
31
|
-
binary in the walk::
|
|
32
|
-
|
|
33
|
-
cat charter.md | python claude_chain_runner.py -- -p --strict-mcp-config
|
|
34
|
-
|
|
35
|
-
Import ``run_claude`` for the outcome object, or run the module as a CLI::
|
|
36
|
-
|
|
37
|
-
python claude_chain_runner.py [--timeout-seconds N]
|
|
38
|
-
[--routing-mode usage_ranked|ordered_account] -- <claude args...>
|
|
39
|
-
"""
|
|
40
|
-
|
|
41
|
-
from __future__ import annotations
|
|
42
|
-
|
|
43
|
-
import argparse
|
|
44
|
-
import importlib
|
|
45
|
-
import io
|
|
46
|
-
import json
|
|
47
|
-
import os
|
|
48
|
-
import subprocess
|
|
49
|
-
import sys
|
|
50
|
-
import tempfile
|
|
51
|
-
import threading
|
|
52
|
-
from collections.abc import Callable, Iterator, Sequence
|
|
53
|
-
from contextlib import contextmanager
|
|
54
|
-
from dataclasses import dataclass, field
|
|
55
|
-
from pathlib import Path
|
|
56
|
-
from types import ModuleType
|
|
57
|
-
from typing import Protocol, TextIO
|
|
58
|
-
|
|
59
|
-
if __name__ == "__main__":
|
|
60
|
-
sys.modules.setdefault("claude_chain_runner", sys.modules[__name__])
|
|
61
|
-
|
|
62
|
-
from dev_env_scripts_constants.claude_chain_constants import (
|
|
63
|
-
AFFINITY_BINDING_COMMAND_MISSING_REASON,
|
|
64
|
-
AFFINITY_BINDING_NOT_OBJECT_REASON,
|
|
65
|
-
AFFINITY_BINDING_SESSION_ID_MISSING_REASON,
|
|
66
|
-
AFFINITY_BINDINGS_MISSING_OR_NOT_LIST_REASON,
|
|
67
|
-
AFFINITY_CORRUPT_MESSAGE_TEMPLATE,
|
|
68
|
-
AFFINITY_JSON_INDENT_SPACES,
|
|
69
|
-
AFFINITY_KEY_ALL_BINDINGS,
|
|
70
|
-
AFFINITY_KEY_COMMAND,
|
|
71
|
-
AFFINITY_KEY_SCHEMA_VERSION,
|
|
72
|
-
AFFINITY_KEY_SESSION_ID,
|
|
73
|
-
AFFINITY_MAXIMUM_ENTRIES,
|
|
74
|
-
AFFINITY_MAXIMUM_ENTRIES_MINIMUM_MESSAGE,
|
|
75
|
-
AFFINITY_SESSION_ID_AND_COMMAND_REQUIRED_MESSAGE,
|
|
76
|
-
AFFINITY_STATE_FILENAME,
|
|
77
|
-
AFFINITY_STATE_SCHEMA_VERSION,
|
|
78
|
-
AFFINITY_TEMP_SUFFIX,
|
|
79
|
-
AFFINITY_TOP_LEVEL_NOT_OBJECT_REASON,
|
|
80
|
-
AFFINITY_UNSUPPORTED_SCHEMA_VERSION_REASON_TEMPLATE,
|
|
81
|
-
AFFINITY_WRITE_FAILED_MESSAGE_TEMPLATE,
|
|
82
|
-
ALL_ROUTING_MODES,
|
|
83
|
-
RESUME_SESSION_FLAG,
|
|
84
|
-
ALL_USAGE_LIMIT_SIGNATURES,
|
|
85
|
-
ATTEMPT_STATUS_EXECUTABLE_NOT_FOUND,
|
|
86
|
-
ATTEMPT_STATUS_NONZERO_EXIT,
|
|
87
|
-
ATTEMPT_STATUS_SERVED,
|
|
88
|
-
ATTEMPT_STATUS_TIMEOUT,
|
|
89
|
-
ATTEMPT_STATUS_USAGE_LIMITED,
|
|
90
|
-
ATTEMPT_SUMMARY_ENTRY_TEMPLATE,
|
|
91
|
-
ATTEMPT_SUMMARY_JOIN_SEPARATOR,
|
|
92
|
-
CARRIAGE_RETURN,
|
|
93
|
-
CHAIN_ADVISOR_BLOCKED_EXIT_CODE,
|
|
94
|
-
CHAIN_CONFIG_ERROR_EXIT_CODE,
|
|
95
|
-
CHAIN_EXHAUSTED_EXIT_CODE,
|
|
96
|
-
CHAIN_EXHAUSTED_MESSAGE_TEMPLATE,
|
|
97
|
-
CHAIN_USAGE_MODULE_NAME,
|
|
98
|
-
CLAUDE_HOME_SUBDIRECTORY,
|
|
99
|
-
CLI_ARGUMENTS_SEPARATOR,
|
|
100
|
-
CLI_ROUTING_MODE_FLAG,
|
|
101
|
-
CLI_TIMEOUT_FLAG,
|
|
102
|
-
CODEC_ERROR_STRATEGY,
|
|
103
|
-
CONFIG_CHAIN_EMPTY_REASON,
|
|
104
|
-
CONFIG_CHAIN_KEY,
|
|
105
|
-
CONFIG_CHAIN_NOT_LIST_REASON,
|
|
106
|
-
CONFIG_COMMAND_KEY,
|
|
107
|
-
CONFIG_CREDENTIALS_PATH_KEY,
|
|
108
|
-
CONFIG_ENTRY_COMMAND_MISSING_REASON,
|
|
109
|
-
CONFIG_ENTRY_CREDENTIALS_PATH_INVALID_REASON,
|
|
110
|
-
CONFIG_ENTRY_EXTRA_ARGS_INVALID_REASON,
|
|
111
|
-
CONFIG_ENTRY_NOT_OBJECT_REASON,
|
|
112
|
-
CONFIG_EXTRA_ARGS_KEY,
|
|
113
|
-
CONFIG_FILENAME,
|
|
114
|
-
CONFIG_INVALID_SHAPE_MESSAGE_TEMPLATE,
|
|
115
|
-
CONFIG_MALFORMED_MESSAGE_TEMPLATE,
|
|
116
|
-
CONFIG_MISSING_MESSAGE_TEMPLATE,
|
|
117
|
-
CONFIG_NOT_OBJECT_REASON,
|
|
118
|
-
CONFIG_UNREADABLE_MESSAGE_TEMPLATE,
|
|
119
|
-
CRLF_NEWLINE,
|
|
120
|
-
DEFAULT_ROUTING_MODE,
|
|
121
|
-
DEFAULT_TIMEOUT_SECONDS,
|
|
122
|
-
EXAMPLE_CONFIG_FILENAME,
|
|
123
|
-
LINE_FEED,
|
|
124
|
-
NO_COMPLETED_PROCESS_RETURN_CODE,
|
|
125
|
-
ROUTING_MODE_ORDERED_ACCOUNT,
|
|
126
|
-
SESSION_ID_JSON_KEY,
|
|
127
|
-
TERMINAL_STATUS_ADVISOR_BLOCKED,
|
|
128
|
-
TERMINAL_STATUS_CHAIN_EXHAUSTED,
|
|
129
|
-
TERMINAL_STATUS_SERVED,
|
|
130
|
-
TERMINAL_STATUS_TIMEOUT,
|
|
131
|
-
UTF8_ENCODING,
|
|
132
|
-
)
|
|
133
|
-
|
|
134
|
-
from subprocess_window_access import hidden_window_creation_flags
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
def _decode_captured_stream(raw_bytes: bytes, encoding: str, errors: str) -> str:
|
|
138
|
-
"""Decode captured *raw_bytes* with ``text=True`` universal-newline semantics.
|
|
139
|
-
|
|
140
|
-
Spool capture writes binary temp files, so a bare ``.decode`` leaves CRLF and
|
|
141
|
-
bare CR intact. ``subprocess.run(..., text=True)`` normalized those to LF;
|
|
142
|
-
this helper restores that contract for Windows children that emit ``\\r\\n``.
|
|
143
|
-
"""
|
|
144
|
-
decoded_text = raw_bytes.decode(encoding, errors)
|
|
145
|
-
return decoded_text.replace(CRLF_NEWLINE, LINE_FEED).replace(
|
|
146
|
-
CARRIAGE_RETURN, LINE_FEED
|
|
147
|
-
)
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
class _SpooledByteStream(Protocol):
|
|
151
|
-
"""Binary spool with seek/read — TemporaryFile wrappers and BufferedIO."""
|
|
152
|
-
|
|
153
|
-
def seek(self, target: int, whence: int = 0, /) -> int: ...
|
|
154
|
-
|
|
155
|
-
def read(self, size: int | None = -1, /) -> bytes: ...
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
def _decoded_spooled_streams(
|
|
159
|
-
stdout_file: _SpooledByteStream,
|
|
160
|
-
stderr_file: _SpooledByteStream,
|
|
161
|
-
encoding: str,
|
|
162
|
-
errors: str,
|
|
163
|
-
) -> tuple[str, str]:
|
|
164
|
-
"""Seek both spool files to the start and decode their full contents."""
|
|
165
|
-
stdout_file.seek(0)
|
|
166
|
-
stderr_file.seek(0)
|
|
167
|
-
return (
|
|
168
|
-
_decode_captured_stream(stdout_file.read(), encoding, errors),
|
|
169
|
-
_decode_captured_stream(stderr_file.read(), encoding, errors),
|
|
170
|
-
)
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
def _attach_partial_timeout_streams(
|
|
174
|
-
timeout_error: subprocess.TimeoutExpired,
|
|
175
|
-
stdout_file: _SpooledByteStream,
|
|
176
|
-
stderr_file: _SpooledByteStream,
|
|
177
|
-
encoding: str,
|
|
178
|
-
errors: str,
|
|
179
|
-
) -> None:
|
|
180
|
-
"""Decode partial spool contents onto *timeout_error* before re-raise."""
|
|
181
|
-
captured_stdout, captured_stderr = _decoded_spooled_streams(
|
|
182
|
-
stdout_file, stderr_file, encoding, errors
|
|
183
|
-
)
|
|
184
|
-
timeout_error.stdout = captured_stdout
|
|
185
|
-
timeout_error.stderr = captured_stderr
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
# Capturing a large-output child through OS pipes (``capture_output=True``)
|
|
189
|
-
# deadlocks on Windows: the child buffers its whole response and flushes it at
|
|
190
|
-
# once, the pipe buffer fills before the parent drains it, and both sides block.
|
|
191
|
-
# Redirecting each stream to a temporary file removes the pipe, so the child
|
|
192
|
-
# writes freely; the files are then read back and decoded the way a pipe capture
|
|
193
|
-
# would. ``capture_output``, ``text``, and ``env`` are ignored in favor of file
|
|
194
|
-
# redirection and the parent environment; ``timeout``, ``check``, ``cwd``,
|
|
195
|
-
# ``stdin``, ``input``, ``encoding``, and ``errors`` are honored. On timeout,
|
|
196
|
-
# partial stdout/stderr are decoded from the temp files and attached to the
|
|
197
|
-
# raised ``TimeoutExpired``. When ``check=True`` and the child exits non-zero,
|
|
198
|
-
# ``CalledProcessError.stdout`` / ``.stderr`` stay unset (temp files are not
|
|
199
|
-
# attached to that raised error).
|
|
200
|
-
def _run_captured_subprocess(
|
|
201
|
-
all_invocation_tokens: list[str],
|
|
202
|
-
**all_subprocess_options: object,
|
|
203
|
-
) -> subprocess.CompletedProcess[str]:
|
|
204
|
-
"""Run *all_invocation_tokens*, spooling stdout and stderr to temp files."""
|
|
205
|
-
encoding = str(all_subprocess_options.get("encoding") or UTF8_ENCODING)
|
|
206
|
-
errors = str(all_subprocess_options.get("errors") or CODEC_ERROR_STRATEGY)
|
|
207
|
-
input_bytes = _captured_stdin_bytes(all_subprocess_options, encoding, errors)
|
|
208
|
-
working_directory = all_subprocess_options.get("cwd")
|
|
209
|
-
timeout_seconds = all_subprocess_options.get("timeout")
|
|
210
|
-
with (
|
|
211
|
-
tempfile.TemporaryFile() as stdout_file,
|
|
212
|
-
tempfile.TemporaryFile() as stderr_file,
|
|
213
|
-
):
|
|
214
|
-
try:
|
|
215
|
-
completion = subprocess.run(
|
|
216
|
-
all_invocation_tokens,
|
|
217
|
-
stdout=stdout_file,
|
|
218
|
-
stderr=stderr_file,
|
|
219
|
-
cwd=working_directory if isinstance(working_directory, str) else None,
|
|
220
|
-
check=bool(all_subprocess_options.get("check", False)),
|
|
221
|
-
timeout=(
|
|
222
|
-
float(timeout_seconds)
|
|
223
|
-
if isinstance(timeout_seconds, (int, float))
|
|
224
|
-
else None
|
|
225
|
-
),
|
|
226
|
-
input=input_bytes,
|
|
227
|
-
creationflags=hidden_window_creation_flags(),
|
|
228
|
-
)
|
|
229
|
-
except subprocess.TimeoutExpired as timeout_error:
|
|
230
|
-
_attach_partial_timeout_streams(
|
|
231
|
-
timeout_error, stdout_file, stderr_file, encoding, errors
|
|
232
|
-
)
|
|
233
|
-
raise
|
|
234
|
-
captured_stdout, captured_stderr = _decoded_spooled_streams(
|
|
235
|
-
stdout_file, stderr_file, encoding, errors
|
|
236
|
-
)
|
|
237
|
-
return subprocess.CompletedProcess(
|
|
238
|
-
all_invocation_tokens,
|
|
239
|
-
completion.returncode,
|
|
240
|
-
captured_stdout,
|
|
241
|
-
captured_stderr,
|
|
242
|
-
)
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
def _captured_stdin_bytes(
|
|
246
|
-
all_subprocess_options: dict[str, object], encoding: str, errors: str
|
|
247
|
-
) -> bytes | None:
|
|
248
|
-
"""Return the bytes to feed the child's stdin for a spooled run.
|
|
249
|
-
|
|
250
|
-
::
|
|
251
|
-
|
|
252
|
-
stdin=<open prompt file> -> the file's bytes
|
|
253
|
-
stdin=subprocess.DEVNULL -> b"" (an immediate EOF)
|
|
254
|
-
input="charter text" -> the encoded text
|
|
255
|
-
|
|
256
|
-
A wrapper hands the runner a ``stdin`` stream or a ``DEVNULL`` sentinel; the
|
|
257
|
-
spooled run reads from an ``input`` pipe rather than the caller's handle, so
|
|
258
|
-
the stream is read into bytes here to deliver the same stdin a direct pipe
|
|
259
|
-
would. When no ``stdin`` is given, the ``input`` text is encoded instead.
|
|
260
|
-
"""
|
|
261
|
-
stdin_source = all_subprocess_options.get("stdin")
|
|
262
|
-
if isinstance(stdin_source, io.TextIOBase):
|
|
263
|
-
return stdin_source.read().encode(encoding, errors)
|
|
264
|
-
if isinstance(stdin_source, (io.RawIOBase, io.BufferedIOBase)):
|
|
265
|
-
return stdin_source.read() or b""
|
|
266
|
-
if isinstance(stdin_source, int):
|
|
267
|
-
return b""
|
|
268
|
-
input_text = all_subprocess_options.get("input")
|
|
269
|
-
if input_text is None:
|
|
270
|
-
return None
|
|
271
|
-
return str(input_text).encode(encoding, errors)
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
class ChainConfigurationError(Exception):
|
|
275
|
-
"""Raised when the chain configuration is missing, unreadable, or malformed."""
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
@dataclass(frozen=True)
|
|
279
|
-
class ChainEntry:
|
|
280
|
-
"""One binary in the fallback chain and its per-account extra arguments.
|
|
281
|
-
|
|
282
|
-
``credentials_path`` is an optional path to that account's OAuth credentials
|
|
283
|
-
file. The subprocess walk does not pass it; weekly-usage ranking reads it
|
|
284
|
-
when present.
|
|
285
|
-
"""
|
|
286
|
-
|
|
287
|
-
command: str
|
|
288
|
-
extra_args: tuple[str, ...]
|
|
289
|
-
credentials_path: str | None = None
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
@dataclass(frozen=True)
|
|
293
|
-
class ChainAttempt:
|
|
294
|
-
"""Record of one binary invocation and how it resolved."""
|
|
295
|
-
|
|
296
|
-
command: str
|
|
297
|
-
status: str
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
@dataclass(frozen=True)
|
|
301
|
-
class ChainInvocationOutcome:
|
|
302
|
-
"""Outcome of one chain walk: who served, how it ended, optional session id.
|
|
303
|
-
|
|
304
|
-
::
|
|
305
|
-
|
|
306
|
-
zero exit with JSON session_id
|
|
307
|
-
-> served_command set, terminal_status=served, session_id filled
|
|
308
|
-
ordered_account auth/timeout/generic process error
|
|
309
|
-
-> served_command=None, terminal_status=advisor_blocked
|
|
310
|
-
usage_ranked TimeoutExpired mid-walk
|
|
311
|
-
-> served_command=None, terminal_status=timeout
|
|
312
|
-
every entry usage-limited or missing
|
|
313
|
-
-> served_command=None, terminal_status=chain_exhausted
|
|
314
|
-
|
|
315
|
-
``attempts`` lists every binary tried. Callers resume later consults with
|
|
316
|
-
``session_id`` when the bind returned one.
|
|
317
|
-
"""
|
|
318
|
-
|
|
319
|
-
served_command: str | None
|
|
320
|
-
returncode: int
|
|
321
|
-
stdout: str
|
|
322
|
-
stderr: str
|
|
323
|
-
attempts: tuple[ChainAttempt, ...]
|
|
324
|
-
terminal_status: str
|
|
325
|
-
session_id: str | None = None
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
class WeeklyUsageAccountReport(Protocol):
|
|
329
|
-
"""Minimal account-report surface the runner needs for ranking and mapping."""
|
|
330
|
-
|
|
331
|
-
command: str
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
chain_subprocess_runner = _run_captured_subprocess
|
|
335
|
-
_shared_chain_subprocess_lock = threading.Lock()
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
def chain_subprocess_runner_lock() -> threading.Lock:
|
|
339
|
-
"""Return the lock for adapters that temporarily configure the runner."""
|
|
340
|
-
return _shared_chain_subprocess_lock
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
@contextmanager
|
|
344
|
-
def override_chain_subprocess_runner(
|
|
345
|
-
replacement_runner: Callable[..., subprocess.CompletedProcess[str]],
|
|
346
|
-
) -> Iterator[Callable[..., subprocess.CompletedProcess[str]]]:
|
|
347
|
-
"""Temporarily replace the chain subprocess seam under its shared lock.
|
|
348
|
-
|
|
349
|
-
Args:
|
|
350
|
-
replacement_runner: Callable used for subprocess invocations during the
|
|
351
|
-
context.
|
|
352
|
-
|
|
353
|
-
Yields:
|
|
354
|
-
The runner that was active before the replacement.
|
|
355
|
-
"""
|
|
356
|
-
global chain_subprocess_runner
|
|
357
|
-
with chain_subprocess_runner_lock():
|
|
358
|
-
previous_runner = chain_subprocess_runner
|
|
359
|
-
chain_subprocess_runner = replacement_runner
|
|
360
|
-
try:
|
|
361
|
-
yield previous_runner
|
|
362
|
-
finally:
|
|
363
|
-
chain_subprocess_runner = previous_runner
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
def _load_chain_usage_module() -> ModuleType:
|
|
367
|
-
return importlib.import_module(CHAIN_USAGE_MODULE_NAME)
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
def _default_chain_weekly_usage_reporter(
|
|
371
|
-
*, config_path: Path
|
|
372
|
-
) -> list[WeeklyUsageAccountReport]:
|
|
373
|
-
usage_module = _load_chain_usage_module()
|
|
374
|
-
return usage_module.report_chain_weekly_usage(config_path=config_path)
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
chain_weekly_usage_reporter: Callable[..., list[WeeklyUsageAccountReport]] = (
|
|
378
|
-
_default_chain_weekly_usage_reporter
|
|
379
|
-
)
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
def chain_config_path() -> Path:
|
|
383
|
-
"""Return the path to the per-user chain configuration file."""
|
|
384
|
-
return Path.home() / CLAUDE_HOME_SUBDIRECTORY / CONFIG_FILENAME
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
def _invalid_shape_error(config_path: Path, reason: str) -> ChainConfigurationError:
|
|
388
|
-
return ChainConfigurationError(
|
|
389
|
-
CONFIG_INVALID_SHAPE_MESSAGE_TEMPLATE.format(
|
|
390
|
-
config_path=config_path,
|
|
391
|
-
reason=reason,
|
|
392
|
-
example_filename=EXAMPLE_CONFIG_FILENAME,
|
|
393
|
-
)
|
|
394
|
-
)
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
def _coerce_extra_args(raw_extra_args: object, config_path: Path) -> tuple[str, ...]:
|
|
398
|
-
if not isinstance(raw_extra_args, list) or not all(
|
|
399
|
-
isinstance(each_argument, str) for each_argument in raw_extra_args
|
|
400
|
-
):
|
|
401
|
-
raise _invalid_shape_error(config_path, CONFIG_ENTRY_EXTRA_ARGS_INVALID_REASON)
|
|
402
|
-
return tuple(raw_extra_args)
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
def _coerce_credentials_path(
|
|
406
|
-
raw_credentials_path: object, config_path: Path
|
|
407
|
-
) -> str | None:
|
|
408
|
-
if raw_credentials_path is None:
|
|
409
|
-
return None
|
|
410
|
-
if not isinstance(raw_credentials_path, str) or not raw_credentials_path:
|
|
411
|
-
raise _invalid_shape_error(
|
|
412
|
-
config_path, CONFIG_ENTRY_CREDENTIALS_PATH_INVALID_REASON
|
|
413
|
-
)
|
|
414
|
-
return raw_credentials_path
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
def _parse_chain_entry(raw_entry: object, config_path: Path) -> ChainEntry:
|
|
418
|
-
if not isinstance(raw_entry, dict):
|
|
419
|
-
raise _invalid_shape_error(config_path, CONFIG_ENTRY_NOT_OBJECT_REASON)
|
|
420
|
-
command = raw_entry.get(CONFIG_COMMAND_KEY)
|
|
421
|
-
if not isinstance(command, str) or not command:
|
|
422
|
-
raise _invalid_shape_error(config_path, CONFIG_ENTRY_COMMAND_MISSING_REASON)
|
|
423
|
-
extra_args = _coerce_extra_args(
|
|
424
|
-
raw_entry.get(CONFIG_EXTRA_ARGS_KEY, []), config_path
|
|
425
|
-
)
|
|
426
|
-
credentials_path = _coerce_credentials_path(
|
|
427
|
-
raw_entry.get(CONFIG_CREDENTIALS_PATH_KEY), config_path
|
|
428
|
-
)
|
|
429
|
-
return ChainEntry(
|
|
430
|
-
command=command,
|
|
431
|
-
extra_args=extra_args,
|
|
432
|
-
credentials_path=credentials_path,
|
|
433
|
-
)
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
def _parse_chain_entries(parsed_config: object, config_path: Path) -> list[ChainEntry]:
|
|
437
|
-
if not isinstance(parsed_config, dict):
|
|
438
|
-
raise _invalid_shape_error(config_path, CONFIG_NOT_OBJECT_REASON)
|
|
439
|
-
raw_chain = parsed_config.get(CONFIG_CHAIN_KEY)
|
|
440
|
-
if not isinstance(raw_chain, list):
|
|
441
|
-
raise _invalid_shape_error(config_path, CONFIG_CHAIN_NOT_LIST_REASON)
|
|
442
|
-
if not raw_chain:
|
|
443
|
-
raise _invalid_shape_error(config_path, CONFIG_CHAIN_EMPTY_REASON)
|
|
444
|
-
return [
|
|
445
|
-
_parse_chain_entry(each_raw_entry, config_path) for each_raw_entry in raw_chain
|
|
446
|
-
]
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
def load_chain(config_path: Path) -> list[ChainEntry]:
|
|
450
|
-
"""Load the ordered fallback chain from *config_path*.
|
|
451
|
-
|
|
452
|
-
Args:
|
|
453
|
-
config_path: Path to the chain configuration JSON file.
|
|
454
|
-
|
|
455
|
-
Returns:
|
|
456
|
-
The ordered list of chain entries the file declares.
|
|
457
|
-
|
|
458
|
-
Raises:
|
|
459
|
-
ChainConfigurationError: When the file is absent, unreadable, not valid
|
|
460
|
-
JSON, or does not match the expected shape.
|
|
461
|
-
"""
|
|
462
|
-
if not config_path.is_file():
|
|
463
|
-
raise ChainConfigurationError(
|
|
464
|
-
CONFIG_MISSING_MESSAGE_TEMPLATE.format(
|
|
465
|
-
config_path=config_path, example_filename=EXAMPLE_CONFIG_FILENAME
|
|
466
|
-
)
|
|
467
|
-
)
|
|
468
|
-
try:
|
|
469
|
-
raw_text = config_path.read_text(encoding=UTF8_ENCODING)
|
|
470
|
-
except OSError as read_error:
|
|
471
|
-
raise ChainConfigurationError(
|
|
472
|
-
CONFIG_UNREADABLE_MESSAGE_TEMPLATE.format(
|
|
473
|
-
config_path=config_path,
|
|
474
|
-
error=read_error,
|
|
475
|
-
example_filename=EXAMPLE_CONFIG_FILENAME,
|
|
476
|
-
)
|
|
477
|
-
) from read_error
|
|
478
|
-
try:
|
|
479
|
-
parsed_config = json.loads(raw_text)
|
|
480
|
-
except json.JSONDecodeError as decode_error:
|
|
481
|
-
raise ChainConfigurationError(
|
|
482
|
-
CONFIG_MALFORMED_MESSAGE_TEMPLATE.format(
|
|
483
|
-
config_path=config_path,
|
|
484
|
-
error=decode_error,
|
|
485
|
-
example_filename=EXAMPLE_CONFIG_FILENAME,
|
|
486
|
-
)
|
|
487
|
-
) from decode_error
|
|
488
|
-
return _parse_chain_entries(parsed_config, config_path)
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
def _build_invocation(entry: ChainEntry, all_claude_arguments: list[str]) -> list[str]:
|
|
492
|
-
return [entry.command, *all_claude_arguments, *entry.extra_args]
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
def _entries_ranked_by_weekly_remaining(
|
|
496
|
-
all_entries: list[ChainEntry],
|
|
497
|
-
all_usage_reports: Sequence[WeeklyUsageAccountReport],
|
|
498
|
-
) -> list[ChainEntry]:
|
|
499
|
-
usage_module = _load_chain_usage_module()
|
|
500
|
-
entries_by_command: dict[str, list[ChainEntry]] = {}
|
|
501
|
-
for each_entry in all_entries:
|
|
502
|
-
entries_by_command.setdefault(each_entry.command, []).append(each_entry)
|
|
503
|
-
all_ranked_reports = usage_module.rank_accounts_by_weekly_remaining(
|
|
504
|
-
list(all_usage_reports)
|
|
505
|
-
)
|
|
506
|
-
ranked_entries: list[ChainEntry] = []
|
|
507
|
-
seen_commands: set[str] = set()
|
|
508
|
-
for each_report in all_ranked_reports:
|
|
509
|
-
if each_report.command in seen_commands:
|
|
510
|
-
continue
|
|
511
|
-
matched_entries = entries_by_command.get(each_report.command)
|
|
512
|
-
if matched_entries is None:
|
|
513
|
-
continue
|
|
514
|
-
seen_commands.add(each_report.command)
|
|
515
|
-
ranked_entries.extend(matched_entries)
|
|
516
|
-
for each_entry in all_entries:
|
|
517
|
-
if each_entry.command not in seen_commands:
|
|
518
|
-
ranked_entries.append(each_entry)
|
|
519
|
-
return ranked_entries
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
def _is_usage_limit_failure(completion: subprocess.CompletedProcess[str]) -> bool:
|
|
523
|
-
combined_text = f"{completion.stdout}{completion.stderr}".lower()
|
|
524
|
-
return any(
|
|
525
|
-
each_signature in combined_text for each_signature in ALL_USAGE_LIMIT_SIGNATURES
|
|
526
|
-
)
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
def extract_session_id_from_stdout(stdout_text: str) -> str | None:
|
|
530
|
-
"""Return the first ``session_id`` found in Claude JSON stdout.
|
|
531
|
-
|
|
532
|
-
::
|
|
533
|
-
|
|
534
|
-
'{"type":"result","session_id":"abc","result":"ok"}'
|
|
535
|
-
-> "abc"
|
|
536
|
-
'not json'
|
|
537
|
-
-> None
|
|
538
|
-
|
|
539
|
-
Accepts a single JSON object or NDJSON event lines. The first non-empty
|
|
540
|
-
string value under the ``session_id`` key wins.
|
|
541
|
-
|
|
542
|
-
Args:
|
|
543
|
-
stdout_text: Captured stdout from a Claude ``--output-format json`` run.
|
|
544
|
-
|
|
545
|
-
Returns:
|
|
546
|
-
The session id string, or ``None`` when none is present.
|
|
547
|
-
"""
|
|
548
|
-
stripped_stdout = stdout_text.strip()
|
|
549
|
-
if not stripped_stdout:
|
|
550
|
-
return None
|
|
551
|
-
maybe_session_id = _session_id_from_json_text(stripped_stdout)
|
|
552
|
-
if maybe_session_id is not None:
|
|
553
|
-
return maybe_session_id
|
|
554
|
-
for each_line in stripped_stdout.splitlines():
|
|
555
|
-
stripped_line = each_line.strip()
|
|
556
|
-
if not stripped_line:
|
|
557
|
-
continue
|
|
558
|
-
maybe_session_id = _session_id_from_json_text(stripped_line)
|
|
559
|
-
if maybe_session_id is not None:
|
|
560
|
-
return maybe_session_id
|
|
561
|
-
return None
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
def _session_id_from_json_text(json_text: str) -> str | None:
|
|
565
|
-
try:
|
|
566
|
-
parsed_payload = json.loads(json_text)
|
|
567
|
-
except json.JSONDecodeError:
|
|
568
|
-
return None
|
|
569
|
-
if not isinstance(parsed_payload, dict):
|
|
570
|
-
return None
|
|
571
|
-
raw_session_id = parsed_payload.get(SESSION_ID_JSON_KEY)
|
|
572
|
-
if isinstance(raw_session_id, str) and raw_session_id:
|
|
573
|
-
return raw_session_id
|
|
574
|
-
return None
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
@dataclass(frozen=True)
|
|
578
|
-
class AffinityBinding:
|
|
579
|
-
"""One session-id to chain-binary binding."""
|
|
580
|
-
|
|
581
|
-
session_id: str
|
|
582
|
-
command: str
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
@dataclass(frozen=True)
|
|
586
|
-
class AffinityStore:
|
|
587
|
-
"""Versioned, bounded session-to-binary affinity document."""
|
|
588
|
-
|
|
589
|
-
schema_version: int = AFFINITY_STATE_SCHEMA_VERSION
|
|
590
|
-
all_bindings: list[AffinityBinding] = field(default_factory=list)
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
def default_affinity_state_path(claude_home_directory: Path) -> Path:
|
|
594
|
-
"""Return the default affinity state path under a Claude home directory.
|
|
595
|
-
|
|
596
|
-
Args:
|
|
597
|
-
claude_home_directory: Claude configuration root (for example ``~/.claude``).
|
|
598
|
-
|
|
599
|
-
Returns:
|
|
600
|
-
Path to the affinity state JSON file.
|
|
601
|
-
"""
|
|
602
|
-
return claude_home_directory / AFFINITY_STATE_FILENAME
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
def _affinity_corrupt_error(state_path: Path, error: object) -> ValueError:
|
|
606
|
-
return ValueError(
|
|
607
|
-
AFFINITY_CORRUPT_MESSAGE_TEMPLATE.format(
|
|
608
|
-
state_path=state_path,
|
|
609
|
-
error=error,
|
|
610
|
-
)
|
|
611
|
-
)
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
def _parse_affinity_binding(
|
|
615
|
-
each_binding: object,
|
|
616
|
-
*,
|
|
617
|
-
state_path: Path,
|
|
618
|
-
) -> AffinityBinding:
|
|
619
|
-
if not isinstance(each_binding, dict):
|
|
620
|
-
raise _affinity_corrupt_error(state_path, AFFINITY_BINDING_NOT_OBJECT_REASON)
|
|
621
|
-
session_id = each_binding.get(AFFINITY_KEY_SESSION_ID)
|
|
622
|
-
command = each_binding.get(AFFINITY_KEY_COMMAND)
|
|
623
|
-
if not isinstance(session_id, str) or not session_id:
|
|
624
|
-
raise _affinity_corrupt_error(
|
|
625
|
-
state_path, AFFINITY_BINDING_SESSION_ID_MISSING_REASON
|
|
626
|
-
)
|
|
627
|
-
if not isinstance(command, str) or not command:
|
|
628
|
-
raise _affinity_corrupt_error(
|
|
629
|
-
state_path, AFFINITY_BINDING_COMMAND_MISSING_REASON
|
|
630
|
-
)
|
|
631
|
-
return AffinityBinding(session_id=session_id, command=command)
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
def _bindings_from_payload(
|
|
635
|
-
all_payload_fields: dict[str, object],
|
|
636
|
-
*,
|
|
637
|
-
state_path: Path,
|
|
638
|
-
) -> list[AffinityBinding]:
|
|
639
|
-
schema_version = all_payload_fields.get(AFFINITY_KEY_SCHEMA_VERSION)
|
|
640
|
-
if schema_version != AFFINITY_STATE_SCHEMA_VERSION:
|
|
641
|
-
raise _affinity_corrupt_error(
|
|
642
|
-
state_path,
|
|
643
|
-
AFFINITY_UNSUPPORTED_SCHEMA_VERSION_REASON_TEMPLATE.format(
|
|
644
|
-
schema_version=schema_version
|
|
645
|
-
),
|
|
646
|
-
)
|
|
647
|
-
raw_bindings = all_payload_fields.get(AFFINITY_KEY_ALL_BINDINGS)
|
|
648
|
-
if not isinstance(raw_bindings, list):
|
|
649
|
-
raise _affinity_corrupt_error(
|
|
650
|
-
state_path, AFFINITY_BINDINGS_MISSING_OR_NOT_LIST_REASON
|
|
651
|
-
)
|
|
652
|
-
return [
|
|
653
|
-
_parse_affinity_binding(each_binding, state_path=state_path)
|
|
654
|
-
for each_binding in raw_bindings
|
|
655
|
-
]
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
def load_affinity_store(state_path: Path) -> AffinityStore:
|
|
659
|
-
"""Load a versioned affinity store, or an empty store when the file is absent.
|
|
660
|
-
|
|
661
|
-
Args:
|
|
662
|
-
state_path: Path to the affinity state JSON file.
|
|
663
|
-
|
|
664
|
-
Returns:
|
|
665
|
-
Parsed affinity store.
|
|
666
|
-
|
|
667
|
-
Raises:
|
|
668
|
-
ValueError: When the document is corrupt or uses an unsupported schema.
|
|
669
|
-
"""
|
|
670
|
-
if not state_path.is_file():
|
|
671
|
-
return AffinityStore()
|
|
672
|
-
try:
|
|
673
|
-
raw_text = state_path.read_text(encoding=UTF8_ENCODING)
|
|
674
|
-
parsed_payload = json.loads(raw_text)
|
|
675
|
-
except (OSError, json.JSONDecodeError, UnicodeError) as load_error:
|
|
676
|
-
raise _affinity_corrupt_error(state_path, load_error) from load_error
|
|
677
|
-
if not isinstance(parsed_payload, dict):
|
|
678
|
-
raise _affinity_corrupt_error(
|
|
679
|
-
state_path, AFFINITY_TOP_LEVEL_NOT_OBJECT_REASON
|
|
680
|
-
)
|
|
681
|
-
return AffinityStore(
|
|
682
|
-
schema_version=AFFINITY_STATE_SCHEMA_VERSION,
|
|
683
|
-
all_bindings=_bindings_from_payload(parsed_payload, state_path=state_path),
|
|
684
|
-
)
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
def record_affinity_binding(
|
|
688
|
-
store: AffinityStore,
|
|
689
|
-
*,
|
|
690
|
-
session_id: str,
|
|
691
|
-
command: str,
|
|
692
|
-
maximum_entries: int = AFFINITY_MAXIMUM_ENTRIES,
|
|
693
|
-
) -> AffinityStore:
|
|
694
|
-
"""Return a new store with ``session_id`` bound to ``command``, bounded.
|
|
695
|
-
|
|
696
|
-
Re-binding an existing session moves it to the newest end. When the store
|
|
697
|
-
exceeds ``maximum_entries``, the oldest bindings drop first.
|
|
698
|
-
|
|
699
|
-
Args:
|
|
700
|
-
store: Current affinity store.
|
|
701
|
-
session_id: Claude session id to bind.
|
|
702
|
-
command: Chain binary command that served the session.
|
|
703
|
-
maximum_entries: Hard cap on retained bindings.
|
|
704
|
-
|
|
705
|
-
Returns:
|
|
706
|
-
Updated store (does not mutate ``store``).
|
|
707
|
-
|
|
708
|
-
Raises:
|
|
709
|
-
ValueError: When session_id, command, or maximum_entries is invalid.
|
|
710
|
-
"""
|
|
711
|
-
if not session_id or not command:
|
|
712
|
-
raise ValueError(AFFINITY_SESSION_ID_AND_COMMAND_REQUIRED_MESSAGE)
|
|
713
|
-
if maximum_entries < 1:
|
|
714
|
-
raise ValueError(AFFINITY_MAXIMUM_ENTRIES_MINIMUM_MESSAGE)
|
|
715
|
-
all_remaining = [
|
|
716
|
-
each_binding
|
|
717
|
-
for each_binding in store.all_bindings
|
|
718
|
-
if each_binding.session_id != session_id
|
|
719
|
-
]
|
|
720
|
-
all_remaining.append(AffinityBinding(session_id=session_id, command=command))
|
|
721
|
-
if len(all_remaining) > maximum_entries:
|
|
722
|
-
all_remaining = all_remaining[-maximum_entries:]
|
|
723
|
-
return AffinityStore(
|
|
724
|
-
schema_version=AFFINITY_STATE_SCHEMA_VERSION,
|
|
725
|
-
all_bindings=all_remaining,
|
|
726
|
-
)
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
def save_affinity_store_atomic(state_path: Path, store: AffinityStore) -> None:
|
|
730
|
-
"""Atomically replace the affinity state file with ``store``.
|
|
731
|
-
|
|
732
|
-
Creates a unique sibling temporary file via ``tempfile.mkstemp``, writes
|
|
733
|
-
and flushes the document, then uses ``os.replace`` so readers never
|
|
734
|
-
observe a partial document.
|
|
735
|
-
|
|
736
|
-
Args:
|
|
737
|
-
state_path: Destination affinity state path.
|
|
738
|
-
store: Store document to persist.
|
|
739
|
-
|
|
740
|
-
Raises:
|
|
741
|
-
OSError: When the write or replace fails (message is actionable).
|
|
742
|
-
"""
|
|
743
|
-
payload = {
|
|
744
|
-
AFFINITY_KEY_SCHEMA_VERSION: store.schema_version,
|
|
745
|
-
AFFINITY_KEY_ALL_BINDINGS: [
|
|
746
|
-
{
|
|
747
|
-
AFFINITY_KEY_SESSION_ID: each_binding.session_id,
|
|
748
|
-
AFFINITY_KEY_COMMAND: each_binding.command,
|
|
749
|
-
}
|
|
750
|
-
for each_binding in store.all_bindings
|
|
751
|
-
],
|
|
752
|
-
}
|
|
753
|
-
serialized_document = (
|
|
754
|
-
json.dumps(payload, indent=AFFINITY_JSON_INDENT_SPACES, sort_keys=True)
|
|
755
|
-
+ "\n"
|
|
756
|
-
)
|
|
757
|
-
state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
758
|
-
file_descriptor, temporary_name = tempfile.mkstemp(
|
|
759
|
-
prefix=f".{state_path.name}.",
|
|
760
|
-
suffix=AFFINITY_TEMP_SUFFIX,
|
|
761
|
-
dir=state_path.parent,
|
|
762
|
-
)
|
|
763
|
-
temporary_path = Path(temporary_name)
|
|
764
|
-
owned_descriptor = file_descriptor
|
|
765
|
-
try:
|
|
766
|
-
with os.fdopen(file_descriptor, "w", encoding=UTF8_ENCODING) as temporary_file:
|
|
767
|
-
owned_descriptor = -1
|
|
768
|
-
temporary_file.write(serialized_document)
|
|
769
|
-
temporary_file.flush()
|
|
770
|
-
os.fsync(temporary_file.fileno())
|
|
771
|
-
os.replace(temporary_path, state_path)
|
|
772
|
-
except OSError as write_error:
|
|
773
|
-
if owned_descriptor >= 0:
|
|
774
|
-
try:
|
|
775
|
-
os.close(owned_descriptor)
|
|
776
|
-
except OSError:
|
|
777
|
-
pass
|
|
778
|
-
try:
|
|
779
|
-
temporary_path.unlink(missing_ok=True)
|
|
780
|
-
except OSError:
|
|
781
|
-
pass
|
|
782
|
-
raise OSError(
|
|
783
|
-
AFFINITY_WRITE_FAILED_MESSAGE_TEMPLATE.format(
|
|
784
|
-
state_path=state_path,
|
|
785
|
-
error=write_error,
|
|
786
|
-
)
|
|
787
|
-
) from write_error
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
def _served_outcome(
|
|
791
|
-
served_command: str,
|
|
792
|
-
completion: subprocess.CompletedProcess[str],
|
|
793
|
-
all_attempts: list[ChainAttempt],
|
|
794
|
-
) -> ChainInvocationOutcome:
|
|
795
|
-
maybe_session_id = None
|
|
796
|
-
if completion.returncode == 0:
|
|
797
|
-
maybe_session_id = extract_session_id_from_stdout(completion.stdout)
|
|
798
|
-
return ChainInvocationOutcome(
|
|
799
|
-
served_command=served_command,
|
|
800
|
-
returncode=completion.returncode,
|
|
801
|
-
stdout=completion.stdout,
|
|
802
|
-
stderr=completion.stderr,
|
|
803
|
-
attempts=tuple(all_attempts),
|
|
804
|
-
terminal_status=TERMINAL_STATUS_SERVED,
|
|
805
|
-
session_id=maybe_session_id,
|
|
806
|
-
)
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
def _timeout_streams(
|
|
810
|
-
timeout_error: subprocess.TimeoutExpired | None,
|
|
811
|
-
) -> tuple[str, str]:
|
|
812
|
-
if timeout_error is None:
|
|
813
|
-
return "", ""
|
|
814
|
-
captured_stdout = (
|
|
815
|
-
timeout_error.stdout if isinstance(timeout_error.stdout, str) else ""
|
|
816
|
-
)
|
|
817
|
-
captured_stderr = (
|
|
818
|
-
timeout_error.stderr if isinstance(timeout_error.stderr, str) else ""
|
|
819
|
-
)
|
|
820
|
-
return captured_stdout, captured_stderr
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
def _no_process_outcome(
|
|
824
|
-
all_attempts: list[ChainAttempt],
|
|
825
|
-
timeout_error: subprocess.TimeoutExpired | None,
|
|
826
|
-
*,
|
|
827
|
-
terminal_status: str,
|
|
828
|
-
) -> ChainInvocationOutcome:
|
|
829
|
-
captured_stdout, captured_stderr = _timeout_streams(timeout_error)
|
|
830
|
-
return ChainInvocationOutcome(
|
|
831
|
-
served_command=None,
|
|
832
|
-
returncode=NO_COMPLETED_PROCESS_RETURN_CODE,
|
|
833
|
-
stdout=captured_stdout,
|
|
834
|
-
stderr=captured_stderr,
|
|
835
|
-
attempts=tuple(all_attempts),
|
|
836
|
-
terminal_status=terminal_status,
|
|
837
|
-
session_id=None,
|
|
838
|
-
)
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
def _advisor_blocked_outcome(
|
|
842
|
-
completion: subprocess.CompletedProcess[str],
|
|
843
|
-
all_attempts: list[ChainAttempt],
|
|
844
|
-
) -> ChainInvocationOutcome:
|
|
845
|
-
return ChainInvocationOutcome(
|
|
846
|
-
served_command=None,
|
|
847
|
-
returncode=completion.returncode,
|
|
848
|
-
stdout=completion.stdout,
|
|
849
|
-
stderr=completion.stderr,
|
|
850
|
-
attempts=tuple(all_attempts),
|
|
851
|
-
terminal_status=TERMINAL_STATUS_ADVISOR_BLOCKED,
|
|
852
|
-
session_id=None,
|
|
853
|
-
)
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
def _exhausted_outcome(
|
|
857
|
-
all_attempts: list[ChainAttempt],
|
|
858
|
-
last_usage_limited: subprocess.CompletedProcess[str] | None,
|
|
859
|
-
) -> ChainInvocationOutcome:
|
|
860
|
-
if last_usage_limited is None:
|
|
861
|
-
return _no_process_outcome(
|
|
862
|
-
all_attempts,
|
|
863
|
-
None,
|
|
864
|
-
terminal_status=TERMINAL_STATUS_CHAIN_EXHAUSTED,
|
|
865
|
-
)
|
|
866
|
-
return ChainInvocationOutcome(
|
|
867
|
-
served_command=None,
|
|
868
|
-
returncode=last_usage_limited.returncode,
|
|
869
|
-
stdout=last_usage_limited.stdout,
|
|
870
|
-
stderr=last_usage_limited.stderr,
|
|
871
|
-
attempts=tuple(all_attempts),
|
|
872
|
-
terminal_status=TERMINAL_STATUS_CHAIN_EXHAUSTED,
|
|
873
|
-
session_id=None,
|
|
874
|
-
)
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
def _classify_completion(
|
|
878
|
-
entry: ChainEntry,
|
|
879
|
-
completion: subprocess.CompletedProcess[str],
|
|
880
|
-
all_attempts: list[ChainAttempt],
|
|
881
|
-
*,
|
|
882
|
-
routing_mode: str,
|
|
883
|
-
) -> ChainInvocationOutcome | None:
|
|
884
|
-
if completion.returncode == 0:
|
|
885
|
-
all_attempts.append(ChainAttempt(entry.command, ATTEMPT_STATUS_SERVED))
|
|
886
|
-
return _served_outcome(entry.command, completion, all_attempts)
|
|
887
|
-
if _is_usage_limit_failure(completion):
|
|
888
|
-
all_attempts.append(ChainAttempt(entry.command, ATTEMPT_STATUS_USAGE_LIMITED))
|
|
889
|
-
return None
|
|
890
|
-
all_attempts.append(ChainAttempt(entry.command, ATTEMPT_STATUS_NONZERO_EXIT))
|
|
891
|
-
if routing_mode == ROUTING_MODE_ORDERED_ACCOUNT:
|
|
892
|
-
return _advisor_blocked_outcome(completion, all_attempts)
|
|
893
|
-
return _served_outcome(entry.command, completion, all_attempts)
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
def _ranked_entries_or_config_order(
|
|
897
|
-
all_entries: list[ChainEntry],
|
|
898
|
-
config_path: Path,
|
|
899
|
-
) -> list[ChainEntry]:
|
|
900
|
-
try:
|
|
901
|
-
all_usage_reports = chain_weekly_usage_reporter(config_path=config_path)
|
|
902
|
-
return _entries_ranked_by_weekly_remaining(
|
|
903
|
-
all_entries, all_usage_reports
|
|
904
|
-
)
|
|
905
|
-
except (ImportError, AttributeError):
|
|
906
|
-
return list(all_entries)
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
def extract_resume_session_id(
|
|
910
|
-
all_claude_arguments: Sequence[str],
|
|
911
|
-
) -> str | None:
|
|
912
|
-
"""Return the session id after ``--resume`` when present.
|
|
913
|
-
|
|
914
|
-
Args:
|
|
915
|
-
all_claude_arguments: Arguments passed after the binary name.
|
|
916
|
-
|
|
917
|
-
Returns:
|
|
918
|
-
The non-empty session id, or ``None`` when the flag is absent.
|
|
919
|
-
"""
|
|
920
|
-
for each_index, each_argument in enumerate(all_claude_arguments):
|
|
921
|
-
if each_argument != RESUME_SESSION_FLAG:
|
|
922
|
-
continue
|
|
923
|
-
next_index = each_index + 1
|
|
924
|
-
if next_index >= len(all_claude_arguments):
|
|
925
|
-
return None
|
|
926
|
-
session_id = all_claude_arguments[next_index]
|
|
927
|
-
if session_id and not session_id.startswith("-"):
|
|
928
|
-
return session_id
|
|
929
|
-
return None
|
|
930
|
-
return None
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
def lookup_affinity_command(
|
|
934
|
-
store: AffinityStore,
|
|
935
|
-
session_id: str,
|
|
936
|
-
) -> str | None:
|
|
937
|
-
"""Return the bound binary command for ``session_id``, if any.
|
|
938
|
-
|
|
939
|
-
Args:
|
|
940
|
-
store: Loaded affinity store.
|
|
941
|
-
session_id: Session id from a prior serve.
|
|
942
|
-
|
|
943
|
-
Returns:
|
|
944
|
-
The bound command string, or ``None`` when unbound.
|
|
945
|
-
"""
|
|
946
|
-
for each_binding in store.all_bindings:
|
|
947
|
-
if each_binding.session_id == session_id:
|
|
948
|
-
return each_binding.command
|
|
949
|
-
return None
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
def order_entries_for_resume(
|
|
953
|
-
all_entries: list[ChainEntry],
|
|
954
|
-
*,
|
|
955
|
-
preferred_command: str | None,
|
|
956
|
-
) -> list[ChainEntry]:
|
|
957
|
-
"""Put the preferred originating binary first; keep relative order of the rest.
|
|
958
|
-
|
|
959
|
-
When ``preferred_command`` is missing from the chain or is ``None``, the
|
|
960
|
-
original list order is returned unchanged (documented fallback).
|
|
961
|
-
|
|
962
|
-
Args:
|
|
963
|
-
all_entries: Chain entries in the mode's base order.
|
|
964
|
-
preferred_command: Originating binary from the affinity store.
|
|
965
|
-
|
|
966
|
-
Returns:
|
|
967
|
-
Ordered walk list for this invocation.
|
|
968
|
-
"""
|
|
969
|
-
if preferred_command is None:
|
|
970
|
-
return list(all_entries)
|
|
971
|
-
preferred: list[ChainEntry] = []
|
|
972
|
-
remaining: list[ChainEntry] = []
|
|
973
|
-
for each_entry in all_entries:
|
|
974
|
-
if each_entry.command == preferred_command and not preferred:
|
|
975
|
-
preferred.append(each_entry)
|
|
976
|
-
else:
|
|
977
|
-
remaining.append(each_entry)
|
|
978
|
-
if not preferred:
|
|
979
|
-
return list(all_entries)
|
|
980
|
-
return preferred + remaining
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
def _resolve_walk_entries(
|
|
984
|
-
all_entries: list[ChainEntry],
|
|
985
|
-
config_path: Path,
|
|
986
|
-
routing_mode: str,
|
|
987
|
-
) -> list[ChainEntry]:
|
|
988
|
-
if routing_mode == ROUTING_MODE_ORDERED_ACCOUNT:
|
|
989
|
-
return list(all_entries)
|
|
990
|
-
return _ranked_entries_or_config_order(all_entries, config_path)
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
def _affinity_preferred_command(
|
|
994
|
-
all_claude_arguments: Sequence[str],
|
|
995
|
-
affinity_path: Path,
|
|
996
|
-
) -> str | None:
|
|
997
|
-
resume_session_id = extract_resume_session_id(all_claude_arguments)
|
|
998
|
-
if resume_session_id is None:
|
|
999
|
-
return None
|
|
1000
|
-
try:
|
|
1001
|
-
affinity_store = load_affinity_store(affinity_path)
|
|
1002
|
-
except ValueError:
|
|
1003
|
-
return None
|
|
1004
|
-
return lookup_affinity_command(affinity_store, resume_session_id)
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
def _walk_entries_for_invocation(
|
|
1008
|
-
all_entries: list[ChainEntry],
|
|
1009
|
-
config_path: Path,
|
|
1010
|
-
routing_mode: str,
|
|
1011
|
-
all_claude_arguments: Sequence[str],
|
|
1012
|
-
affinity_path: Path,
|
|
1013
|
-
) -> list[ChainEntry]:
|
|
1014
|
-
base_order = _resolve_walk_entries(all_entries, config_path, routing_mode)
|
|
1015
|
-
preferred_command = _affinity_preferred_command(
|
|
1016
|
-
all_claude_arguments, affinity_path
|
|
1017
|
-
)
|
|
1018
|
-
return order_entries_for_resume(
|
|
1019
|
-
base_order, preferred_command=preferred_command
|
|
1020
|
-
)
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
def _require_known_routing_mode(routing_mode: str) -> str:
|
|
1024
|
-
if routing_mode not in ALL_ROUTING_MODES:
|
|
1025
|
-
raise ValueError(
|
|
1026
|
-
f"Unknown routing_mode {routing_mode!r}; "
|
|
1027
|
-
f"expected one of {sorted(ALL_ROUTING_MODES)}"
|
|
1028
|
-
)
|
|
1029
|
-
return routing_mode
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
def run_claude(
|
|
1033
|
-
all_claude_arguments: list[str],
|
|
1034
|
-
*,
|
|
1035
|
-
timeout_seconds: int,
|
|
1036
|
-
stdin_text: str | None = None,
|
|
1037
|
-
routing_mode: str = DEFAULT_ROUTING_MODE,
|
|
1038
|
-
) -> ChainInvocationOutcome:
|
|
1039
|
-
"""Run *all_claude_arguments* through the fallback chain.
|
|
1040
|
-
|
|
1041
|
-
::
|
|
1042
|
-
|
|
1043
|
-
usage_ranked (default): highest remaining first
|
|
1044
|
-
ordered_account: config order; usage-limit-only fallover
|
|
1045
|
-
ordered_account + auth/timeout/generic process error
|
|
1046
|
-
-> terminal_status=advisor_blocked (no fallover)
|
|
1047
|
-
zero exit with JSON session_id
|
|
1048
|
-
-> outcome.session_id set for later --resume
|
|
1049
|
-
|
|
1050
|
-
Default mode probes weekly remaining once, ranks highest first, then walks
|
|
1051
|
-
that order. Ordered-account mode walks config order and never probes usage.
|
|
1052
|
-
Only a usage-limit failure falls over. Missing binaries are skipped and the
|
|
1053
|
-
walk continues; timeout and other nonzero exits stop. In ordered-account
|
|
1054
|
-
mode those non-usage stops report ``advisor_blocked``. When usage ranking
|
|
1055
|
-
infrastructure fails to load under usage-ranked mode, the walk uses config
|
|
1056
|
-
order instead.
|
|
1057
|
-
|
|
1058
|
-
Args:
|
|
1059
|
-
all_claude_arguments: Arguments passed after the binary name, such as
|
|
1060
|
-
``["-p", prompt, "--strict-mcp-config"]``.
|
|
1061
|
-
timeout_seconds: Timeout applied to each binary invocation.
|
|
1062
|
-
stdin_text: Optional UTF-8 text forwarded as stdin to every binary.
|
|
1063
|
-
``None`` leaves the subprocess without a piped stdin body.
|
|
1064
|
-
routing_mode: ``usage_ranked`` (default) or ``ordered_account``.
|
|
1065
|
-
|
|
1066
|
-
Returns:
|
|
1067
|
-
The outcome of the walk, naming the serving binary, terminal status,
|
|
1068
|
-
optional session id, and the full attempt trail.
|
|
1069
|
-
|
|
1070
|
-
Raises:
|
|
1071
|
-
ChainConfigurationError: When the chain configuration cannot be loaded.
|
|
1072
|
-
ValueError: When *routing_mode* is not a known mode.
|
|
1073
|
-
"""
|
|
1074
|
-
selected_routing_mode = _require_known_routing_mode(routing_mode)
|
|
1075
|
-
config_path = chain_config_path()
|
|
1076
|
-
all_entries = load_chain(config_path)
|
|
1077
|
-
affinity_path = default_affinity_state_path(
|
|
1078
|
-
Path.home() / CLAUDE_HOME_SUBDIRECTORY
|
|
1079
|
-
)
|
|
1080
|
-
all_walk_entries = _walk_entries_for_invocation(
|
|
1081
|
-
all_entries,
|
|
1082
|
-
config_path,
|
|
1083
|
-
selected_routing_mode,
|
|
1084
|
-
all_claude_arguments,
|
|
1085
|
-
affinity_path,
|
|
1086
|
-
)
|
|
1087
|
-
return _walk_chain_attempts(
|
|
1088
|
-
all_walk_entries,
|
|
1089
|
-
all_claude_arguments=all_claude_arguments,
|
|
1090
|
-
timeout_seconds=timeout_seconds,
|
|
1091
|
-
stdin_text=stdin_text,
|
|
1092
|
-
routing_mode=selected_routing_mode,
|
|
1093
|
-
affinity_path=affinity_path,
|
|
1094
|
-
)
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
def _walk_chain_attempts(
|
|
1098
|
-
all_walk_entries: list[ChainEntry],
|
|
1099
|
-
*,
|
|
1100
|
-
all_claude_arguments: list[str],
|
|
1101
|
-
timeout_seconds: int,
|
|
1102
|
-
stdin_text: str | None,
|
|
1103
|
-
routing_mode: str,
|
|
1104
|
-
affinity_path: Path,
|
|
1105
|
-
) -> ChainInvocationOutcome:
|
|
1106
|
-
all_attempts: list[ChainAttempt] = []
|
|
1107
|
-
last_usage_limited: subprocess.CompletedProcess[str] | None = None
|
|
1108
|
-
for each_entry in all_walk_entries:
|
|
1109
|
-
try:
|
|
1110
|
-
completion = chain_subprocess_runner(
|
|
1111
|
-
_build_invocation(each_entry, all_claude_arguments),
|
|
1112
|
-
capture_output=True,
|
|
1113
|
-
text=True,
|
|
1114
|
-
encoding=UTF8_ENCODING,
|
|
1115
|
-
errors=CODEC_ERROR_STRATEGY,
|
|
1116
|
-
timeout=timeout_seconds,
|
|
1117
|
-
check=False,
|
|
1118
|
-
input=stdin_text,
|
|
1119
|
-
)
|
|
1120
|
-
except subprocess.TimeoutExpired as timeout_error:
|
|
1121
|
-
all_attempts.append(
|
|
1122
|
-
ChainAttempt(each_entry.command, ATTEMPT_STATUS_TIMEOUT)
|
|
1123
|
-
)
|
|
1124
|
-
timeout_terminal_status = (
|
|
1125
|
-
TERMINAL_STATUS_ADVISOR_BLOCKED
|
|
1126
|
-
if routing_mode == ROUTING_MODE_ORDERED_ACCOUNT
|
|
1127
|
-
else TERMINAL_STATUS_TIMEOUT
|
|
1128
|
-
)
|
|
1129
|
-
return _no_process_outcome(
|
|
1130
|
-
all_attempts,
|
|
1131
|
-
timeout_error,
|
|
1132
|
-
terminal_status=timeout_terminal_status,
|
|
1133
|
-
)
|
|
1134
|
-
except FileNotFoundError:
|
|
1135
|
-
all_attempts.append(
|
|
1136
|
-
ChainAttempt(each_entry.command, ATTEMPT_STATUS_EXECUTABLE_NOT_FOUND)
|
|
1137
|
-
)
|
|
1138
|
-
continue
|
|
1139
|
-
terminal_outcome = _classify_completion(
|
|
1140
|
-
each_entry,
|
|
1141
|
-
completion,
|
|
1142
|
-
all_attempts,
|
|
1143
|
-
routing_mode=routing_mode,
|
|
1144
|
-
)
|
|
1145
|
-
if terminal_outcome is not None:
|
|
1146
|
-
_persist_served_affinity(
|
|
1147
|
-
affinity_path,
|
|
1148
|
-
served_command=each_entry.command,
|
|
1149
|
-
session_id=terminal_outcome.session_id,
|
|
1150
|
-
)
|
|
1151
|
-
return terminal_outcome
|
|
1152
|
-
last_usage_limited = completion
|
|
1153
|
-
return _exhausted_outcome(all_attempts, last_usage_limited)
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
def _persist_served_affinity(
|
|
1157
|
-
affinity_path: Path,
|
|
1158
|
-
*,
|
|
1159
|
-
served_command: str,
|
|
1160
|
-
session_id: str | None,
|
|
1161
|
-
) -> None:
|
|
1162
|
-
"""Best-effort write of session-to-binary affinity after a successful serve."""
|
|
1163
|
-
if not session_id:
|
|
1164
|
-
return
|
|
1165
|
-
try:
|
|
1166
|
-
store = load_affinity_store(affinity_path)
|
|
1167
|
-
updated = record_affinity_binding(
|
|
1168
|
-
store, session_id=session_id, command=served_command
|
|
1169
|
-
)
|
|
1170
|
-
save_affinity_store_atomic(affinity_path, updated)
|
|
1171
|
-
except (OSError, ValueError):
|
|
1172
|
-
return
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
def _run_cli_chain(
|
|
1176
|
-
all_claude_arguments: list[str],
|
|
1177
|
-
*,
|
|
1178
|
-
timeout_seconds: int,
|
|
1179
|
-
stdin_text: str | None,
|
|
1180
|
-
routing_mode: str,
|
|
1181
|
-
) -> ChainInvocationOutcome:
|
|
1182
|
-
"""Run the CLI-selected chain arguments through the public runner."""
|
|
1183
|
-
return run_claude(
|
|
1184
|
-
all_claude_arguments,
|
|
1185
|
-
timeout_seconds=timeout_seconds,
|
|
1186
|
-
stdin_text=stdin_text,
|
|
1187
|
-
routing_mode=routing_mode,
|
|
1188
|
-
)
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
def _build_argument_parser() -> argparse.ArgumentParser:
|
|
1192
|
-
parser = argparse.ArgumentParser(
|
|
1193
|
-
description="Run a claude invocation through the fallback chain."
|
|
1194
|
-
)
|
|
1195
|
-
parser.add_argument(
|
|
1196
|
-
CLI_TIMEOUT_FLAG,
|
|
1197
|
-
dest="timeout_seconds",
|
|
1198
|
-
type=int,
|
|
1199
|
-
default=DEFAULT_TIMEOUT_SECONDS,
|
|
1200
|
-
help="Timeout in seconds applied to each binary invocation.",
|
|
1201
|
-
)
|
|
1202
|
-
parser.add_argument(
|
|
1203
|
-
CLI_ROUTING_MODE_FLAG,
|
|
1204
|
-
dest="routing_mode",
|
|
1205
|
-
choices=sorted(ALL_ROUTING_MODES),
|
|
1206
|
-
default=DEFAULT_ROUTING_MODE,
|
|
1207
|
-
help=(
|
|
1208
|
-
"Chain routing: usage_ranked (default) or ordered_account "
|
|
1209
|
-
"(config order, usage-limit-only fallover)."
|
|
1210
|
-
),
|
|
1211
|
-
)
|
|
1212
|
-
parser.add_argument("passthrough", nargs=argparse.REMAINDER)
|
|
1213
|
-
return parser
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
def _strip_leading_separator(all_passthrough: list[str]) -> list[str]:
|
|
1217
|
-
if all_passthrough and all_passthrough[0] == CLI_ARGUMENTS_SEPARATOR:
|
|
1218
|
-
return all_passthrough[1:]
|
|
1219
|
-
return all_passthrough
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
def _exhausted_message(all_attempts: tuple[ChainAttempt, ...]) -> str:
|
|
1223
|
-
attempt_summary = ATTEMPT_SUMMARY_JOIN_SEPARATOR.join(
|
|
1224
|
-
ATTEMPT_SUMMARY_ENTRY_TEMPLATE.format(
|
|
1225
|
-
command=each_attempt.command, status=each_attempt.status
|
|
1226
|
-
)
|
|
1227
|
-
for each_attempt in all_attempts
|
|
1228
|
-
)
|
|
1229
|
-
return CHAIN_EXHAUSTED_MESSAGE_TEMPLATE.format(attempt_summary=attempt_summary)
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
def _read_piped_stdin_text() -> str | None:
|
|
1233
|
-
if sys.stdin.isatty():
|
|
1234
|
-
return None
|
|
1235
|
-
return sys.stdin.read()
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
def main(all_command_arguments: list[str]) -> int:
|
|
1239
|
-
"""Walk the chain for CLI arguments and return the process exit code.
|
|
1240
|
-
|
|
1241
|
-
::
|
|
1242
|
-
|
|
1243
|
-
main(["--", "-p", "hi"])
|
|
1244
|
-
main(["--routing-mode", "ordered_account", "--", "-p", "hi"])
|
|
1245
|
-
|
|
1246
|
-
Args:
|
|
1247
|
-
all_command_arguments: The argument vector after the program name.
|
|
1248
|
-
|
|
1249
|
-
Returns:
|
|
1250
|
-
The served binary's return code, a distinct code when the chain is
|
|
1251
|
-
exhausted or advisor-blocked, or a distinct code when the configuration
|
|
1252
|
-
cannot be loaded.
|
|
1253
|
-
"""
|
|
1254
|
-
parser = _build_argument_parser()
|
|
1255
|
-
parsed_arguments = parser.parse_args(all_command_arguments)
|
|
1256
|
-
all_claude_arguments = _strip_leading_separator(parsed_arguments.passthrough)
|
|
1257
|
-
maybe_stdin_text = _read_piped_stdin_text()
|
|
1258
|
-
try:
|
|
1259
|
-
chain_outcome = _run_cli_chain(
|
|
1260
|
-
all_claude_arguments,
|
|
1261
|
-
timeout_seconds=parsed_arguments.timeout_seconds,
|
|
1262
|
-
stdin_text=maybe_stdin_text,
|
|
1263
|
-
routing_mode=parsed_arguments.routing_mode,
|
|
1264
|
-
)
|
|
1265
|
-
except ChainConfigurationError as configuration_error:
|
|
1266
|
-
print(str(configuration_error), file=sys.stderr)
|
|
1267
|
-
return CHAIN_CONFIG_ERROR_EXIT_CODE
|
|
1268
|
-
if chain_outcome.terminal_status == TERMINAL_STATUS_ADVISOR_BLOCKED:
|
|
1269
|
-
sys.stdout.write(chain_outcome.stdout)
|
|
1270
|
-
sys.stderr.write(chain_outcome.stderr)
|
|
1271
|
-
return CHAIN_ADVISOR_BLOCKED_EXIT_CODE
|
|
1272
|
-
if chain_outcome.served_command is None:
|
|
1273
|
-
print(_exhausted_message(chain_outcome.attempts), file=sys.stderr)
|
|
1274
|
-
return CHAIN_EXHAUSTED_EXIT_CODE
|
|
1275
|
-
sys.stdout.write(chain_outcome.stdout)
|
|
1276
|
-
sys.stderr.write(chain_outcome.stderr)
|
|
1277
|
-
return chain_outcome.returncode
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
def _reconfigure_stream_to_utf8(stream: TextIO) -> None:
|
|
1281
|
-
"""Reconfigure *stream* to emit UTF-8, replacing any unmappable character."""
|
|
1282
|
-
if isinstance(stream, io.TextIOWrapper):
|
|
1283
|
-
stream.reconfigure(encoding=UTF8_ENCODING, errors=CODEC_ERROR_STRATEGY)
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
if __name__ == "__main__":
|
|
1287
|
-
_reconfigure_stream_to_utf8(sys.stdout)
|
|
1288
|
-
_reconfigure_stream_to_utf8(sys.stderr)
|
|
1289
|
-
sys.exit(main(sys.argv[1:]))
|