From 0bb4633201fc5f28c6773e2f1c421bc66f986f7a Mon Sep 17 00:00:00 2001 From: skateddu Date: Thu, 13 Aug 2026 14:53:02 +0200 Subject: [PATCH 1/3] ci: add lint and configuration validation workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing verified this repository until now: every PR merged without an automated check. Beyond ruff, the workflow checks the failure modes that break the template silently for whoever copies it — a hook whose script was renamed stops firing, a broken CLAUDE.md @-import drops that rule from context, and a skill missing frontmatter becomes undiscoverable. None of those surface an error at runtime. shellcheck covers the hooks because quoting and boundary bugs in them have shipped twice before. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 46 +++++++++++ CHANGELOG.md | 3 + README.md | 24 ++++++ pyproject.toml | 4 +- scripts/validate_config.py | 152 +++++++++++++++++++++++++++++++++++++ 5 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml create mode 100644 scripts/validate_config.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..eecec09 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,46 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate: + name: Lint and validate configuration + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v7 + + - name: Install uv + uses: astral-sh/setup-uv@v10 + with: + enable-cache: true + + - name: Sync dependencies + run: uv sync + + - name: Lint + run: uv run ruff check . + + - name: Check formatting + run: uv run ruff format --check . + + # Catches the failure modes that break the template silently for whoever + # copies it: unparseable config, hooks pointing at deleted scripts, + # dropped CLAUDE.md rule imports, skills Claude can no longer discover. + - name: Validate Claude Code configuration + run: uv run python scripts/validate_config.py + + # The hooks are the enforced guardrails, and shell quoting bugs in them + # have shipped before. Warning severity skips style nitpicks. + - name: Shellcheck hooks + run: shellcheck --severity=warning .claude/hooks/*.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index b1f0f46..a9d9620 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- **CI** (`.github/workflows/ci.yml`): a GitHub Actions workflow on pull requests and pushes to `main`. Until now nothing verified this repository at all — every PR merged without a single automated check. It runs ruff (lint + format), `shellcheck --severity=warning` over the hook scripts, and a new configuration validator +- **Scripts** (`scripts/validate_config.py`): validates that the configuration this template ships is internally coherent — `settings.json` and the MCP configs parse; hook scripts referenced by `settings.json` exist on disk; `CLAUDE.md`'s `@`-imports resolve; every skill's `SKILL.md` still declares `name` and `description`. All but the first fail **silently** at runtime: a hook whose script was renamed just stops firing, a broken `@`-import drops that rule from Claude's context, and a skill missing frontmatter becomes undiscoverable — none of which surfaces an error. Verified by injecting each fault and confirming a non-zero exit + - **README.md**: a "Bash Sandbox" section documenting Claude Code's OS-enforced filesystem and network isolation for Bash commands, with a paste-ready `sandbox` block for a uv-based Python project (allowlists PyPI and GitHub so `uv sync`/`uv add`/`git` don't prompt; denies reads of `~/.aws/credentials` and `~/.ssh`). Deliberately **not** enabled in `.claude/settings.json`: the sandbox doesn't run on native Windows, and enabling it in checked-in project settings would produce a startup warning for every Windows contributor — so the section recommends user-level settings instead. Documents the two footguns worth knowing up front: there's no built-in credential deny list (only what you list is protected), and the `dangerouslyDisableSandbox` retry can put a failed command back outside the boundary unless `allowUnsandboxedCommands` is `false` - **README.md**: added `bubblewrap` + `socat` to the optional dependencies table — needed for the sandbox on Linux/WSL2, while macOS uses the built-in Seatbelt framework - **.env.example** / **README.md**: six Claude Code tuning variables. `BASH_DEFAULT_TIMEOUT_MS` (default `120000`) and `BASH_MAX_OUTPUT_LENGTH` (default `30000`, max `150000`) matter for this template specifically — a test suite running over two minutes gets killed mid-run, and verbose `pytest -v` output can be truncated before the failure summary. The other four are listed as explicit defaults: `CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH` (`3`), `CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS` (`20`, v2.1.217+), `CLAUDE_CODE_MAX_WEB_SEARCHES_PER_SESSION` (`200`, v2.1.212+ — shared across the main conversation and every subagent, so parallel research fan-outs draw on one budget; raisable but not disableable, and `/clear` resets it), and `CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS` (`120000`, v2.1.212+ — how long a main-conversation MCP call runs before moving to a background task; `0` disables, and subagent calls are never backgrounded) diff --git a/README.md b/README.md index b78efa1..1ad827e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # Claude Code Python Setup +[![CI](https://github.com/skateddu/claude-code-python-setup/actions/workflows/ci.yml/badge.svg)](https://github.com/skateddu/claude-code-python-setup/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/github/license/skateddu/claude-code-python-setup)](LICENSE) [![Python >= 3.10](https://img.shields.io/badge/python-%3E%3D3.10-blue)](https://www.python.org/) [![GitHub stars](https://img.shields.io/github/stars/skateddu/claude-code-python-setup)](https://github.com/skateddu/claude-code-python-setup/stargazers) @@ -140,9 +141,14 @@ claude-code-python-setup/ │ ├── skill-creator/ │ ├── webapp-testing/ │ └── xlsx/ +├── .github/ +│ └── workflows/ +│ └── ci.yml # Lint + configuration validation on every PR ├── mcp_config/ │ ├── linux_mac.mcp.json # MCP server config (Linux/Mac) │ └── windows.mcp.json # MCP server config (Windows) +├── scripts/ +│ └── validate_config.py # Checks this template's own config is coherent ├── .env.example # Environment variables template ├── .gitattributes # Force *.sh to LF so hooks run on Windows ├── .gitignore @@ -536,6 +542,24 @@ The script is written in Python and works cross-platform: Windows, macOS, and Li > Full documentation: [code.claude.com/docs/en/statusline](https://code.claude.com/docs/en/statusline) +## Continuous Integration + +`.github/workflows/ci.yml` runs on every pull request and on pushes to `main`. It lints with ruff and validates that the configuration this template ships is internally coherent — the class of breakage that would otherwise reach whoever copies the `.claude/` folder. + +| Check | Catches | +|-------|---------| +| `ruff check` / `ruff format --check` | Lint and formatting on `.claude/statusline.py` and `scripts/` (`src/**/*.py` covers projects built from this template) | +| `scripts/validate_config.py` | Unparseable `settings.json` or MCP config; hooks pointing at scripts that no longer exist; `CLAUDE.md` `@`-imports that don't resolve; skills whose `SKILL.md` lost its `name`/`description` frontmatter | +| `shellcheck --severity=warning` | Quoting and logic bugs in the hook scripts | + +The middle three failures are all silent at runtime: a hook whose script was renamed simply stops firing, and a broken `@`-import drops that rule from Claude's context without an error. Run the same checks locally with: + +```bash +uv run ruff check . && uv run ruff format --check . && uv run python scripts/validate_config.py +``` + +There is no test job: this repository ships configuration, not application code, so `pyproject.toml`'s pytest setup is there for the projects you build from it. + ## Contributing Contributions are welcome! If you have ideas for new agents, skills, rules, or improvements to the existing setup: diff --git a/pyproject.toml b/pyproject.toml index 8a6c989..82692ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,7 +54,9 @@ agents = [ [tool.ruff] line-length = 99 src = ["src"] -include = ["pyproject.toml", "src/**/*.py"] +# src/** is for projects built from this template; the other two are the Python +# this repo actually ships. Vendored .claude/skills/ scripts stay out of scope. +include = ["pyproject.toml", "src/**/*.py", "scripts/**/*.py", ".claude/statusline.py"] [tool.ruff.lint] extend-select = ["I"] # Add import sorting diff --git a/scripts/validate_config.py b/scripts/validate_config.py new file mode 100644 index 0000000..4ae5f16 --- /dev/null +++ b/scripts/validate_config.py @@ -0,0 +1,152 @@ +"""Validate the Claude Code configuration this template ships.""" + +from __future__ import annotations + +import json +from pathlib import Path +import re +import sys + +REPO_ROOT = Path(__file__).resolve().parent.parent +SETTINGS_PATH = REPO_ROOT / ".claude" / "settings.json" +MCP_CONFIG_DIR = REPO_ROOT / "mcp_config" +CLAUDE_MD_PATH = REPO_ROOT / "CLAUDE.md" +SKILLS_DIR = REPO_ROOT / ".claude" / "skills" + +# Hook entries name their script inline, e.g. "bash .claude/hooks/verify.sh". +HOOK_SCRIPT_PATTERN = re.compile(r"\.claude/hooks/[\w.-]+\.sh") +# CLAUDE.md pulls in modular rules with "@.claude/rules/.md". +IMPORT_PATTERN = re.compile(r"@(\.claude/rules/[\w.-]+\.md)") +# Skills declare their identity in YAML frontmatter at the top of SKILL.md. +FRONTMATTER_PATTERN = re.compile(r"\A---\r?\n(.*?)\r?\n---", re.S) + + +def check_json_parses() -> list[str]: + """Report config files that are not valid JSON. + + Returns + ------- + list[str] + One message per unparseable file; empty when all parse. + """ + errors: list[str] = [] + for path in [SETTINGS_PATH, *sorted(MCP_CONFIG_DIR.glob("*.json"))]: + try: + json.loads(path.read_text(encoding="utf-8")) + except ValueError as error: + errors.append(f"{path.relative_to(REPO_ROOT)}: invalid JSON -> {error}") + return errors + + +def check_hook_scripts_exist() -> list[str]: + """Report hook scripts referenced by settings.json that are missing. + + A hook pointing at a deleted script fails silently at runtime, so the + guardrail is simply gone with no error surfaced to the user. + + Returns + ------- + list[str] + One message per dangling reference; empty when all resolve. + """ + settings_text = SETTINGS_PATH.read_text(encoding="utf-8") + referenced = sorted(set(HOOK_SCRIPT_PATTERN.findall(settings_text))) + if not referenced: + return ["`.claude/settings.json`: no hook scripts referenced — did the format change?"] + return [ + f".claude/settings.json references missing hook: {reference}" + for reference in referenced + if not (REPO_ROOT / reference).is_file() + ] + + +def check_claude_md_imports() -> list[str]: + """Report `@`-imports in CLAUDE.md that do not resolve to a file. + + A broken import drops that rule from Claude's context without warning. + + Returns + ------- + list[str] + One message per unresolved import; empty when all resolve. + """ + imports = sorted(set(IMPORT_PATTERN.findall(CLAUDE_MD_PATH.read_text(encoding="utf-8")))) + if not imports: + return ["CLAUDE.md: no @-imports found — did the rules section move?"] + return [ + f"CLAUDE.md imports missing file: @{target}" + for target in imports + if not (REPO_ROOT / target).is_file() + ] + + +def check_skill_frontmatter() -> list[str]: + """Report skills whose SKILL.md lacks usable frontmatter. + + Claude discovers a skill through the `name` and `description` fields, so a + skill missing either is invisible to automatic invocation. + + Returns + ------- + list[str] + One message per malformed skill; empty when all are well formed. + """ + errors: list[str] = [] + for skill_dir in sorted(path for path in SKILLS_DIR.iterdir() if path.is_dir()): + skill_file = skill_dir / "SKILL.md" + if not skill_file.is_file(): + errors.append(f"{skill_dir.name}: no SKILL.md") + continue + + match = FRONTMATTER_PATTERN.match(skill_file.read_text(encoding="utf-8")) + if match is None: + errors.append(f"{skill_dir.name}/SKILL.md: no YAML frontmatter block") + continue + + frontmatter = match.group(1) + missing = [ + field + for field in ("name", "description") + if not re.search(rf"^{field}:", frontmatter, re.M) + ] + if missing: + errors.append(f"{skill_dir.name}/SKILL.md: frontmatter missing {', '.join(missing)}") + return errors + + +def main() -> int: + """Run every check and report the outcome. + + Returns + ------- + int + Process exit code: 0 when every check passes, 1 otherwise. + """ + checks = { + "JSON config parses": check_json_parses, + "hook scripts exist": check_hook_scripts_exist, + "CLAUDE.md imports resolve": check_claude_md_imports, + "skill frontmatter is complete": check_skill_frontmatter, + } + + failures = 0 + for label, check in checks.items(): + errors = check() + if errors: + failures += len(errors) + print(f"FAIL {label}") + for error in errors: + print(f" {error}") + else: + print(f"ok {label}") + + if failures: + print(f"\n{failures} problem(s) found.") + return 1 + + print("\nAll configuration checks passed.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From db570bafd0deb78f45d34abf230bd13e784f8ae6 Mon Sep 17 00:00:00 2001 From: skateddu Date: Thu, 13 Aug 2026 14:55:09 +0200 Subject: [PATCH 2/3] ci: pin setup-uv to v10.0.0 setup-uv publishes no floating major tag past v7, so @v10 does not resolve. Caught by the workflow's own first run. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eecec09..746fa78 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,9 @@ jobs: uses: actions/checkout@v7 - name: Install uv - uses: astral-sh/setup-uv@v10 + # setup-uv stopped publishing floating major tags after v7, so this + # pins the full version; actions/checkout still maintains a v7 tag. + uses: astral-sh/setup-uv@v10.0.0 with: enable-cache: true From 63f9189eba2d4ad3e21cf87cdb4a831ed3040055 Mon Sep 17 00:00:00 2001 From: skateddu Date: Thu, 13 Aug 2026 14:57:06 +0200 Subject: [PATCH 3/3] fix(hooks): drop unused payload assignment in session-start.sh shellcheck SC2034, surfaced by the new CI on its first run. The hook inspects the filesystem, not the payload, so the assignment was dead weight that implied otherwise. stdin is still drained. Co-Authored-By: Claude Opus 5 --- .claude/hooks/session-start.sh | 4 +++- CHANGELOG.md | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh index 48095d4..1e28f6e 100644 --- a/.claude/hooks/session-start.sh +++ b/.claude/hooks/session-start.sh @@ -6,7 +6,9 @@ # Requires: jq set -euo pipefail -input=$(cat) +# This hook inspects the filesystem rather than the hook payload, but stdin is +# still drained so Claude Code's write to the pipe always completes. +cat >/dev/null # Only act inside a uv-managed Python project. [ -f pyproject.toml ] || exit 0 diff --git a/CHANGELOG.md b/CHANGELOG.md index a9d9620..0cf3e3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **Hooks** (`session-start.sh`): `input=$(cat)` assigned the hook payload to a variable the script never read (shellcheck `SC2034`), found by the new CI workflow on its first run. Not a bug — the hook inspects the filesystem, not the payload — but the dead assignment made it look like the payload mattered. Replaced with `cat >/dev/null` plus a comment explaining that stdin is drained so Claude Code's write to the pipe always completes + - **Hooks** (`protect-main.sh`): the broad `rm -rf` guard used `\b` (word-boundary) to close each dangerous target (`/`, `.`, `..`, `~`), which doesn't behave as a token boundary — it matches on any adjacent word character and doesn't match at all at end-of-string. Result: the guard silently let through the most common forms of the command (`rm -rf .`, `rm -rf ..`, `rm -rf /`, `rm -rf ~`, with no trailing space), while also incorrectly blocking legitimate specific targets like `rm -rf .git` or `rm -rf ~/tmp-dir`. Replaced the closing boundary with `($|\s)` so it matches the whole token instead. Verified against both dangerous and legitimate cases by invoking the hook directly with crafted input - **Settings** (`.claude/settings.json`): the `enforce-uv.sh`/`protect-main.sh` `PreToolUse` entries combined multiple patterns in one `if` string (e.g. `Bash(python *)|Bash(pytest *)|...`); the `if` field holds exactly one permission rule with no `||`/list syntax, so the condition never matched and both hooks silently stopped firing — including `protect-main.sh`'s guardrails against force-push, direct push to main, `git reset --hard`, and broad `rm -rf`. Split each pattern into its own hook handler entry (8 for `enforce-uv.sh`, 2 for `protect-main.sh`), per [code.claude.com/docs/en/hooks](https://code.claude.com/docs/en/hooks)