claude-dev-env 1.90.0 → 1.92.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.
- package/CLAUDE.md +1 -52
- package/agents/clean-coder.md +2 -2
- package/agents/code-verifier.md +0 -1
- package/agents/test_agent_frontmatter.py +78 -0
- package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +1 -1
- package/audit-rubrics/prompts/category-j-code-rules-compliance.md +1 -1
- package/bin/install.mjs +1 -0
- package/docs/CODE_RULES.md +2 -2
- package/hooks/blocking/CLAUDE.md +5 -2
- package/hooks/blocking/code_rules_comments.py +2 -2
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +44 -0
- package/hooks/blocking/config/verified_commit_constants.py +2 -0
- package/hooks/blocking/conftest.py +115 -0
- package/hooks/blocking/nas_ssh_binary_enforcer.py +191 -0
- package/hooks/blocking/pr_description_enforcer.py +46 -22
- package/hooks/blocking/pr_description_pr_number.py +5 -3
- package/hooks/blocking/pr_description_proof_of_work.py +367 -0
- package/hooks/blocking/precommit_code_rules_gate.py +5 -1
- package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +8 -2
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +71 -0
- package/hooks/blocking/test_nas_ssh_binary_enforcer.py +168 -0
- package/hooks/blocking/test_pr_description_enforcer_proof_gate.py +175 -0
- package/hooks/blocking/test_pr_description_proof_of_work.py +162 -0
- package/hooks/blocking/test_precommit_code_rules_gate.py +89 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +4 -0
- package/hooks/blocking/test_verification_verdict_store.py +11 -0
- package/hooks/blocking/test_verified_commit_config_bootstrap.py +49 -0
- package/hooks/blocking/test_verified_commit_gate.py +11 -0
- package/hooks/blocking/test_verifier_verdict_minter.py +11 -0
- package/hooks/blocking/verdict_directory_write_blocker.py +6 -0
- package/hooks/blocking/verification_verdict_store.py +73 -5
- package/hooks/blocking/verified_commit_config_bootstrap.py +51 -0
- package/hooks/blocking/verified_commit_gate.py +6 -0
- package/hooks/blocking/verifier_verdict_minter.py +6 -0
- package/hooks/hooks.json +7 -2
- package/hooks/hooks_constants/CLAUDE.md +2 -0
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
- package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +3 -0
- package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +59 -0
- package/hooks/hooks_constants/pr_description_enforcer_constants.py +2 -0
- package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +111 -0
- package/hooks/validators/run_all_validators.py +216 -4
- package/hooks/validators/test_run_all_validators_pretooluse.py +102 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +7 -0
- package/rules/nas-ssh-invocation.md +21 -0
- package/rules/proof-of-work-pr-comments.md +26 -0
- package/rules/testing.md +0 -2
- package/scripts/CLAUDE.md +1 -0
- package/scripts/claude-chain.example.json +8 -0
- package/scripts/claude_chain_runner.py +400 -0
- package/scripts/dev_env_scripts_constants/CLAUDE.md +1 -0
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +124 -0
- package/scripts/sync_to_cursor/rules.py +1 -1
- package/scripts/test_claude_chain_runner.py +472 -0
- package/skills/CLAUDE.md +3 -0
- package/skills/orchestrator/SKILL.md +188 -0
- package/skills/orchestrator-refresh/SKILL.md +25 -0
- package/skills/usage-pause/SKILL.md +108 -0
- package/skills/usage-pause/scripts/resolve_usage_window.py +462 -0
- package/skills/usage-pause/scripts/test_resolve_usage_window.py +278 -0
- package/skills/usage-pause/scripts/usage_pause_constants/__init__.py +1 -0
- package/skills/usage-pause/scripts/usage_pause_constants/resolve_usage_window_constants.py +65 -0
- package/system-prompts/software-engineer.xml +3 -2
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Run a ``claude`` invocation through a config-driven fallback chain.
|
|
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
|
+
This module runs the leading binary in the chain. It falls over to the next
|
|
7
|
+
binary only on a usage-limit failure. Every other outcome returns to the caller
|
|
8
|
+
unchanged.
|
|
9
|
+
|
|
10
|
+
The chain lives in ``~/.claude/claude-chain.json``. Copy the committed
|
|
11
|
+
``claude-chain.example.json`` template there and list your binaries in fallback
|
|
12
|
+
order::
|
|
13
|
+
|
|
14
|
+
{"chain": [{"command": "claude", "extra_args": []},
|
|
15
|
+
{"command": "claude-ev", "extra_args": []}]}
|
|
16
|
+
|
|
17
|
+
A usage-limited primary falls over to the second binary::
|
|
18
|
+
|
|
19
|
+
primary claude -> exit 1, "usage limit reached" (falls over)
|
|
20
|
+
fallback claude-ev -> exit 0 (served)
|
|
21
|
+
|
|
22
|
+
Import ``run_claude`` for the outcome object, or run the module as a CLI::
|
|
23
|
+
|
|
24
|
+
python claude_chain_runner.py [--timeout-seconds N] -- <claude args...>
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import argparse
|
|
30
|
+
import json
|
|
31
|
+
import subprocess
|
|
32
|
+
import sys
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
from dev_env_scripts_constants.claude_chain_constants import (
|
|
37
|
+
ALL_USAGE_LIMIT_SIGNATURES,
|
|
38
|
+
ATTEMPT_STATUS_EXECUTABLE_NOT_FOUND,
|
|
39
|
+
ATTEMPT_STATUS_NONZERO_EXIT,
|
|
40
|
+
ATTEMPT_STATUS_SERVED,
|
|
41
|
+
ATTEMPT_STATUS_TIMEOUT,
|
|
42
|
+
ATTEMPT_STATUS_USAGE_LIMITED,
|
|
43
|
+
ATTEMPT_SUMMARY_ENTRY_TEMPLATE,
|
|
44
|
+
ATTEMPT_SUMMARY_JOIN_SEPARATOR,
|
|
45
|
+
CHAIN_CONFIG_ERROR_EXIT_CODE,
|
|
46
|
+
CHAIN_EXHAUSTED_EXIT_CODE,
|
|
47
|
+
CHAIN_EXHAUSTED_MESSAGE_TEMPLATE,
|
|
48
|
+
CLAUDE_HOME_SUBDIRECTORY,
|
|
49
|
+
CLI_ARGUMENTS_SEPARATOR,
|
|
50
|
+
CLI_TIMEOUT_FLAG,
|
|
51
|
+
CONFIG_CHAIN_EMPTY_REASON,
|
|
52
|
+
CONFIG_CHAIN_KEY,
|
|
53
|
+
CONFIG_CHAIN_NOT_LIST_REASON,
|
|
54
|
+
CONFIG_COMMAND_KEY,
|
|
55
|
+
CONFIG_ENTRY_COMMAND_MISSING_REASON,
|
|
56
|
+
CONFIG_ENTRY_EXTRA_ARGS_INVALID_REASON,
|
|
57
|
+
CONFIG_ENTRY_NOT_OBJECT_REASON,
|
|
58
|
+
CONFIG_EXTRA_ARGS_KEY,
|
|
59
|
+
CONFIG_FILENAME,
|
|
60
|
+
CONFIG_INVALID_SHAPE_MESSAGE_TEMPLATE,
|
|
61
|
+
CONFIG_MALFORMED_MESSAGE_TEMPLATE,
|
|
62
|
+
CONFIG_MISSING_MESSAGE_TEMPLATE,
|
|
63
|
+
CONFIG_NOT_OBJECT_REASON,
|
|
64
|
+
CONFIG_UNREADABLE_MESSAGE_TEMPLATE,
|
|
65
|
+
DEFAULT_TIMEOUT_SECONDS,
|
|
66
|
+
EXAMPLE_CONFIG_FILENAME,
|
|
67
|
+
NO_COMPLETED_PROCESS_RETURN_CODE,
|
|
68
|
+
UTF8_ENCODING,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ChainConfigurationError(Exception):
|
|
73
|
+
"""Raised when the chain configuration is missing, unreadable, or malformed."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class ChainEntry:
|
|
78
|
+
"""One binary in the fallback chain and its per-account extra arguments."""
|
|
79
|
+
|
|
80
|
+
command: str
|
|
81
|
+
extra_args: tuple[str, ...]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class ChainAttempt:
|
|
86
|
+
"""Record of one binary invocation and how it resolved."""
|
|
87
|
+
|
|
88
|
+
command: str
|
|
89
|
+
status: str
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True)
|
|
93
|
+
class ChainInvocationOutcome:
|
|
94
|
+
"""Outcome of walking the chain for one call.
|
|
95
|
+
|
|
96
|
+
``served_command`` names the binary whose response is returned. It is
|
|
97
|
+
``None`` when no binary served the call: every entry was usage-limited or
|
|
98
|
+
missing, the invocation timed out, or the primary binary was absent. The
|
|
99
|
+
``attempts`` trail records every binary tried and how it resolved.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
served_command: str | None
|
|
103
|
+
returncode: int
|
|
104
|
+
stdout: str
|
|
105
|
+
stderr: str
|
|
106
|
+
attempts: tuple[ChainAttempt, ...]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
chain_subprocess_runner = subprocess.run
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def chain_config_path() -> Path:
|
|
113
|
+
"""Return the path to the per-user chain configuration file."""
|
|
114
|
+
return Path.home() / CLAUDE_HOME_SUBDIRECTORY / CONFIG_FILENAME
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _invalid_shape_error(config_path: Path, reason: str) -> ChainConfigurationError:
|
|
118
|
+
return ChainConfigurationError(
|
|
119
|
+
CONFIG_INVALID_SHAPE_MESSAGE_TEMPLATE.format(
|
|
120
|
+
config_path=config_path,
|
|
121
|
+
reason=reason,
|
|
122
|
+
example_filename=EXAMPLE_CONFIG_FILENAME,
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _coerce_extra_args(raw_extra_args: object, config_path: Path) -> tuple[str, ...]:
|
|
128
|
+
if not isinstance(raw_extra_args, list) or not all(
|
|
129
|
+
isinstance(each_argument, str) for each_argument in raw_extra_args
|
|
130
|
+
):
|
|
131
|
+
raise _invalid_shape_error(config_path, CONFIG_ENTRY_EXTRA_ARGS_INVALID_REASON)
|
|
132
|
+
return tuple(raw_extra_args)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _parse_chain_entry(raw_entry: object, config_path: Path) -> ChainEntry:
|
|
136
|
+
if not isinstance(raw_entry, dict):
|
|
137
|
+
raise _invalid_shape_error(config_path, CONFIG_ENTRY_NOT_OBJECT_REASON)
|
|
138
|
+
command = raw_entry.get(CONFIG_COMMAND_KEY)
|
|
139
|
+
if not isinstance(command, str) or not command:
|
|
140
|
+
raise _invalid_shape_error(config_path, CONFIG_ENTRY_COMMAND_MISSING_REASON)
|
|
141
|
+
extra_args = _coerce_extra_args(
|
|
142
|
+
raw_entry.get(CONFIG_EXTRA_ARGS_KEY, []), config_path
|
|
143
|
+
)
|
|
144
|
+
return ChainEntry(command=command, extra_args=extra_args)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _parse_chain_entries(parsed_config: object, config_path: Path) -> list[ChainEntry]:
|
|
148
|
+
if not isinstance(parsed_config, dict):
|
|
149
|
+
raise _invalid_shape_error(config_path, CONFIG_NOT_OBJECT_REASON)
|
|
150
|
+
raw_chain = parsed_config.get(CONFIG_CHAIN_KEY)
|
|
151
|
+
if not isinstance(raw_chain, list):
|
|
152
|
+
raise _invalid_shape_error(config_path, CONFIG_CHAIN_NOT_LIST_REASON)
|
|
153
|
+
if not raw_chain:
|
|
154
|
+
raise _invalid_shape_error(config_path, CONFIG_CHAIN_EMPTY_REASON)
|
|
155
|
+
return [
|
|
156
|
+
_parse_chain_entry(each_raw_entry, config_path) for each_raw_entry in raw_chain
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def load_chain(config_path: Path) -> list[ChainEntry]:
|
|
161
|
+
"""Load the ordered fallback chain from *config_path*.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
config_path: Path to the chain configuration JSON file.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
The ordered list of chain entries the file declares.
|
|
168
|
+
|
|
169
|
+
Raises:
|
|
170
|
+
ChainConfigurationError: When the file is absent, unreadable, not valid
|
|
171
|
+
JSON, or does not match the expected shape.
|
|
172
|
+
"""
|
|
173
|
+
if not config_path.is_file():
|
|
174
|
+
raise ChainConfigurationError(
|
|
175
|
+
CONFIG_MISSING_MESSAGE_TEMPLATE.format(
|
|
176
|
+
config_path=config_path, example_filename=EXAMPLE_CONFIG_FILENAME
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
try:
|
|
180
|
+
raw_text = config_path.read_text(encoding=UTF8_ENCODING)
|
|
181
|
+
except OSError as read_error:
|
|
182
|
+
raise ChainConfigurationError(
|
|
183
|
+
CONFIG_UNREADABLE_MESSAGE_TEMPLATE.format(
|
|
184
|
+
config_path=config_path,
|
|
185
|
+
error=read_error,
|
|
186
|
+
example_filename=EXAMPLE_CONFIG_FILENAME,
|
|
187
|
+
)
|
|
188
|
+
) from read_error
|
|
189
|
+
try:
|
|
190
|
+
parsed_config = json.loads(raw_text)
|
|
191
|
+
except json.JSONDecodeError as decode_error:
|
|
192
|
+
raise ChainConfigurationError(
|
|
193
|
+
CONFIG_MALFORMED_MESSAGE_TEMPLATE.format(
|
|
194
|
+
config_path=config_path,
|
|
195
|
+
error=decode_error,
|
|
196
|
+
example_filename=EXAMPLE_CONFIG_FILENAME,
|
|
197
|
+
)
|
|
198
|
+
) from decode_error
|
|
199
|
+
return _parse_chain_entries(parsed_config, config_path)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _build_invocation(entry: ChainEntry, all_claude_arguments: list[str]) -> list[str]:
|
|
203
|
+
return [entry.command, *all_claude_arguments, *entry.extra_args]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _is_usage_limit_failure(completion: subprocess.CompletedProcess[str]) -> bool:
|
|
207
|
+
combined_text = f"{completion.stdout}{completion.stderr}".lower()
|
|
208
|
+
return any(
|
|
209
|
+
each_signature in combined_text for each_signature in ALL_USAGE_LIMIT_SIGNATURES
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _served_outcome(
|
|
214
|
+
served_command: str,
|
|
215
|
+
completion: subprocess.CompletedProcess[str],
|
|
216
|
+
all_attempts: list[ChainAttempt],
|
|
217
|
+
) -> ChainInvocationOutcome:
|
|
218
|
+
return ChainInvocationOutcome(
|
|
219
|
+
served_command=served_command,
|
|
220
|
+
returncode=completion.returncode,
|
|
221
|
+
stdout=completion.stdout,
|
|
222
|
+
stderr=completion.stderr,
|
|
223
|
+
attempts=tuple(all_attempts),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _timeout_streams(
|
|
228
|
+
timeout_error: subprocess.TimeoutExpired | None,
|
|
229
|
+
) -> tuple[str, str]:
|
|
230
|
+
if timeout_error is None:
|
|
231
|
+
return "", ""
|
|
232
|
+
captured_stdout = (
|
|
233
|
+
timeout_error.stdout if isinstance(timeout_error.stdout, str) else ""
|
|
234
|
+
)
|
|
235
|
+
captured_stderr = (
|
|
236
|
+
timeout_error.stderr if isinstance(timeout_error.stderr, str) else ""
|
|
237
|
+
)
|
|
238
|
+
return captured_stdout, captured_stderr
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _no_process_outcome(
|
|
242
|
+
all_attempts: list[ChainAttempt],
|
|
243
|
+
timeout_error: subprocess.TimeoutExpired | None,
|
|
244
|
+
) -> ChainInvocationOutcome:
|
|
245
|
+
captured_stdout, captured_stderr = _timeout_streams(timeout_error)
|
|
246
|
+
return ChainInvocationOutcome(
|
|
247
|
+
served_command=None,
|
|
248
|
+
returncode=NO_COMPLETED_PROCESS_RETURN_CODE,
|
|
249
|
+
stdout=captured_stdout,
|
|
250
|
+
stderr=captured_stderr,
|
|
251
|
+
attempts=tuple(all_attempts),
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _exhausted_outcome(
|
|
256
|
+
all_attempts: list[ChainAttempt],
|
|
257
|
+
last_usage_limited: subprocess.CompletedProcess[str] | None,
|
|
258
|
+
) -> ChainInvocationOutcome:
|
|
259
|
+
if last_usage_limited is None:
|
|
260
|
+
return _no_process_outcome(all_attempts, None)
|
|
261
|
+
return ChainInvocationOutcome(
|
|
262
|
+
served_command=None,
|
|
263
|
+
returncode=last_usage_limited.returncode,
|
|
264
|
+
stdout=last_usage_limited.stdout,
|
|
265
|
+
stderr=last_usage_limited.stderr,
|
|
266
|
+
attempts=tuple(all_attempts),
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _classify_completion(
|
|
271
|
+
entry: ChainEntry,
|
|
272
|
+
completion: subprocess.CompletedProcess[str],
|
|
273
|
+
all_attempts: list[ChainAttempt],
|
|
274
|
+
) -> ChainInvocationOutcome | None:
|
|
275
|
+
if completion.returncode == 0:
|
|
276
|
+
all_attempts.append(ChainAttempt(entry.command, ATTEMPT_STATUS_SERVED))
|
|
277
|
+
return _served_outcome(entry.command, completion, all_attempts)
|
|
278
|
+
if _is_usage_limit_failure(completion):
|
|
279
|
+
all_attempts.append(ChainAttempt(entry.command, ATTEMPT_STATUS_USAGE_LIMITED))
|
|
280
|
+
return None
|
|
281
|
+
all_attempts.append(ChainAttempt(entry.command, ATTEMPT_STATUS_NONZERO_EXIT))
|
|
282
|
+
return _served_outcome(entry.command, completion, all_attempts)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def run_claude(
|
|
286
|
+
all_claude_arguments: list[str], *, timeout_seconds: int
|
|
287
|
+
) -> ChainInvocationOutcome:
|
|
288
|
+
"""Run *all_claude_arguments* through the configured fallback chain.
|
|
289
|
+
|
|
290
|
+
The leading binary serves the call. Only a usage-limit failure (a non-zero
|
|
291
|
+
exit whose output carries a usage-limit signature) falls over to the next
|
|
292
|
+
binary. A missing fallback binary is skipped and the walk continues. A
|
|
293
|
+
timeout, a missing primary binary, or a non-zero exit without a usage-limit
|
|
294
|
+
signature stops the walk and returns that outcome unchanged.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
all_claude_arguments: Arguments passed after the binary name, such as
|
|
298
|
+
``["-p", prompt, "--strict-mcp-config"]``.
|
|
299
|
+
timeout_seconds: Timeout applied to each binary invocation.
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
The outcome of the walk, naming the serving binary and the full
|
|
303
|
+
attempt trail.
|
|
304
|
+
|
|
305
|
+
Raises:
|
|
306
|
+
ChainConfigurationError: When the chain configuration cannot be loaded.
|
|
307
|
+
"""
|
|
308
|
+
all_entries = load_chain(chain_config_path())
|
|
309
|
+
all_attempts: list[ChainAttempt] = []
|
|
310
|
+
last_usage_limited: subprocess.CompletedProcess[str] | None = None
|
|
311
|
+
for each_index, each_entry in enumerate(all_entries):
|
|
312
|
+
is_primary = each_index == 0
|
|
313
|
+
try:
|
|
314
|
+
completion = chain_subprocess_runner(
|
|
315
|
+
_build_invocation(each_entry, all_claude_arguments),
|
|
316
|
+
capture_output=True,
|
|
317
|
+
text=True,
|
|
318
|
+
timeout=timeout_seconds,
|
|
319
|
+
check=False,
|
|
320
|
+
)
|
|
321
|
+
except subprocess.TimeoutExpired as timeout_error:
|
|
322
|
+
all_attempts.append(
|
|
323
|
+
ChainAttempt(each_entry.command, ATTEMPT_STATUS_TIMEOUT)
|
|
324
|
+
)
|
|
325
|
+
return _no_process_outcome(all_attempts, timeout_error)
|
|
326
|
+
except FileNotFoundError:
|
|
327
|
+
all_attempts.append(
|
|
328
|
+
ChainAttempt(each_entry.command, ATTEMPT_STATUS_EXECUTABLE_NOT_FOUND)
|
|
329
|
+
)
|
|
330
|
+
if is_primary:
|
|
331
|
+
return _no_process_outcome(all_attempts, None)
|
|
332
|
+
continue
|
|
333
|
+
terminal_outcome = _classify_completion(each_entry, completion, all_attempts)
|
|
334
|
+
if terminal_outcome is not None:
|
|
335
|
+
return terminal_outcome
|
|
336
|
+
last_usage_limited = completion
|
|
337
|
+
return _exhausted_outcome(all_attempts, last_usage_limited)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _build_argument_parser() -> argparse.ArgumentParser:
|
|
341
|
+
parser = argparse.ArgumentParser(
|
|
342
|
+
description="Run a claude invocation through the fallback chain."
|
|
343
|
+
)
|
|
344
|
+
parser.add_argument(
|
|
345
|
+
CLI_TIMEOUT_FLAG,
|
|
346
|
+
dest="timeout_seconds",
|
|
347
|
+
type=int,
|
|
348
|
+
default=DEFAULT_TIMEOUT_SECONDS,
|
|
349
|
+
help="Timeout in seconds applied to each binary invocation.",
|
|
350
|
+
)
|
|
351
|
+
parser.add_argument("passthrough", nargs=argparse.REMAINDER)
|
|
352
|
+
return parser
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _strip_leading_separator(all_passthrough: list[str]) -> list[str]:
|
|
356
|
+
if all_passthrough and all_passthrough[0] == CLI_ARGUMENTS_SEPARATOR:
|
|
357
|
+
return all_passthrough[1:]
|
|
358
|
+
return all_passthrough
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _exhausted_message(all_attempts: tuple[ChainAttempt, ...]) -> str:
|
|
362
|
+
attempt_summary = ATTEMPT_SUMMARY_JOIN_SEPARATOR.join(
|
|
363
|
+
ATTEMPT_SUMMARY_ENTRY_TEMPLATE.format(
|
|
364
|
+
command=each_attempt.command, status=each_attempt.status
|
|
365
|
+
)
|
|
366
|
+
for each_attempt in all_attempts
|
|
367
|
+
)
|
|
368
|
+
return CHAIN_EXHAUSTED_MESSAGE_TEMPLATE.format(attempt_summary=attempt_summary)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def main(all_command_arguments: list[str]) -> int:
|
|
372
|
+
"""Walk the chain for CLI arguments and return the process exit code.
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
all_command_arguments: The argument vector after the program name.
|
|
376
|
+
|
|
377
|
+
Returns:
|
|
378
|
+
The served binary's return code, a distinct code when the chain is
|
|
379
|
+
exhausted, or a distinct code when the configuration cannot be loaded.
|
|
380
|
+
"""
|
|
381
|
+
parser = _build_argument_parser()
|
|
382
|
+
parsed_arguments = parser.parse_args(all_command_arguments)
|
|
383
|
+
all_claude_arguments = _strip_leading_separator(parsed_arguments.passthrough)
|
|
384
|
+
try:
|
|
385
|
+
chain_outcome = run_claude(
|
|
386
|
+
all_claude_arguments, timeout_seconds=parsed_arguments.timeout_seconds
|
|
387
|
+
)
|
|
388
|
+
except ChainConfigurationError as configuration_error:
|
|
389
|
+
print(str(configuration_error), file=sys.stderr)
|
|
390
|
+
return CHAIN_CONFIG_ERROR_EXIT_CODE
|
|
391
|
+
if chain_outcome.served_command is None:
|
|
392
|
+
print(_exhausted_message(chain_outcome.attempts), file=sys.stderr)
|
|
393
|
+
return CHAIN_EXHAUSTED_EXIT_CODE
|
|
394
|
+
sys.stdout.write(chain_outcome.stdout)
|
|
395
|
+
sys.stderr.write(chain_outcome.stderr)
|
|
396
|
+
return chain_outcome.returncode
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
if __name__ == "__main__":
|
|
400
|
+
sys.exit(main(sys.argv[1:]))
|
|
@@ -8,6 +8,7 @@ Named constants for scripts in `scripts/`. Follows the project convention that t
|
|
|
8
8
|
|---|---|
|
|
9
9
|
| `timing.py` | `sweep_empty_dirs.py` — `DEFAULT_AGE_SECONDS` (smallest age before an empty directory is eligible for removal) and `DEFAULT_POLL_INTERVAL` (seconds between sweep passes in continuous-watch mode) |
|
|
10
10
|
| `gh_artifact_upload_constants.py` | `gh_artifact_upload.py` — the `artifacts` release tag, title, and notes body, the GitHub CLI binary name, the asset-name timestamp format and template, the asset download URL template, the notes-file suffix, and the text encoding |
|
|
11
|
+
| `claude_chain_constants.py` | `claude_chain_runner.py` — the chain config filename and home subdirectory, the usage-limit signature text, the per-binary status labels, the default timeout, CLI flag and separator tokens, config JSON keys, invalid-shape reason text, config-error and exhausted-chain message templates, and CLI exit codes |
|
|
11
12
|
| `__init__.py` | Empty package marker |
|
|
12
13
|
|
|
13
14
|
## Convention
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Named constants for the claude fallback-chain runner.
|
|
2
|
+
|
|
3
|
+
Per the project's configuration conventions, every scalar and structural
|
|
4
|
+
constant the runner needs lives here rather than inline in the module.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
UTF8_ENCODING: str = "utf-8"
|
|
10
|
+
"""Encoding used to read the chain configuration file."""
|
|
11
|
+
|
|
12
|
+
CLAUDE_HOME_SUBDIRECTORY: str = ".claude"
|
|
13
|
+
"""Per-user directory under the home directory that holds the chain config."""
|
|
14
|
+
|
|
15
|
+
CONFIG_FILENAME: str = "claude-chain.json"
|
|
16
|
+
"""Real chain-configuration filename read from the user's home directory."""
|
|
17
|
+
|
|
18
|
+
EXAMPLE_CONFIG_FILENAME: str = "claude-chain.example.json"
|
|
19
|
+
"""Committed template filename referenced in the config-error guidance."""
|
|
20
|
+
|
|
21
|
+
CONFIG_CHAIN_KEY: str = "chain"
|
|
22
|
+
"""Top-level key whose value is the ordered list of chain entries."""
|
|
23
|
+
|
|
24
|
+
CONFIG_COMMAND_KEY: str = "command"
|
|
25
|
+
"""Chain-entry key naming the binary to spawn."""
|
|
26
|
+
|
|
27
|
+
CONFIG_EXTRA_ARGS_KEY: str = "extra_args"
|
|
28
|
+
"""Chain-entry key holding per-account arguments appended to each invocation."""
|
|
29
|
+
|
|
30
|
+
ALL_USAGE_LIMIT_SIGNATURES: tuple[str, ...] = (
|
|
31
|
+
"hit your session limit",
|
|
32
|
+
"usage limit reached",
|
|
33
|
+
"out of usage",
|
|
34
|
+
"usage quota exceeded",
|
|
35
|
+
)
|
|
36
|
+
"""Case-insensitive substrings that mark a non-zero exit as a usage-limit refusal."""
|
|
37
|
+
|
|
38
|
+
ATTEMPT_STATUS_SERVED: str = "served"
|
|
39
|
+
"""Status recorded when a binary exits zero and serves the call."""
|
|
40
|
+
|
|
41
|
+
ATTEMPT_STATUS_USAGE_LIMITED: str = "usage_limited"
|
|
42
|
+
"""Status recorded when a binary fails with a usage-limit signature."""
|
|
43
|
+
|
|
44
|
+
ATTEMPT_STATUS_EXECUTABLE_NOT_FOUND: str = "executable_not_found"
|
|
45
|
+
"""Status recorded when a binary is not installed."""
|
|
46
|
+
|
|
47
|
+
ATTEMPT_STATUS_NONZERO_EXIT: str = "nonzero_exit"
|
|
48
|
+
"""Status recorded when a binary fails without a usage-limit signature."""
|
|
49
|
+
|
|
50
|
+
ATTEMPT_STATUS_TIMEOUT: str = "timeout"
|
|
51
|
+
"""Status recorded when a binary exceeds the invocation timeout."""
|
|
52
|
+
|
|
53
|
+
DEFAULT_TIMEOUT_SECONDS: int = 300
|
|
54
|
+
"""Timeout applied to each binary invocation when the caller names none."""
|
|
55
|
+
|
|
56
|
+
NO_COMPLETED_PROCESS_RETURN_CODE: int = 1
|
|
57
|
+
"""Return code carried on the result when no binary produced a completed process."""
|
|
58
|
+
|
|
59
|
+
CHAIN_EXHAUSTED_EXIT_CODE: int = 2
|
|
60
|
+
"""CLI exit code when no binary in the chain served the call."""
|
|
61
|
+
|
|
62
|
+
CHAIN_CONFIG_ERROR_EXIT_CODE: int = 3
|
|
63
|
+
"""CLI exit code when the chain configuration is missing or invalid."""
|
|
64
|
+
|
|
65
|
+
CLI_TIMEOUT_FLAG: str = "--timeout-seconds"
|
|
66
|
+
"""CLI flag that overrides the per-invocation timeout in seconds."""
|
|
67
|
+
|
|
68
|
+
CLI_ARGUMENTS_SEPARATOR: str = "--"
|
|
69
|
+
"""CLI token separating runner flags from the passthrough claude arguments."""
|
|
70
|
+
|
|
71
|
+
CONFIG_NOT_OBJECT_REASON: str = "the top-level value is not a JSON object"
|
|
72
|
+
"""Reason detail when the config root is not an object."""
|
|
73
|
+
|
|
74
|
+
CONFIG_CHAIN_NOT_LIST_REASON: str = "the 'chain' key is missing or not a list"
|
|
75
|
+
"""Reason detail when the chain key is absent or the wrong type."""
|
|
76
|
+
|
|
77
|
+
CONFIG_CHAIN_EMPTY_REASON: str = "the 'chain' list is empty"
|
|
78
|
+
"""Reason detail when the chain contains no entries."""
|
|
79
|
+
|
|
80
|
+
CONFIG_ENTRY_NOT_OBJECT_REASON: str = "a chain entry is not a JSON object"
|
|
81
|
+
"""Reason detail when a chain entry is not an object."""
|
|
82
|
+
|
|
83
|
+
CONFIG_ENTRY_COMMAND_MISSING_REASON: str = "a chain entry has no string 'command'"
|
|
84
|
+
"""Reason detail when a chain entry lacks a usable command."""
|
|
85
|
+
|
|
86
|
+
CONFIG_ENTRY_EXTRA_ARGS_INVALID_REASON: str = (
|
|
87
|
+
"a chain entry's 'extra_args' is not a list of strings"
|
|
88
|
+
)
|
|
89
|
+
"""Reason detail when a chain entry's extra_args value is the wrong shape."""
|
|
90
|
+
|
|
91
|
+
CONFIG_MISSING_MESSAGE_TEMPLATE: str = (
|
|
92
|
+
"Claude chain config not found at {config_path}. Copy {example_filename} to "
|
|
93
|
+
"{config_path} and list your logged-in claude binaries in fallback order."
|
|
94
|
+
)
|
|
95
|
+
"""Guidance shown when the config file is absent."""
|
|
96
|
+
|
|
97
|
+
CONFIG_UNREADABLE_MESSAGE_TEMPLATE: str = (
|
|
98
|
+
"Cannot read claude chain config at {config_path}: {error}. "
|
|
99
|
+
"See {example_filename} for the expected shape."
|
|
100
|
+
)
|
|
101
|
+
"""Guidance shown when the config file cannot be read."""
|
|
102
|
+
|
|
103
|
+
CONFIG_MALFORMED_MESSAGE_TEMPLATE: str = (
|
|
104
|
+
"Malformed JSON in claude chain config at {config_path}: {error}. "
|
|
105
|
+
"See {example_filename} for the expected shape."
|
|
106
|
+
)
|
|
107
|
+
"""Guidance shown when the config file is not valid JSON."""
|
|
108
|
+
|
|
109
|
+
CONFIG_INVALID_SHAPE_MESSAGE_TEMPLATE: str = (
|
|
110
|
+
"Invalid claude chain config at {config_path}: {reason}. "
|
|
111
|
+
"See {example_filename} for the expected shape."
|
|
112
|
+
)
|
|
113
|
+
"""Guidance shown when the config JSON does not match the expected shape."""
|
|
114
|
+
|
|
115
|
+
CHAIN_EXHAUSTED_MESSAGE_TEMPLATE: str = (
|
|
116
|
+
"No claude binary in the chain served the call. Attempts: {attempt_summary}"
|
|
117
|
+
)
|
|
118
|
+
"""CLI stderr message when the walk ends without a serving binary."""
|
|
119
|
+
|
|
120
|
+
ATTEMPT_SUMMARY_ENTRY_TEMPLATE: str = "{command}={status}"
|
|
121
|
+
"""Per-attempt fragment used to build the exhausted-chain summary."""
|
|
122
|
+
|
|
123
|
+
ATTEMPT_SUMMARY_JOIN_SEPARATOR: str = ", "
|
|
124
|
+
"""Separator joining per-attempt fragments in the exhausted-chain summary."""
|