Recipes

hooks, permissions, path-scoped 규칙으로 Claude Code 프로젝트 컨벤션을 강제하기

README 링크에서 발견 · today

Software & DevOpsWork & Operations

Python 저장소에서 느슨했던 CLAUDE.md 규칙을 강제 적용되는 설정으로 바꿨다.

What they did

작성자는 Python 저장소의 기존 Claude Code 설정(CLAUDE.md, /do-issue 및 /auto-issues 명령, ruff-format PostToolUse 훅, CI)을 감사한 결과, 대부분의 관례가 Claude가 무시할 수 있는 산문에 불과하다는 점을 발견했다. 작성자는 검증된 격차를 정리한 상세한 티켓을 작성했다: pyproject.toml에 [tool.ruff] 블록이 없어서(따라서 import 정렬이나 bugbear 규칙, 줄 길이 제한도 없음) 죽은 [tool.isort] 프로필이 있었고, mypy는 설치만 되어 있고 설정도 실행도 되지 않았으며, pytest가 의존성으로 빠져 있어(모든 명령에 --with pytest가 필요), [tool.pytest.ini_options]도 없었고, .vscode/settings.json은 pytest 대신 unittest를 가리키고 있었다. .pre-commit-config.yaml 없이 pre-commit git 훅이 설치되어 있어 매 커밋마다 PRE_COMMIT_ALLOW_NO_CONFIG=1을 강제해야 했다. CLAUDE.md에는 main에 절대 푸시하지 말라고 되어 있었지만 이를 강제하는 것은 없었다. 훅은 하나만 존재했고, .env는 보호되지 않았으며, .claude/settings.json에는 permissions 블록이 없었다. 영역 지식은 .claude/preflight.md에 있었는데, 이는 /auto-issues만 읽을 수 있었다. 제안된 수정안은 다음과 같다: target-version py311, 현재 코드에 맞는 줄 길이, lint.select E/F/I/B/UP를 갖춘 [tool.ruff] 추가; isort 제거; mypy의 운명 결정; pytest ini 옵션과 함께 pytest를 dev 그룹에 추가하고 VS Code 수정; uv.lock ruff 버전에 고정된 .pre-commit-config.yaml 추가; main으로의 푸시, --force, --no-verify, .env 읽기를 차단하는 PreToolUse 훅 추가; PostToolUse를 확장하여 ruff check --fix를 실행하고 오류를 다시 보고; allow/deny 권한 목록 추가; 그리고 server/agent, client, testing을 위한 경로 범위 .claude/rules/ 파일 생성. 작성자는 또한 위생 문제로 중복된 grill-me/grilling 스킬과 추적되지 않는 .claude/launch.json을 지적했으며, 작업을 네 개의 PR로 나눌 것을 제안했다.

How it went

병합된 코드가 아니라 범위가 정해진 체크리스트 형식의 티켓으로 인수 기준과 함께 전달되었다: ruff check/format과 세 개의 pytest 스위트 모두 로컬과 CI에서 통과해야 하고, 일반 git commit이 env 우회 없이 동작해야 하며, Claude 세션은 main 푸시, force-push, .env 읽기를 눈에 띄게 차단해야 하고, 규칙은 해당 경로에만 로드되어야 한다(/memory로 검증). 작성자는 작업을 명시적으로 네 개의 PR(스타일+pre-commit, 테스트+규칙, 훅+권한, 규칙+preflight 정리)로 나누었고, 커버리지 게이트, CI 내 e2e, .mcp.json은 범위 밖으로 명시했다.

Worth knowing

이것은 검증된 구현이 아니라 계획입니다 — 통과하는 인수 기준은 하나도 제시되지 않았습니다. 줄 길이와 mypy 유지/제거 결정은 열린 상태로 남아 있습니다. Stop 훅은 선택 사항이며 의도적으로 테스트를 건너뜁니다. 커버리지 게이트, CI에서의 e2e(OpenAI 키 시크릿 필요), 프로젝트 .mcp.json은 보류되었습니다. 훅 동작은 사용 중인 버전의 Claude Code PreToolUse 종료 코드 의미론과 권한 구문 일치에 의존합니다.

Wiring

# 1. `pyproject.toml`

```toml
[tool.ruff]
target-version = "py311"
line-length = 100

[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP"]

# [tool.isort] removed — ruff's "I" ruleset replaces it
```

mypy: keep it out of this pass. Ruff covers lint/format; add mypy later as a separate decision (it needs its own config, plugin choices, and a baseline of type errors to fix). Don't half-configure it now.

