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
37 changes: 37 additions & 0 deletions .claude/scripts/format-and-lint-after-edit.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
set -uo pipefail

# Claude Code PostToolUse hook entrypoint for Write/Edit/MultiEdit.
#
# Reads the hook payload (JSON) from stdin, extracts the edited file path,
# then formats (always) and lints it via the shared pre-commit-oxc.sh script.
#
# Exit code contract (Claude Code PostToolUse):
# 0 -> success, or skip (no file path / file gone): nothing fed back
# 2 -> lint/format errors: stderr is fed back to the agent to fix
# Tool crashes (killed by a signal, exit >= 128) are swallowed so a transient
# tooling bug never repeatedly blocks the agent.
#
# AI agents: keep this in sync with the other wiring points (AGENTS.md "Hook Sync Rule"):
# - .claude/settings.json (PostToolUse)
# - .claude/scripts/pre-commit-oxc.sh (shared formatter/linter; also the prek.toml hook)

script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

# Claude Code passes hook data as JSON on stdin, not via environment variables.
file_path="$(jq -r '.tool_input.file_path // empty')"
if [[ -z "$file_path" || ! -f "$file_path" ]]; then
exit 0
fi

output="$(bash "$script_dir/pre-commit-oxc.sh" "$file_path" 2>&1)"
code=$?

# Success, or a tool crash we don't want to surface as actionable feedback.
if [[ "$code" -eq 0 || "$code" -ge 128 ]]; then
exit 0
fi

# Lint/format errors: surface them to the agent (exit 2 feeds stderr back).
printf '%s\n' "$output" >&2
exit 2
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@
set -euo pipefail

# Shared formatter/linter entrypoint used by:
# - .pre-commit-config.yaml (local hook: oxc-format-and-lint)
# - .github/hooks/format-and-lint-after-edit.json (PostToolUse)
# AI agents: keep both wiring points and this usage list in sync when behavior or paths change.
# - prek.toml (local hook: oxc-format-and-lint)
# - .claude/scripts/format-and-lint-after-edit.sh (PostToolUse wrapper,
# wired via .claude/settings.json)
# AI agents: keep all wiring points and this usage list in sync when behavior or paths change.

# pre-commit passes staged file paths as positional arguments.
# prek passes staged file paths as positional arguments.
# Exit fast when nothing matched this hook's file filter.
if [[ "$#" -eq 0 ]]; then
exit 0
Expand Down
31 changes: 31 additions & 0 deletions .claude/scripts/stop-review-agents.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -euo pipefail

# Stop hook: prompts the agent to capture any learnings from this session in the
# right place — AGENTS.md (team-shared repo conventions) and auto-memory
# (MEMORY.md + memory files, for user preferences and project context).
# Only triggers when there are uncommitted changes — skips read-only sessions.
#
# Used by:
# - .claude/settings.json (Stop hook)

if ! command -v git >/dev/null 2>&1; then
exit 0
fi

# Skip if no working-tree changes (read-only session or everything already committed).
if git diff --quiet 2>/dev/null && git diff --cached --quiet 2>/dev/null; then
exit 0
fi

cat <<'EOF'
{
"hookSpecificOutput": {
"hookEventName": "Stop",
"decision": "block",
"reason": "Before finishing: capture any learnings from this session in the right place.\n\n1. AGENTS.md — team-shared, repo-specific conventions, patterns, gotchas, or corrections to stale info.\n2. Auto-memory (MEMORY.md + memory files) — user preferences, feedback on how to work, and project context not derivable from the code.\n\nFor each: add what's new, fix what's stale, and skip what the repo already records. If both are already up to date, output a brief confirmation and the session can end."
}
}
EOF

exit 0
39 changes: 39 additions & 0 deletions .claude/scripts/type-check-after-edit.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
set -uo pipefail

