Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .claude/hooks/session-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
152 changes: 152 additions & 0 deletions scripts/validate_config.py
Original file line number Diff line number Diff line change
@@ -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/<name>.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())