# 2. pytest as a dev dependency + config

```toml
[dependency-groups]
dev = [
    "pytest",
    # ...existing dev deps
]

[tool.pytest.ini_options]
testpaths = ["tests", "e2e"]
pythonpath = ["."]
```

Then strip `--with pytest` from:
- `CLAUDE.md` (any `uv run --with pytest ...` → `uv run pytest ...`)
- CI workflow (same substitution)
- preflight script (same substitution)

Fix `.vscode/settings.json` — point the interpreter at the uv venv and enable pytest discovery:

```json
{
  "python.defaultInterpreterPath": ".venv/bin/python",
  "python.testing.pytestEnabled": true,
  "python.testing.pytestArgs": ["tests", "e2e"]
}
```

# 3. `.pre-commit-config.yaml`

Pin to the same ruff version as `uv.lock` (check `uv.lock` for the resolved `ruff` version and substitute below):

```yaml
repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.6.9  # match uv.lock
    hooks:
      - id: ruff
        args: [--fix]
      - id: ruff-format
```

With this in place, `PRE_COMMIT_ALLOW_NO_CONFIG` can be removed from the environment/CI.

# 4. `.claude/settings.json` hooks

```json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "cmd=$(jq -r '.tool_input.command'); if echo \"$cmd\" | grep -Eq 'git push.*(main|--force)|--no-verify'; then echo 'Blocked: push to main, --force, or --no-verify' >&2; exit 2; fi"
          }
        ]
      },
      {
        "matcher": "Read|Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "p=$(jq -r '.tool_input.file_path // .tool_input.path // empty'); if echo \"$p\" | grep -Eq '(^|/)\\.env$'; then echo 'Blocked: .env is off-limits (use .env.example)' >&2; exit 2; fi"
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "p=$(jq -r '.tool_input.file_path // empty'); case \"$p\" in *.py) uv run ruff format \"$p\" && uv run ruff check --fix \"$p\" ;; esac"
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "uv run ruff check ."
          }
        ]
      }
    ]
  }
}
```

Notes:
- `exit 2` from a `PreToolUse` hook blocks the tool call and surfaces stderr to Claude.
- The `.env` matcher allows `.env.example` because the regex anchors on `\.env$`.
- The `Stop` hook is optional; drop it if the full-repo check is too slow on every turn end.

# 5. Permissions block

Add to the same `.claude/settings.json`:

```json
{
  "permissions": {
    "allow": [
      "Bash(uv run ruff *)",
      "Bash(uv run * pytest *)",
      "Bash(make e2e)",
      "Bash(gh issue view *)",
      "Bash(gh pr view *)",
      "Bash(git status)",
      "Bash(git diff *)",
      "Bash(git log *)"
    ],
    "deny": [
      "Read(./.env)",
      "Bash(git push --force*)",
      "Bash(rm -rf *)"
    ]
  }
}
```

# 6. `.claude/rules/*.md`

Each file starts with `paths:` frontmatter so it only loads for matching files.

`.claude/rules/server-agent.md`:
```markdown
---
paths:
  - "server/agent/**"
---
# Server / agent rules
- ...
```

`.claude/rules/client.md`:
```markdown
---
paths:
  - "client/**"
---
# Client rules
- ...
```

`.claude/rules/testing.md`:
```markdown
---
paths:
  - "**/tests/**"
  - "e2e/**"
---
# Testing rules
- ...
```

Then move general facts (project layout, commands, conventions that apply everywhere) out of `.claude/preflight.md` into `CLAUDE.md` or the appropriate rule file. `preflight.md` should keep only what's genuinely preflight-specific.

Verify with `/memory` that each rule file loads only when a matching path is in context — open a file under `server/agent/` and confirm `server-agent.md` appears, then open one under `client/` and confirm `client.md` appears and `server-agent.md` does not.

Try it yourself

# Claude Code Setup Audit & Enforcement Plan

## Audit Findings

### 1. Python tooling (`pyproject.toml`)
- **`[tool.ruff]`**: verify `target-version` matches the interpreter floor, `line-length` matches the actual codebase (measure with `ruff format --check` diff count), and `lint.select = ["E", "F", "I", "B", "UP"]`. Missing `UP`/`B` means modern-syntax and bugbear rules are silently off.
- **`[tool.isort]`**: dead config — ruff's `I` ruleset supersedes it. Remove to avoid two sources of truth.
- **`mypy`**: present but unconfigured (no `[tool.mypy]` section, no `strict`/`disallow_untyped_defs`). Either configure it or drop it from CI to avoid a no-op gate.