# Claude Code PostToolUse hook for TypeScript type-checking after Write/Edit/MultiEdit.
#
# Runs as an async "rewake" hook (asyncRewake): it does NOT block the edit. The
# whole-project type check (`pnpm run tsc:check`) runs in the background, and on
# type errors the hook exits 2 to wake the agent with the errors to fix.
#
# Exit code contract (asyncRewake PostToolUse):
# 0 -> no type errors, or skipped: the agent is not interrupted
# 2 -> type errors: hook output is fed back to wake the agent to fix them
#
# tsc checks the whole project, so this only runs for edits that can change type
# results; doc/config-only edits are skipped to avoid pointless background runs.
#
# AI agents: keep wiring points in sync (AGENTS.md "Hook Sync Rule"):
# - .claude/settings.json (PostToolUse)

if ! command -v pnpm >/dev/null 2>&1; then
exit 0
fi

# Claude Code passes hook data as JSON on stdin, not via environment variables.
file_path="$(jq -r '.tool_input.file_path // empty')"
case "$file_path" in
*.ts | *.tsx | *.cts | *.mts | *.js | *.jsx | *.cjs | *.mjs | *.json) ;;
*) exit 0 ;;
esac

tsc_output="$(pnpm run tsc:check 2>&1)" || tsc_exit=$?

if [[ "${tsc_exit:-0}" -eq 0 ]]; then
exit 0
fi

# Type errors: exit 2 so asyncRewake feeds these back to the agent.
printf 'TypeScript type errors must be fixed:\n\n%s\n' "$tsc_output"
exit 2
22 changes: 13 additions & 9 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,31 @@
"hooks": [
{
"type": "command",
"command": "bash ./.github/hooks/scripts/stop-type-check.sh",
"timeout": 60
"command": "bash ./.claude/scripts/stop-review-agents.sh",
"timeout": 10
}
]
},
}
],
"PostToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "bash ./.github/hooks/scripts/stop-review-agents.sh",
"timeout": 10
"command": "bash ./.claude/scripts/format-and-lint-after-edit.sh",
"statusMessage": "Formatting and linting edited file"
}
]
}
],
"PostToolUse": [
},
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "if [[ -n \"$TOOL_INPUT_FILE_PATH\" && -f \"$TOOL_INPUT_FILE_PATH\" ]]; then bash ./.github/hooks/scripts/pre-commit-oxc.sh \"$TOOL_INPUT_FILE_PATH\"; fi"
"command": "bash ./.claude/scripts/type-check-after-edit.sh",
"asyncRewake": true,
"statusMessage": "Type-checking project"
}
]
}
Expand Down
76 changes: 76 additions & 0 deletions .devcontainer/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Base image pinned by digest for reproducibility. Bumping the node version
# means updating both the tag and the digest (see `docker pull` output).
FROM node:24.16.0-bookworm-slim@sha256:242549cd46785b480c832479a730f4f2a20865d61ea2e404fdb2a5c3d3b73ecf

