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/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..746fa78 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +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 + # 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 + + - 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..0cf3e3e 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) @@ -18,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) 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())