claude-dev-env 1.89.0 → 1.92.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +0 -13
- package/_shared/pr-loop/scripts/code_rules_gate.py +106 -14
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +26 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +160 -0
- 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 +6 -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/test_volatile_path_in_post_blocker.py +210 -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/blocking/volatile_path_in_post_blocker.py +351 -0
- package/hooks/hooks.json +32 -2
- package/hooks/hooks_constants/CLAUDE.md +4 -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/enter_worktree_prefetch_constants.py +18 -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/hooks_constants/volatile_path_in_post_blocker_constants.py +48 -0
- package/hooks/lifecycle/CLAUDE.md +3 -1
- package/hooks/lifecycle/enter_worktree_origin_prefetch.py +146 -0
- package/hooks/lifecycle/test_enter_worktree_origin_prefetch.py +178 -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 +3 -0
- package/rules/durable-post-artifacts.md +35 -0
- package/rules/nas-ssh-invocation.md +21 -0
- package/rules/proof-of-work-pr-comments.md +26 -0
- package/scripts/CLAUDE.md +2 -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 +2 -0
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +124 -0
- package/scripts/dev_env_scripts_constants/gh_artifact_upload_constants.py +43 -0
- package/scripts/gh_artifact_upload.py +256 -0
- package/scripts/sync_to_cursor/rules.py +1 -1
- package/scripts/test_claude_chain_runner.py +472 -0
- package/scripts/tests/test_gh_artifact_upload.py +205 -0
- package/skills/CLAUDE.md +3 -0
- package/skills/team-advisor/SKILL.md +188 -0
- package/skills/team-advisor-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,102 @@
|
|
|
1
|
+
"""Behavioral tests for the run_all_validators PreToolUse gate mode.
|
|
2
|
+
|
|
3
|
+
The gate mode validates the proposed post-edit content of the single file a
|
|
4
|
+
Write, Edit, or MultiEdit would produce and emits a PreToolUse deny decision
|
|
5
|
+
when that content violates a validator, rather than grading the whole branch.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from unittest.mock import patch
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from .run_all_validators import main, run_validators_entrypoint_subprocess
|
|
17
|
+
|
|
18
|
+
CLEAN_PYTHON_SOURCE = (
|
|
19
|
+
"def add_two_numbers(first_number: int, second_number: int) -> int:\n"
|
|
20
|
+
" return first_number + second_number\n"
|
|
21
|
+
)
|
|
22
|
+
VIOLATING_PYTHON_SOURCE = (
|
|
23
|
+
"def calculate_total_price(unit_price: int, quantity: int) -> int:\n"
|
|
24
|
+
" return unit_price * quantity * 199 * 42\n"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def run_gate(payload: dict[str, object]) -> "subprocess.CompletedProcess[str]":
|
|
29
|
+
return run_validators_entrypoint_subprocess(["--pre-tool-use"], stdin_text=json.dumps(payload))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TestPreToolUseGate:
|
|
33
|
+
def test_write_with_violating_content_denies(self) -> None:
|
|
34
|
+
completed = run_gate(
|
|
35
|
+
{
|
|
36
|
+
"tool_name": "Write",
|
|
37
|
+
"tool_input": {
|
|
38
|
+
"file_path": "calculate.py",
|
|
39
|
+
"content": VIOLATING_PYTHON_SOURCE,
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
assert completed.returncode == 0, completed.stderr
|
|
44
|
+
assert '"permissionDecision": "deny"' in completed.stdout
|
|
45
|
+
assert "Magic Values" in completed.stdout
|
|
46
|
+
|
|
47
|
+
def test_write_with_clean_content_allows(self) -> None:
|
|
48
|
+
completed = run_gate(
|
|
49
|
+
{
|
|
50
|
+
"tool_name": "Write",
|
|
51
|
+
"tool_input": {
|
|
52
|
+
"file_path": "add.py",
|
|
53
|
+
"content": CLEAN_PYTHON_SOURCE,
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
assert completed.returncode == 0, completed.stderr
|
|
58
|
+
assert "deny" not in completed.stdout
|
|
59
|
+
|
|
60
|
+
def test_edit_validates_reconstructed_post_edit_content(self, tmp_path: Path) -> None:
|
|
61
|
+
target_file = tmp_path / "calculate.py"
|
|
62
|
+
target_file.write_text(CLEAN_PYTHON_SOURCE, encoding="utf-8")
|
|
63
|
+
completed = run_gate(
|
|
64
|
+
{
|
|
65
|
+
"tool_name": "Edit",
|
|
66
|
+
"tool_input": {
|
|
67
|
+
"file_path": str(target_file),
|
|
68
|
+
"old_string": " return first_number + second_number\n",
|
|
69
|
+
"new_string": " return first_number + second_number + 199 * 42\n",
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
assert completed.returncode == 0, completed.stderr
|
|
74
|
+
assert '"permissionDecision": "deny"' in completed.stdout
|
|
75
|
+
|
|
76
|
+
def test_unparseable_payload_exits_silently(self) -> None:
|
|
77
|
+
completed = run_validators_entrypoint_subprocess(
|
|
78
|
+
["--pre-tool-use"], stdin_text="not json at all"
|
|
79
|
+
)
|
|
80
|
+
assert completed.returncode == 0, completed.stderr
|
|
81
|
+
assert "deny" not in completed.stdout
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class TestCliModeRegression:
|
|
85
|
+
def test_cli_mode_reports_violations_and_exits_one(
|
|
86
|
+
self, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
87
|
+
) -> None:
|
|
88
|
+
violating_file = tmp_path / "calculate.py"
|
|
89
|
+
violating_file.write_text(VIOLATING_PYTHON_SOURCE, encoding="utf-8")
|
|
90
|
+
with patch(
|
|
91
|
+
"validators.run_all_validators.get_changed_files",
|
|
92
|
+
return_value=[violating_file],
|
|
93
|
+
):
|
|
94
|
+
original_argv = sys.argv
|
|
95
|
+
try:
|
|
96
|
+
sys.argv = ["run_all_validators.py"]
|
|
97
|
+
exit_code = main()
|
|
98
|
+
finally:
|
|
99
|
+
sys.argv = original_argv
|
|
100
|
+
captured = capsys.readouterr()
|
|
101
|
+
assert exit_code == 1
|
|
102
|
+
assert "PRE-PUSH VALIDATOR RESULTS" in captured.out
|
package/package.json
CHANGED
package/rules/CLAUDE.md
CHANGED
|
@@ -17,6 +17,7 @@ Rule files installed into `~/.claude/rules/` by `bin/install.mjs`. Claude Code l
|
|
|
17
17
|
| `conservative-action.md` | Research and recommend when intent is ambiguous; act only on explicit request |
|
|
18
18
|
| `context7.md` | Use Context7 MCP to fetch current library docs; always prefer live docs over built-in knowledge |
|
|
19
19
|
| `docstring-prose-matches-implementation.md` | Prose enumerations in docstrings cover every behavior the body applies |
|
|
20
|
+
| `durable-post-artifacts.md` | GitHub post bodies never reference volatile scratch paths; text embeds inline and binary artifacts upload to the `artifacts` release with the permanent URL linked |
|
|
20
21
|
| `explore-thoroughly.md` | Read relevant files and map existing patterns before proposing a change |
|
|
21
22
|
| `file-global-constants.md` | File-global constants need at least two same-file references; otherwise move value to `config/` |
|
|
22
23
|
| `gh-body-file.md` | Use `--body-file` with a temp file for all `gh` commands carrying markdown body content |
|
|
@@ -24,6 +25,7 @@ Rule files installed into `~/.claude/rules/` by `bin/install.mjs`. Claude Code l
|
|
|
24
25
|
| `git-workflow.md` | PR workflow: always create as draft, one commit per review stage, never commit working docs or images |
|
|
25
26
|
| `hook-prose-matches-detector.md` | Hook prose descriptions match what the hook actually detects |
|
|
26
27
|
| `long-horizon-autonomy.md` | Autonomous-run behaviors: act on what you have, do not end on a promise, delegate and keep working |
|
|
28
|
+
| `nas-ssh-invocation.md` | Reach the NAS at `192.168.1.100` through the `System32/OpenSSH` binary with `-o BatchMode=yes`; bare `ssh`/`scp`/`sftp` stalls on an interactive password prompt |
|
|
27
29
|
| `no-cross-skill-duplicate-helpers.md` | No duplicating shared helpers across skills; use `_shared/` |
|
|
28
30
|
| `no-historical-clutter.md` | Documentation describes current state only; no historical or transitional language |
|
|
29
31
|
| `no-inline-destructive-literals.md` | No destructive-command literals in Bash tool command strings, even as data |
|
|
@@ -36,6 +38,7 @@ Rule files installed into `~/.claude/rules/` by `bin/install.mjs`. Claude Code l
|
|
|
36
38
|
| `plain-illustrative-docstrings.md` | Public docstring narrative reads plainly and shows behavior with a diagram block (a `::` example or a doctest), painting a concrete scene a general developer follows on first read; a run-on backstop hook, a prose-wall backstop hook, and Category O9 audit enforce it |
|
|
37
39
|
| `plain-language.md` | Everyday words, short active sentences, lead with the answer |
|
|
38
40
|
| `prompt-workflow-context-controls.md` | Keep prompt-workflow instruction layers small and stable; load heavy skills on demand |
|
|
41
|
+
| `proof-of-work-pr-comments.md` | Every PR carries one five-part proof-of-work comment before it leaves draft; the `pr_description_enforcer` hook audits proof-shaped comments and gates `gh pr ready` |
|
|
39
42
|
| `re-stage-before-commit.md` | Stage the files edited this session before `git commit`; the session edit stage gate denies a commit that leaves a tracked session edit unstaged, with `-a`, a pathspec, a preceding `git add`, and `# partial-commit` as escapes |
|
|
40
43
|
| `research-mode.md` | Three anti-hallucination constraints: say "I don't know", verify with citations, quote for factual grounding |
|
|
41
44
|
| `right-sized-engineering.md` | Simple over clever; functions over classes; concrete over abstract |
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Durable Post Artifacts
|
|
2
|
+
|
|
3
|
+
**When this applies:** Any GitHub post that lives on the server — an issue, a pull request, a comment, or a review — created through `gh` (`gh pr create/comment/edit/review`, `gh issue create/comment/edit`) or a GitHub MCP post tool.
|
|
4
|
+
|
|
5
|
+
## Rule
|
|
6
|
+
|
|
7
|
+
A post lives forever. Job scratch directories, worktrees, and system temp folders do not — they are cleaned soon after the run that made them. So a post must never point at a path in that scratch. The moment the directory is cleaned, the reference breaks and the reader is left with a dead path.
|
|
8
|
+
|
|
9
|
+
Handle the two kinds of content differently:
|
|
10
|
+
|
|
11
|
+
- **Text data** (logs, tables, diffs, stack traces): paste the actual text inline in the post body. Do not link a scratch file that holds it.
|
|
12
|
+
- **Binary artifacts** (images, screenshots, archives): upload the file to the repository's durable `artifacts` release and link the permanent URL. Use the helper:
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
python3 ~/.claude/scripts/gh_artifact_upload.py <file-path> <owner/repo>
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
It ensures the repository has a prerelease tagged `artifacts`, uploads the file under a `YYYYMMDD_HHMMSS_<name>` asset name, and prints the permanent download URL. Put that URL in the post.
|
|
19
|
+
|
|
20
|
+
## Volatile paths that must not appear in a post body
|
|
21
|
+
|
|
22
|
+
- A job scratch directory (`.claude-editor/jobs/`)
|
|
23
|
+
- A worktree (`.claude/worktrees/`)
|
|
24
|
+
- A system temp location (`AppData\Local\Temp`, `%TEMP%`, `$env:TEMP`, `/tmp/`)
|
|
25
|
+
- The job scratch environment variable (`$CLAUDE_JOB_DIR`)
|
|
26
|
+
|
|
27
|
+
Both slash directions count.
|
|
28
|
+
|
|
29
|
+
## Enforcement
|
|
30
|
+
|
|
31
|
+
The `volatile_path_in_post_blocker` PreToolUse hook reads the body of each `gh` post command and each GitHub MCP post call, scans it for these markers, and blocks the post when it finds one. For a `--body-file`, the hook reads the file and scans its contents, so writing the body to a temp file and passing it with `--body-file` stays allowed — what the hook rejects is a volatile path inside the text that gets posted.
|
|
32
|
+
|
|
33
|
+
## Why
|
|
34
|
+
|
|
35
|
+
A comment that cites an artifact under a job's tmp directory reads fine the moment it is posted and breaks a few minutes later, once the job is cleaned. Embedding text inline and linking binary artifacts to a durable release keeps every post readable for as long as it exists.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# NAS SSH Invocation Policy
|
|
2
|
+
|
|
3
|
+
**When this applies:** Any `ssh`, `scp`, or `sftp` command against the NAS at `192.168.1.100`.
|
|
4
|
+
|
|
5
|
+
## Rule
|
|
6
|
+
|
|
7
|
+
Reach the NAS through the Windows OpenSSH binary with batch mode on. Git Bash's MSYS `ssh` reads `~/.ssh/id_ed25519` as world-readable through its ACL mapping, rejects the key as bad permissions, offers no key, and falls back to an interactive password prompt. In an unattended session no one answers that prompt, so the session hangs. The `System32/OpenSSH` binary authenticates the same key without a prompt.
|
|
8
|
+
|
|
9
|
+
Use this form for every NAS ssh command:
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
"/c/Windows/System32/OpenSSH/ssh.exe" -o BatchMode=yes -o ConnectTimeout=10 -p 9222 jon@192.168.1.100 "<cmd>"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`scp` and `sftp` take the matching `System32/OpenSSH` binary, `-o BatchMode=yes`, and port `9222` (`-P` for `scp`).
|
|
16
|
+
|
|
17
|
+
`-o BatchMode=yes` is required, not optional: it turns a key-authentication failure into a loud non-zero exit rather than a silent password prompt, so an auth regression surfaces as an error you can read.
|
|
18
|
+
|
|
19
|
+
## Enforcement
|
|
20
|
+
|
|
21
|
+
`nas_ssh_binary_enforcer.py` (PreToolUse on Bash) denies a bare `ssh`/`scp`/`sftp` command word aimed at `192.168.1.100` and points at the full-binary form. It also denies the full `System32/OpenSSH` binary to that host when the command omits `-o BatchMode=yes`. Commands to any other host, and commands that mention the address without an ssh-family command word, pass.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Proof-of-Work PR Comments
|
|
2
|
+
|
|
3
|
+
**When this applies:** Every pull request. One comment on the PR carries the proof of the work — posted after `gh pr create` or after a material new commit, and always before the PR leaves draft.
|
|
4
|
+
|
|
5
|
+
## The standard
|
|
6
|
+
|
|
7
|
+
A proof comment has five parts:
|
|
8
|
+
|
|
9
|
+
1. **The exact commands run on real data.** A fenced code block showing the command(s) that produced the artifact — not test output alone.
|
|
10
|
+
2. **Measured outcomes.** Numbers read from the produced artifact — counts, dimensions, hashes, byte sizes, rankings — as a table or a bullet list of facts.
|
|
11
|
+
3. **Plan linkage.** One sentence naming the parent issue or phase the PR advances, with the issue reference (for example: "Advances phase 2 of issue #12").
|
|
12
|
+
4. **Visual evidence for values a human cannot read at a glance.** When the change is visual (the diff touches images, HTML, CSS, or hex color values), embed an image: hex colors as inline swatch images (``), coordinates as marked-up screenshots, size changes as before/after pairs. A wall of raw values fails the standard even when every value is correct.
|
|
13
|
+
5. **Honest gaps.** State plainly what the offline proof cannot show, and what covers that gap.
|
|
14
|
+
|
|
15
|
+
## Enforcement
|
|
16
|
+
|
|
17
|
+
The `pr_description_enforcer` hook enforces the standard at two points:
|
|
18
|
+
|
|
19
|
+
- **On `gh pr comment`:** a comment body whose heading names proof or verification is audited for the five parts. The audit looks for a fenced code block with content, a table row or bullet line carrying a number, a line pairing an issue reference (`#123`) with a linkage word (issue, phase, plan, parent, advances, milestone, part of), an image embed when the PR diff is visual, and a gap phrase (gap, limitation, cannot, does not show, not shown, unverified, not covered). The block message names each missing part.
|
|
20
|
+
- **On `gh pr ready`:** the hook reads the PR's comments and blocks readying while no comment passes the audit. `gh pr ready --undo` returns a PR to draft and is never blocked.
|
|
21
|
+
|
|
22
|
+
A `gh` failure (network, auth, missing executable) never blocks — the gate fails open on tooling problems, and the comment audit skips bodies it cannot read.
|
|
23
|
+
|
|
24
|
+
## Why
|
|
25
|
+
|
|
26
|
+
A PR body says what changed; the proof comment shows that it worked. Real command output, measured numbers, and a rendered image let a reviewer check the claim in seconds, with no need to re-run the work. Stating the gaps keeps the proof honest: the reviewer knows exactly what still rests on trust and where that is covered. Gating draft-to-ready makes the comment land before review starts, on every machine, whatever the session's habits.
|
package/scripts/CLAUDE.md
CHANGED
|
@@ -6,6 +6,8 @@ Utility scripts installed into `~/.claude/scripts/` by `bin/install.mjs`. Each s
|
|
|
6
6
|
|
|
7
7
|
| File | Purpose |
|
|
8
8
|
|---|---|
|
|
9
|
+
| `claude_chain_runner.py` | Runs a `claude` invocation through a config-driven fallback chain (`~/.claude/claude-chain.json`): the leading binary serves the call, and only a usage-limit failure falls over to the next logged-in binary; usable as an imported module (`run_claude`) or a CLI. Copy `claude-chain.example.json` to `~/.claude/claude-chain.json` and list your binaries in fallback order |
|
|
10
|
+
| `gh_artifact_upload.py` | Uploads a file to a repo's durable `artifacts` prerelease under a timestamped asset name and prints the permanent download URL a GitHub post can link |
|
|
9
11
|
| `setup_project_paths.py` | One-time bootstrap: discovers git repos via `es.exe` (Everything) and writes `~/.claude/project-paths.json`; never hardcodes scan roots |
|
|
10
12
|
| `sweep_empty_dirs.py` | Deletes empty directories older than a configurable age under a given root; runs once (`--once`) or in continuous-watch mode |
|
|
11
13
|
| `sync_to_cursor.py` | Entry point for syncing Claude rules to Cursor `.mdc` files; delegates to the `sync_to_cursor/` package |
|
|
@@ -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:]))
|
|
@@ -7,6 +7,8 @@ Named constants for scripts in `scripts/`. Follows the project convention that t
|
|
|
7
7
|
| File | Constants for |
|
|
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
|
+
| `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 |
|
|
10
12
|
| `__init__.py` | Empty package marker |
|
|
11
13
|
|
|
12
14
|
## Convention
|