# System packages. Individual versions are not pinned — the digest-pinned base
# image plus Docker's layer cache provide reproducibility in practice.
# - zsh + zsh-autosuggestions: interactive shell setup, configured via zshrc.
# - chsh: make zsh the login shell for the `node` user so VS Code terminals
# and any `docker exec` sessions land in zsh by default.
# - openssh-client: provides ssh-keygen, which git invokes to SSH-sign commits
# (gpg.format=ssh + commit.gpgsign=true); without it commits fail.
RUN apt-get update && apt-get install -y --no-install-recommends \
curl wget gnupg ca-certificates apt-transport-https \
git openssh-client bash zsh zsh-autosuggestions \
&& rm -rf /var/lib/apt/lists/* \
&& chsh -s /usr/bin/zsh node

# Persisted zsh history. /commandhistory is mounted as a named volume in
# devcontainer.json so history survives container rebuilds. The directory must
# exist and be owned by `node` before the volume mounts, otherwise the first
# write fails with EACCES. SHARE_HISTORY (set in zshrc) writes each command
# immediately and makes it visible across concurrent shells.
RUN mkdir -p /commandhistory \
&& touch /commandhistory/.zsh_history \
&& chown -R node:node /commandhistory

# Shell config lives in a sibling file so it can be edited without escaping
# shell quoting inside a Dockerfile RUN. See .devcontainer/zshrc.
COPY --chown=node:node zshrc /home/node/.zshrc

# pnpm 11.1.1 — installed globally from the npm registry rather than piping the
# get.pnpm.io install script to bash. npm resolves the package against its
# registry integrity hashes, so there is no unpinnable remote shell script
# executing as root at build time. npm's global prefix on the node image is
# /usr/local, so the binary lands on PATH for the non-root `node` user. Not
# Corepack: pnpm docs flag an outdated-signatures issue that adds bootstrapping
# friction; a plain npm global install sidesteps that. PNPM_HOME is where pnpm
# places its own globally-installed tools (`pnpm add -g`).
ENV PNPM_HOME="/usr/local/share/pnpm"
ENV PATH="$PNPM_HOME:$PNPM_HOME/bin:$PATH"
RUN npm install -g pnpm@11.1.1

# GitHub CLI 2.93.0 — installed from the official cli.github.com apt repository.
# The keyring fingerprint is verified against GitHub's published key before trusting it.
# To update: change the version in `apt-get install gh=<version>` and update the comment.
# Latest releases: https://github.com/cli/cli/releases
RUN mkdir -p -m 755 /etc/apt/keyrings \
&& wget -nv -O /etc/apt/keyrings/githubcli-archive-keyring.gpg \
https://cli.github.com/packages/githubcli-archive-keyring.gpg \
&& gpg --show-keys /etc/apt/keyrings/githubcli-archive-keyring.gpg 2>/dev/null \
| grep -qF "2C6106201985B60E6C7AC87323F3D4EA75716059" \
&& chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
> /etc/apt/sources.list.d/github-cli.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends gh=2.93.0 \
&& rm -rf /var/lib/apt/lists/*

# act v0.2.88 — run GitHub Actions locally, installed via the nektos/act install
# script. The installer is pulled from the matching version tag (not `master`) so
# the script that runs at build time is pinned alongside the binary it installs.
# The script verifies the downloaded release against its published checksums
# internally. -b sets the install directory; the version arg pins the release.
RUN curl --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/nektos/act/v0.2.88/install.sh \
| bash -s -- -b /usr/local/bin v0.2.88

# prek v0.4.3 — pre-commit hook manager, copied from the official distroless image
COPY --from=ghcr.io/j178/prek:v0.4.3@sha256:953593c93d253d44bad97b36b428c13edd074dc35de003ccbf3ff9a40c0c79b9 /prek /usr/local/bin/prek

# Drop to the non-root `node` user as the image default. devcontainer.json sets
# `remoteUser: node` for VS Code sessions, but this also covers plain
# `docker exec`/`docker run`, so nothing lands as root by default. All privileged
# install steps above run as root; devcontainer features re-elevate on their own.
USER node

WORKDIR /workspace
42 changes: 42 additions & 0 deletions .devcontainer/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Dev Container

This dev container provides a pre-configured TypeScript/Node environment with Claude Code and the GitHub CLI ready to use.

## Host requirements

The container bind-mounts a few host paths so your local Claude Code and `gh` credentials are available inside the container. `initializeCommand` creates these paths automatically on first launch if they don't exist, but you should authenticate them on the host **before** opening the project in the container — otherwise the container starts with empty/unauthenticated credentials.

### Required host setup

1. **Claude Code** — install and sign in on the host so that `~/.claude.json` and `~/.claude/` are populated:

```sh
# https://docs.claude.com/en/docs/claude-code/setup
claude # follow the sign-in prompts
```

2. **GitHub CLI** — install and authenticate on the host so that `~/.config/gh/` contains valid credentials:
```sh
# https://cli.github.com/
gh auth login
```

If either step is skipped, the container will still build, but you'll need to re-authenticate inside the container (and for the read-only mounts below, that means editing the host files instead).

## Mounts

| Source (host) | Target (container) | Mode | Purpose |
| ------------------------------------- | ------------------------- | ---------- | -------------------------------------------------- |
| `~/.claude.json` | `/home/node/.claude.json` | read-only | Claude Code account/config (credentials) |
| `~/.claude/` | `/home/node/.claude/` | read-write | Claude Code working state: projects, todos, memory |
| `~/.config/gh/` | `/home/node/.config/gh/` | read-only | `gh` CLI credentials and config |
| `cloudflare-pages-action-zsh-history` | `/commandhistory` | volume | Persistent zsh history across container rebuilds |

The credential mounts (`.claude.json`, `gh`) are **read-only** so a compromised dependency running inside the container cannot exfiltrate or tamper with host credentials. Re-authentication should be performed on the host.

The `.claude/` directory is read-write because Claude Code writes session state (projects, todos, memory) during normal use.

## Security notes

- `initializeCommand` runs on the **host** with your user privileges before the container starts. Review changes to [devcontainer.json](devcontainer.json) in PRs the same way you'd review CI workflow changes.
- Host credentials are exposed to any process running inside the container, including `pnpm install` postinstall scripts. The read-only flag limits write access but not read access — treat the container as a trust boundary for the credentials it can see.
20 changes: 0 additions & 20 deletions .devcontainer/devcontainer-lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,30 +5,10 @@
"resolved": "ghcr.io/anthropics/devcontainer-features/claude-code@sha256:cfc2e7d3e9fd3b9b01f8d5cb158508a884c8c0ede2e23ed10f32dea5d4ffe69a",
"integrity": "sha256:cfc2e7d3e9fd3b9b01f8d5cb158508a884c8c0ede2e23ed10f32dea5d4ffe69a"
},
"ghcr.io/devcontainers-extra/features/act:1.0.15": {
"version": "1.0.15",
"resolved": "ghcr.io/devcontainers-extra/features/act@sha256:db4a2194930d1f7ec62822d4f600dd2fa4aff3c33b98cdb0b578b64ffb10924c",
"integrity": "sha256:db4a2194930d1f7ec62822d4f600dd2fa4aff3c33b98cdb0b578b64ffb10924c"
},
"ghcr.io/devcontainers-extra/features/pre-commit:2.0.18": {
"version": "2.0.18",
"resolved": "ghcr.io/devcontainers-extra/features/pre-commit@sha256:6e0bb2ce80caca1d94f44dab5d0653d88a1c00984e590adb7c6bce012d0ade6e",
"integrity": "sha256:6e0bb2ce80caca1d94f44dab5d0653d88a1c00984e590adb7c6bce012d0ade6e"
},
"ghcr.io/devcontainers/features/docker-outside-of-docker:1.10.0": {
"version": "1.10.0",
"resolved": "ghcr.io/devcontainers/features/docker-outside-of-docker@sha256:c2c2cf829505ead8e4892c88c31b6594ae94a2bbb209e16e1fac456c1a3a624e",
"integrity": "sha256:c2c2cf829505ead8e4892c88c31b6594ae94a2bbb209e16e1fac456c1a3a624e"
},
"ghcr.io/devcontainers/features/github-cli:1.1.0": {
"version": "1.1.0",
"resolved": "ghcr.io/devcontainers/features/github-cli@sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671",
"integrity": "sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671"
},
"ghcr.io/devcontainers/features/node:2.0.0": {
"version": "2.0.0",
"resolved": "ghcr.io/devcontainers/features/node@sha256:fedd4c11f7adfb64283b578dddc7da906728daa25fa293351c9d913231acf12f",
"integrity": "sha256:fedd4c11f7adfb64283b578dddc7da906728daa25fa293351c9d913231acf12f"
}
}
}
55 changes: 34 additions & 21 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// For format and config option details, see https://aka.ms/devcontainer.json.
{
"name": "Container - Cloudflare Pages Action",
// mcr.microsoft.com/devcontainers/base:jammy
"image": "mcr.microsoft.com/devcontainers/base:jammy@sha256:f2d74267998cfe76acefa5cc8d19ccc86bb2ba4520a5ad2b218def9566dc04cd",
"build": {
"dockerfile": "Dockerfile"
},
"customizations": {
"vscode": {
"extensions": [
Expand All @@ -11,36 +12,48 @@
"GraphQL.vscode-graphql-syntax@1.3.10",
"GraphQL.vscode-graphql@0.13.4",
"oxc.oxc-vscode@1.56.0",
"redhat.vscode-yaml@1.24.2026052209",
"vitest.explorer@1.50.4",
"webpro.vscode-knip@2.1.5",
"yoavbls.pretty-ts-errors@0.8.7"
"webpro.vscode-knip@2.1.5"
],
"settings": {
"chat.disableAIFeatures": true,
"extensions.autoUpdate": false,
"extensions.autoCheckUpdates": true
"extensions.autoCheckUpdates": true,
"terminal.integrated.defaultProfile.linux": "zsh",
"terminal.integrated.profiles.linux": {
"zsh": {
"path": "/usr/bin/zsh"
}
}
}
}
},
"postCreateCommand": "sed -i '/^ZSH_THEME/c\\ZSH_THEME=\"bira\"' ~/.zshrc && pnpm i && pre-commit install",
"updateContentCommand": "rm -rf .cache && pnpm i && pre-commit install",
"postCreateCommand": "sed -i '/^ZSH_THEME/c\\ZSH_THEME=\"bira\"' ~/.zshrc && pnpm i && prek install",
"updateContentCommand": "rm -rf .cache && pnpm i && prek install",
"features": {
"ghcr.io/devcontainers-extra/features/act:1.0.15": {},
"ghcr.io/devcontainers/features/docker-outside-of-docker:1.10.0": {},
"ghcr.io/devcontainers/features/node:2.0.0": {
"version": "24.7.0",
"pnpmVersion": "11.0.8"
},
"ghcr.io/devcontainers/features/github-cli:1.1.0": {},
"ghcr.io/devcontainers-extra/features/pre-commit:2.0.18": {},
"ghcr.io/anthropics/devcontainer-features/claude-code:1.0.5": {}
},
"containerEnv": {
"CLAUDE_CONFIG_DIR": "/home/vscode/.claude"
"CLAUDE_CONFIG_DIR": "/home/node/.claude",
"DISABLE_AUTOUPDATER": "1"
},
// Ensure mount sources exist on the host before the container starts.
// Without this, Docker would auto-create an empty *directory* at the .claude.json
// path (since the source is missing), which breaks Claude Code.
// See ./README.md for host setup requirements.
"initializeCommand": "touch ${localEnv:HOME}/.claude.json && mkdir -p ${localEnv:HOME}/.claude ${localEnv:HOME}/.config/gh",
"mounts": [
"source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind"
]
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
// Claude Code credentials — read-only so container processes can't tamper with host auth.
"source=${localEnv:HOME}/.claude.json,target=/home/node/.claude.json,type=bind,readonly",
// Claude Code working state (projects, todos, memory) — must be writable.
"source=${localEnv:HOME}/.claude,target=/home/node/.claude,type=bind",
// GitHub CLI credentials — read-only; re-auth on the host with `gh auth login`.
"source=${localEnv:HOME}/.config/gh,target=/home/node/.config/gh,type=bind,readonly",
// Named volume for persistent zsh history across container rebuilds.
"source=cloudflare-pages-action-zsh-history,target=/commandhistory,type=volume"
],
// Connect as the non-root `node` user (the Dockerfile's default USER). Set to
// "root" to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
"remoteUser": "node"
}
Loading