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.
Files changed (77) hide show
  1. package/CLAUDE.md +0 -13
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +106 -14
  3. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +26 -0
  4. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +160 -0
  5. package/agents/clean-coder.md +2 -2
  6. package/agents/code-verifier.md +0 -1
  7. package/agents/test_agent_frontmatter.py +78 -0
  8. package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +1 -1
  9. package/audit-rubrics/prompts/category-j-code-rules-compliance.md +1 -1
  10. package/bin/install.mjs +1 -0
  11. package/docs/CODE_RULES.md +2 -2
  12. package/hooks/blocking/CLAUDE.md +6 -2
  13. package/hooks/blocking/code_rules_comments.py +2 -2
  14. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +44 -0
  15. package/hooks/blocking/config/verified_commit_constants.py +2 -0
  16. package/hooks/blocking/conftest.py +115 -0
  17. package/hooks/blocking/nas_ssh_binary_enforcer.py +191 -0
  18. package/hooks/blocking/pr_description_enforcer.py +46 -22
  19. package/hooks/blocking/pr_description_pr_number.py +5 -3
  20. package/hooks/blocking/pr_description_proof_of_work.py +367 -0
  21. package/hooks/blocking/precommit_code_rules_gate.py +5 -1
  22. package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +8 -2
  23. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +71 -0
  24. package/hooks/blocking/test_nas_ssh_binary_enforcer.py +168 -0
  25. package/hooks/blocking/test_pr_description_enforcer_proof_gate.py +175 -0
  26. package/hooks/blocking/test_pr_description_proof_of_work.py +162 -0
  27. package/hooks/blocking/test_precommit_code_rules_gate.py +89 -0
  28. package/hooks/blocking/test_verdict_directory_write_blocker.py +4 -0
  29. package/hooks/blocking/test_verification_verdict_store.py +11 -0
  30. package/hooks/blocking/test_verified_commit_config_bootstrap.py +49 -0
  31. package/hooks/blocking/test_verified_commit_gate.py +11 -0
  32. package/hooks/blocking/test_verifier_verdict_minter.py +11 -0
  33. package/hooks/blocking/test_volatile_path_in_post_blocker.py +210 -0
  34. package/hooks/blocking/verdict_directory_write_blocker.py +6 -0
  35. package/hooks/blocking/verification_verdict_store.py +73 -5
  36. package/hooks/blocking/verified_commit_config_bootstrap.py +51 -0
  37. package/hooks/blocking/verified_commit_gate.py +6 -0
  38. package/hooks/blocking/verifier_verdict_minter.py +6 -0
  39. package/hooks/blocking/volatile_path_in_post_blocker.py +351 -0
  40. package/hooks/hooks.json +32 -2
  41. package/hooks/hooks_constants/CLAUDE.md +4 -0
  42. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
  43. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +3 -0
  44. package/hooks/hooks_constants/enter_worktree_prefetch_constants.py +18 -0
  45. package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +59 -0
  46. package/hooks/hooks_constants/pr_description_enforcer_constants.py +2 -0
  47. package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +111 -0
  48. package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +48 -0
  49. package/hooks/lifecycle/CLAUDE.md +3 -1
  50. package/hooks/lifecycle/enter_worktree_origin_prefetch.py +146 -0
  51. package/hooks/lifecycle/test_enter_worktree_origin_prefetch.py +178 -0
  52. package/hooks/validators/run_all_validators.py +216 -4
  53. package/hooks/validators/test_run_all_validators_pretooluse.py +102 -0
  54. package/package.json +1 -1
  55. package/rules/CLAUDE.md +3 -0
  56. package/rules/durable-post-artifacts.md +35 -0
  57. package/rules/nas-ssh-invocation.md +21 -0
  58. package/rules/proof-of-work-pr-comments.md +26 -0
  59. package/scripts/CLAUDE.md +2 -0
  60. package/scripts/claude-chain.example.json +8 -0
  61. package/scripts/claude_chain_runner.py +400 -0
  62. package/scripts/dev_env_scripts_constants/CLAUDE.md +2 -0
  63. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +124 -0
  64. package/scripts/dev_env_scripts_constants/gh_artifact_upload_constants.py +43 -0
  65. package/scripts/gh_artifact_upload.py +256 -0
  66. package/scripts/sync_to_cursor/rules.py +1 -1
  67. package/scripts/test_claude_chain_runner.py +472 -0
  68. package/scripts/tests/test_gh_artifact_upload.py +205 -0
  69. package/skills/CLAUDE.md +3 -0
  70. package/skills/team-advisor/SKILL.md +188 -0
  71. package/skills/team-advisor-refresh/SKILL.md +25 -0
  72. package/skills/usage-pause/SKILL.md +108 -0
  73. package/skills/usage-pause/scripts/resolve_usage_window.py +462 -0
  74. package/skills/usage-pause/scripts/test_resolve_usage_window.py +278 -0
  75. package/skills/usage-pause/scripts/usage_pause_constants/__init__.py +1 -0
  76. package/skills/usage-pause/scripts/usage_pause_constants/resolve_usage_window_constants.py +65 -0
  77. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "1.89.0",
3
+ "version": "1.92.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
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 (`![](https://placehold.co/20x20/RRGGBB/RRGGBB.png)`), 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,8 @@
1
+ {
2
+ "chain": [
3
+ { "command": "claude", "extra_args": [] },
4
+ { "command": "claude-1", "extra_args": [] },
5
+ { "command": "claude-2", "extra_args": [] },
6
+ { "command": "claude-3", "extra_args": [] }
7
+ ]
8
+ }
@@ -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