### 2. Testing
- Confirm `pytest` is in `[dependency-groups].dev` (or `[project.optional-dependencies].dev`), not just installed ad hoc.
- Add `[tool.pytest.ini_options]` with `testpaths = ["tests"]` and `pythonpath = ["."]` (or `["src"]`) so `pytest` runs from repo root without `PYTHONPATH` hacks.
- `.vscode/settings.json`: check `python.testing.pytestEnabled = true` and `python.testing.pytestArgs = ["tests"]`; otherwise the editor runs unittest discovery and diverges from CI.

### 3. Pre-commit
- If `.git/hooks/pre-commit` exists but `.pre-commit-config.yaml` does not, the hook is unmanaged (likely a hand-written script). Replace with `pre-commit install` + a committed config so every clone gets the same gate.

### 4. `.claude/settings.json`
- Missing `permissions.allow` / `permissions.deny` — every Bash/Edit call prompts or is silently allowed depending on user config.
- No `hooks` block — no guardrails on destructive git operations or secret-file reads.

---

## Proposed Changes (split into PRs)

### PR 1 — Python tooling baseline
- `pyproject.toml`: fix `[tool.ruff]` (`target-version`, `line-length`, `lint.select = ["E","F","I","B","UP"]`), delete `[tool.isort]`, add `[tool.mypy]` (`strict = true`, `files = ["src"]`) or remove mypy from CI.
- Add `[tool.pytest.ini_options]` with `testpaths` and `pythonpath`; move `pytest` into the dev dependency group.
- `.vscode/settings.json`: enable pytest discovery.

**Acceptance criteria**
- `uv run ruff check .` and `uv run ruff format --check .` pass on `main`.
- `uv run pytest` collects tests from repo root with no env vars set.
- `uv run mypy` (if kept) exits 0 or is removed from CI.
- No `[tool.isort]` section remains; `grep -r isort pyproject.toml` returns nothing.

### PR 2 — Pre-commit
- Add `.pre-commit-config.yaml` pinning `astral-sh/ruff-pre-commit` to the exact `ruff` version in `uv.lock` (both `ruff` and `ruff-format` hooks).
- Remove any hand-written `.git/hooks/pre-commit`; document `pre-commit install` in `CLAUDE.md`.

**Acceptance criteria**
- `pre-commit run --all-files` passes on a clean checkout.
- Hook `rev` matches `uv.lock` ruff version (assert in CI or a test).
- Fresh clone + `pre-commit install` blocks a commit with a lint error.

### PR 3 — Claude Code hooks (`.claude/settings.json`)
- **PreToolUse on `Bash`**: block `git push` to `main`, `--force`/`-f`, and `--no-verify`; `exit 2` with a human-readable reason.
- **PreToolUse on `Read|Edit|Write`**: block paths matching `.env` (and `.env.*` except `.env.example`); allow `.env.example`.
- **PostToolUse on `Edit|Write`**: run `ruff format` then `ruff check --fix` on the touched file; surface remaining errors back to the model.
- Add `permissions.allow` (e.g. `Bash(uv run pytest:*)`, `Bash(uv run ruff:*)`, `Read`, `Edit`, `Write`) and `permissions.deny` (e.g. `Bash(git push --force:*)`, `Read(./.env)`, `Read(./.env.*)`).

**Acceptance criteria**
- Attempting `git push origin main` from Claude Code exits 2 with a reason; same for `--force` and `--no-verify`.
- Reading `.env` is denied; reading `.env.example` succeeds.
- Editing a Python file with a lint error triggers PostToolUse and the error appears in the transcript.
- `permissions.deny` entries are honored even when `allow` would otherwise match.

### PR 4 — Path-scoped rules
- Add `.claude/rules/server-agent.md`, `.claude/rules/client.md`, `.claude/rules/testing.md`, each with `paths:` frontmatter (e.g. `paths: ["server/**", "agent/**"]`, `paths: ["client/**"]`, `paths: ["tests/**", "**/test_*.py"]`).
- Trim `CLAUDE.md` to ≤60 lines: keep repo map, commands, and pointers to rules; move domain conventions into the scoped files.

**Acceptance criteria**
- `wc -l CLAUDE.md` ≤ 60.
- Each rule file has valid `paths:` frontmatter and is only injected when editing matching files (verify via a scoped edit).
- No duplicated guidance between `CLAUDE.md` and rule files.

Source

English

Tools in this stack