This repo uses pre-commit to run linters (shellcheck, ruff, yamllint, hadolint) before each commit. All hook dependencies (including linter binaries) are managed by pre-commit — no separate installs needed.
Setup (one-time, from the repo root with direnv active):
# copy the uv envrc if you haven't already — this sets UV_TOOL_DIR/UV_TOOL_BIN_DIR
# so uv installs tools into .tools/bin/ (project-local, not ~/.local/bin)
cp .envrcs/.envrc.user.uv .envrcs/.envrc.user
direnv allow
uv tool install pre-commitThe git hook is symlinked automatically by direnv (via symlink_hooks in lib/git.sh). In devcontainer and worktree contexts, install_hooks also clears core.hooksPath before symlinking. The hook finds pre-commit via .tools/bin/ on the host or PATH in the container — no pre-commit install needed.
After this, the configured checks run automatically on git commit. To run all hooks against every file manually:
pre-commit run --all-filesAs an alternative to setting up a local environment, you can use the dev container. It works with both the main checkout and git worktrees.
Prerequisites (host): Docker, bash, git, and Python 3. No other dependencies required (no Node, no uv). The script must be run from inside a git repo.
Important
Linux x86_64 only. Installs amd64 binaries and uses GNU coreutils. macOS and Windows are not supported.
Toolchain (container): Python 3.12 with uv 0.7.8, just 1.40.0, sops 3.9.4, gh, direnv, Node 20, and Claude Code 2.1.119. Generic tool versions are pinned in Dockerfile; project-specific versions (uv, direnv) are in the project overlay's install-system.sh.
Setup: The devcontainer script lives in dev/. Run direnv allow in the repo root to add it to your PATH, or invoke it directly as ./dev/devcontainer.
# start the container (builds on first run)
devcontainer up
# open a shell inside the container
devcontainer exec
# run claude
devcontainer claude
# run claude with --dangerously-skip-permissions
devcontainer claude-dangerously-skip-permissions
# stop the container
devcontainer down
# destroy container and all volumes (venv, uv cache) to start fresh
devcontainer reset
# reset + remove images and host-side artifacts
devcontainer clean
# check whether the container is running
devcontainer status
# list all worktrees with container status and overlay
devcontainer list
# show resolved overlay and project.env values without starting anything
devcontainer resolve
# view container logs
devcontainer logsNotes:
- After the first
up, subsequentexecandclaudeinvocations auto-start the container if it isn't already running. - The container rebuilds automatically when the
Dockerfileor compose config changes. downprompts before stopping a running container;resetprompts before destroying volumes.execwith no arguments opens a bash shell and requires an interactive TTY. With an explicit command (e.g.exec uv run pytest -m core), no TTY is needed.
To use from a worktree, pass -w or run the script from the worktree directory:
# from the main checkout, targeting a worktree
devcontainer -w ../xorq-my-feature up
# or from inside the worktree (direnv adds dev/ to PATH)
devcontainer upProject-specific configuration lives in a project overlay — either projects/<name>/ in the devcontainer repo (shipped defaults) or .devcontainer/ in the consumer workspace (local override). Everything outside the overlay (the Dockerfile, docker-compose.yml, dev/devcontainer, lib/, etc.) is generic infrastructure. The overlay is resolved automatically: workspace .devcontainer/ takes precedence over projects/<name>/, which falls back to defaults/. Per-project devcontainer.json lives alongside the overlay because the spec doesn't support sub-file includes (see step 5 below).
| File | Role |
|---|---|
install-system.sh |
apt packages and language toolchain (runs as root during docker build) |
setup-env.sh |
first-run + sync-on-lockfile-change hooks (runs in-container as vscode) |
compose.override.yml |
extra named volumes, bind mounts, env vars, and the EXTRA_PATH build arg |
external-volumes.txt |
basenames of named volumes declared external: true in compose.override.yml; pre-created as ${DEV_PROJECT_NAME}-<basename> so a fresh checkout doesn't error |
worktree-symlinks.txt |
paths under the main worktree to symlink into new worktrees |
worktree-copies.txt |
paths to copy (not symlink) into new worktrees; globs allowed |
host-mounts.txt |
<host-path>:<container-path>[:<options>] bind mounts added to compose at runtime; tilde-expanded, missing host paths skipped with a warning |
host-mounts.local.txt |
per-developer mount overrides (gitignored); same format as host-mounts.txt |
audit-prefixes.txt |
first-word triggers for two-word grouping in the audit report (e.g. git → git status, uv → uv run) |
project.env.example |
template for the gitignored project.env overrides |
Copy project.env.example to project.env (gitignored) in the overlay directory to set:
MODEL_VERSION— passed as--modelon eachdev/devcontainer claudeinvocation. Per-worktree: each worktree'sproject.envcan set a different model (e.g. use a cheaper model for routine tasks). Re-read from the host on every call, not baked into the container. Leave unset to use Claude Code's default.DANGEROUSLY_SKIP_PERMISSIONS=1— makesdev/devcontainer claudepass--dangerously-skip-permissionsautomatically. The explicitclaude-dangerously-skip-permissionssubcommand still works as a one-off override regardless of this setting.
Inspecting the resolved configuration:
devcontainer resolve shows what overlay and settings would be used for the current workspace without starting anything — useful for verifying that a checkout maps to the expected projects/<name>/ entry:
$ devcontainer resolve
Resolved configuration for workspace: /home/dan/repos/github/xorq-dasher
Overlay resolution:
[skipped] .devcontainer/ — not present
[skipped] projects/xorq-dasher/ — not present
[fallback] defaults/
overlay: defaults/ (no project overlay matched)
from: /home/dan/repos/github/devcontainer/defaults
Resolved values:
PROJECT_NAME=xorq-dasher
MODEL_VERSION=<unset>
DANGEROUSLY_SKIP_PERMISSIONS=<unset>
CONTAINER_NAME=xorq-dasher-dev-xorq-dasher
If the overlay name doesn't match the checkout directory name (e.g. you cloned dasher as xorq-dasher), set DEV_PROJECT_NAME before invoking: DEV_PROJECT_NAME=dasher devcontainer resolve.
devcontainer list shows all worktrees with their container status and the overlay each would resolve to now:
$ devcontainer list
WORKTREE STATUS OVERLAY
main running projects/dasher
feat/auth stopped projects/dasher
When a container starts (up, exec, claude), the resolved overlay and project.env values are written to .envrcs/.resolved-env in the workspace as a historical record of what was actually used.
The container workspace is always /workspaces/src — threaded through compose, the Dockerfile, and setup-claude as DEV_CONTAINER_WORKSPACE, but changing it is not supported.
Clone this repo as a sibling of your project and symlink its dev/ directory into your workspace:
# from the parent directory containing both repos
ln -s ../devcontainer/dev myproject/dev
echo dev >> myproject/.gitignoreThen add PATH_add dev to your .envrc (or export PATH="$PWD/dev:$PATH" in your shell). The devcontainer script resolves its own location through the symlink to find the Dockerfile, compose config, and libraries, so a single clone serves all projects. socat is optional on the host but required for SSH and GPG agent forwarding into the container.
From your project directory, create a project overlay with devcontainer init --local (creates .devcontainer/ in the workspace) or devcontainer init [project-name] (creates projects/<name>/ in the devcontainer repo for shipping defaults), then edit the overlay:
-
install-system.sh— apt packages and language toolchain. The default is a no-op. Replace with whatever your project needs (e.g.build-essential+uv,golang-go,rustup,bun). -
setup-env.sh— what runs after the container starts. Takes one subcommand:first-run(called once after initialup— install dependencies, seed caches) orsync-if-needed(called on everyexec/claudeentry — re-sync if a lockfile changed, e.g. compareuv.lockmtime against.venv/.last-sync). Keep the case-statement interface and replace the bodies. -
compose.override.yml— named volumes, host bind mounts, env vars, and theEXTRA_PATHbuild arg. All project-specific compose customization belongs here, not indocker-compose.yml. Named-volume mount targets are auto-chowned tovscodeon first run, so adding a volume requires no changes outside this file. Delete or rename volumes you don't need. Cross-worktree volumes (external: true) must also be listed inexternal-volumes.txtsodev/devcontainerpre-creates them with the project-namespaced name.Skeleton for a Python project with a
.venvand shared uv cache:services: app: build: args: EXTRA_PATH: /workspaces/src/.venv/bin volumes: - uv-cache:/home/vscode/.cache/uv volumes: uv-cache: external: true
With
external: true, adduv-cachetoexternal-volumes.txtso the volume is pre-created as${DEV_PROJECT_NAME}-uv-cache. -
worktree-symlinks.txt/worktree-copies.txt— whatsetup-worktreepropagates from the main worktree. -
devcontainer.json— only used when attaching VS Code to an already-running container (started viadev/devcontainer up). EditforwardPortsandcustomizations.vscode.extensionsfor your project. TheinitializeCommandtripwire blocks VS Code's "Reopen in Container" flow, which is unsupported (see below). Lives alongside the overlay. -
Dockerfile— exposes build args you can override fromcompose.override.ymlrather than editing the Dockerfile in place:BASE_IMAGE(defaultmcr.microsoft.com/devcontainers/python:3.12-bookworm) for non-Python base images;EXTRA_PATH(empty by default, set to the Python venv's bin dir in the project override) prepended to the containerPATHso project tools resolve; and tool-version pins (NODE_MAJOR,JUST_VERSION,SOPS_VERSION,CLAUDE_CODE_VERSION) — bump these together with their companion checksum args where present (NODESOURCE_SHA256,JUST_INSTALLER_SHA256,SOPS_SHA256; Claude Code is installed via npm and has no checksum arg).
All overlay files are optional. install-system.sh and setup-env.sh must exist (the Dockerfile COPYs them) but may be empty no-ops; compose.override.yml and the *.txt lists may be missing entirely — read_list treats a missing list as empty.
Two worktree paths are hardcoded in dev/setup-worktree rather than living in worktree-{symlinks,copies}.txt: .gitignore is always copied (git opens it with O_NOFOLLOW, so a symlink would ELOOP), and .claude is always symlinked (audit logs and session captures are devcontainer infrastructure that must aggregate in the main checkout regardless of project).
# bash
eval "$(devcontainer completions bash)"
# zsh
eval "$(devcontainer completions zsh)"
# fish
devcontainer completions fish | sourceWarning
Use dev/devcontainer. The VS Code "Reopen in Container" / official devcontainer CLI path is unsupported. It will start a container, but most of what makes the dev environment work — UID/GID matching, host git/gh/SSH credentials, Claude config, sops keys, worktree support, dependency install — is implemented in dev/devcontainer and is not invoked by VS Code's path. The container will appear to start, then silently lack credentials, fail on permission errors, or run with stale state. We keep devcontainer.json only for IDE port forwarding and extension installation when used alongside an already-running container started via dev/devcontainer.
An initializeCommand tripwire in devcontainer.json exits non-zero on the unsupported path so the failure surfaces with a pointer here instead of silently degrading. dev/devcontainer drives docker compose directly and never reads devcontainer.json, so the tripwire doesn't fire when starting via the supported entry point; VS Code attach to an already-running container also bypasses it (lifecycle commands only run on build/up).
The two paths diverge in what they provide:
| Capability | dev/devcontainer |
devcontainer.json (VS Code) |
|---|---|---|
| Build & run | docker compose via the script |
VS Code / devcontainer CLI |
| Toolchain (uv, just, sops, gh, node, claude) | Dockerfile — always applied | Dockerfile — always applied |
| UID/GID matching | DEV_UID/DEV_GID build args |
Not applied (uses image defaults) |
| Git config, gh auth, Claude setup | setup_git, setup_gh, setup_claude (in lib/host-bridge.sh) |
Not applied |
| Worktree support | Full (mount host .git, resolve worktree paths) |
Not supported |
| SSH agent forwarding | socat TCP bridge via host.docker.internal |
Not applied |
| GPG agent forwarding | socat TCP bridge via host.docker.internal |
Not applied |
| sops age keys | Mounted read-only via compose | Not applied |
| Port forwarding | Not handled (use docker compose ports) |
forwardPorts in devcontainer.json |
| VS Code extensions & settings | Not applied | customizations.vscode in devcontainer.json |
| Dep sync on lockfile change | sync_if_needed in the script |
Not applied |
| Image staleness check | Content hash of Dockerfile, compose, and COPY'd files | Handled by VS Code |
-
devcontainer: command not found—dev/isn't on PATH. Rundirenv allowin the repo root, or invoke as./dev/devcontainer. -
docker: command not foundor "Cannot connect to the Docker daemon" — Docker isn't installed or the daemon isn't running. Start Docker (systemctl --user start dockeror your distro's equivalent). -
docker composereports "unknown command" — you have Compose v1. Install Compose v2 (the script usesdocker compose, notdocker-compose). -
Build fails partway through
up— inspect withdevcontainer logs, thendevcontainer clean && devcontainer upto rebuild from scratch. -
Files in
.venv/owned by root, orPermission deniedwriting to the workspace — UID/GID drift between the host and the image.devcontainer clean && devcontainer uprebuilds with the current host UID/GID. -
uv syncruns every entry — the lockfile is newer than.venv/.last-sync. Expected aftergit pull; harmless. -
"all predefined address pools have been fully subnetted" — Docker ran out of subnet ranges for new networks. Orphaned networks accumulate when containers exit without going through
devcontainer down(host reboot, Docker daemon restart,docker stop). Clean up withdocker network pruneand retry. -
Auto-rebuild prompt won't go away — the content hash of
Dockerfile/ compose changed. Accept the rebuild, ordevcontainer cleanto reset state. -
GPG passphrase prompt inside the container despite the host not requiring one — The GPG agent bridge uses the agent-extra-socket, which disables external-cache lookups in pinentry (GNOME Keyring, macOS Keychain, etc.). The setup pre-warms gpg-agent's internal cache with a no-op sign, but if the cache expires mid-session you'll be prompted again. Bump the TTL in your host's
~/.gnupg/gpg-agent.conf:default-cache-ttl 28800 max-cache-ttl 28800Then restart the agent:
gpgconf --kill gpg-agent. The values above give an 8-hour window. To re-warm without restarting the container:devcontainer refresh-gpg. -
Container claude can't see host OAuth token after upgrading from a pre-shared-credentials build — the shared-credentials design relies on a
~/.claude/.credentials.json -> credentials/.credentials.jsonsymlink baked into the image and copied into the per-worktreeclaude-homenamed volume on first creation. Volumes that predate the symlink still hold the old plain file. Each worktree has its ownclaude-homevolume, so repeat per worktree — new worktrees and fresh checkouts aren't affected. Two ways to recover:# surgical: repair the symlink in a running container (keeps venv volume) devcontainer fix-credentials # full reset: destroy volumes and rebuild (also rebuilds the venv) devcontainer reset && devcontainer up
The container's ~/.claude is a per-worktree Docker volume, isolated from the host except for credentials (see the shared-credentials note below). On each entry (up, exec, claude, claude-dangerously-skip-permissions), setup-claude sets up the following:
- Credentials —
~/.claude/credentials/is bind-mounted read-write from the host. All containers and the host share a single.credentials.jsonvia this mount, so OAuth token refreshes in any container are immediately visible everywhere. On first run,setup_claude_credentialsmigrates the host's~/.claude/.credentials.jsoninto~/.claude/credentials/and leaves a symlink. - Global permissions and
CLAUDE.md— thepermissionsblock from~/.claude/settings.json, plus~/.claude/CLAUDE.md(copied from the read-only host mount) - Project permissions and memory — the
permissionsblock from~/.claude/projects/<host-project-key>/settings.jsonandsettings.local.json, plus the project'smemory/directory (copied)
Container-side Claude settings changes (e.g. permissions granted mid-session) are overwritten on the next entry. Host hooks are intentionally not copied — they reference host paths and binaries that don't exist inside the container.
A PreToolUse hook logs every tool invocation to .claude/container-audit/audit.jsonl in the workspace. Use the audit subcommand to review:
# summary (default): tool counts, bash prefixes, permissions not in host baseline
devcontainer audit
devcontainer audit --summary
# just the new permission patterns (for piping into settings)
devcontainer audit --new
# all observed patterns (including those already in baseline)
devcontainer audit --all
# clear the audit log
devcontainer audit --clearContainer session logs are written to .claude/container-sessions/ in the workspace, visible from the host.
Note
Shared credentials: The ~/.claude/credentials/ directory is bind-mounted read-write into every container. A container compromise gains write access to the host's credential file (read access was already possible via the read-only mount). This is a deliberate tradeoff to avoid cascading 401s from OAuth token refresh invalidating copies.
Volume persistence: down stops the container but leaves Docker volumes intact. Credentials live on the host filesystem (not in the volume), so reset does not scrub them — revoke tokens via your OAuth provider if needed.
Host git access: The host's .git directory is mounted read-write inside the container (required for git operations in worktrees). A container compromise could modify host git history, hooks, and refs.
SSH/GPG agent bridge (Linux-native): On Linux-native Docker, the socat SSH-agent and GPG-agent bridges bind to the Docker bridge gateway IP (e.g. 172.17.0.1). Any container on the same bridge network can connect to these ports and use the forwarded keys. On Docker Desktop and WSL2, the bridges bind to 127.0.0.1 and are not exposed. If you run untrusted sibling containers on the same Docker bridge, stop the bridges after your session (devcontainer down kills them) or isolate the dev container on a dedicated network.