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 only. The tooling uses GNU coreutils; the Nix base image is multi-arch (amd64 + arm64), while the classic Dockerfile installs amd64 binaries. macOS and Windows are not supported — a macOS entry path (VS Code "Reopen in Container" on the pulled Nix base) is tracked in #43.
Toolchain (container): Python 3.12, just, sops, gh, and Claude Code. Most projects get these from the Nix base — a CI-published multi-arch image pulled from ghcr, where a claude-code bump reships one layer instead of rebuilding everything (see nix/base/README.md). Overlays that drive their own Nix store (created with init --nix) build on the classic root Dockerfile instead, which also ships Node 22 and pins its own tool versions. devcontainer resolve shows which base a workspace gets (BASE=...); DEV_NIX_BASE=0/=1 force it. Project-specific tools (e.g. uv, direnv) are installed by the project overlay's install-system.sh; for example, the Python overlays pin uv 0.7.8.
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 host-side artifacts (keeps the shared project image)
devcontainer clean
# remove the project's shared images — affects ALL worktrees
devcontainer clean-images
# 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 upWorktrees of one project share a single image, tagged <project>-devimg:<fingerprint> where the fingerprint hashes every build input (Dockerfile, compose files, overlay scripts, baked build args). The first worktree builds it; siblings with identical build inputs reuse it and skip the build entirely. A branch that edits a build input gets its own tag — the two images coexist, so nothing is clobbered or rebuilt on every branch switch. Containers, networks, and per-worktree volumes stay isolated per worktree; devcontainer clean-images removes the project's images (all fingerprints) once no container uses them, and old fingerprints otherwise accumulate until you run it.
The image tag hashes build inputs only. Purely runtime inputs (host-mounts.txt / host-mounts.local.txt) feed a separate staleness hash that drives the "config changed, recreate?" prompt on a running container — so editing a host mount recreates the container without minting a new image tag or triggering a rebuild. Compose files are hashed whole (they mix build and runtime config), so a runtime-only compose edit still re-tags into a fully-cached rebuild — correct, just slightly noisy.
Project-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, nix/base/, 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).
The overlay also selects the container's base image: an overlay whose compose.override.yml mounts a nix seed volume (:/nix, from init --nix) or overrides classic-only Dockerfile build args (BASE_IMAGE, tool pins) builds on the classic root Dockerfile; every other overlay builds on the pulled Nix base via nix/base/Dockerfile.nix-default. The two are alternative /nix delivery strategies — see nix/base/README.md for the model table and escape hatches.
| 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). Pass --nix if your project drives Nix itself (its own flake, nix develop/nix build) — this seeds a writable, vscode-owned /nix volume and keeps you on the classic Dockerfile. Then edit the overlay:
Which base you get. By default your overlay builds on the CI-published Nix base pulled from ghcr — first up on a machine transfers ~0.76 GB (cached once; later claude-code bumps pull a single layer). This base ships no node/npm: if your project needs npx (e.g. npx-based MCP servers or tooling), install node in install-system.sh or run with DEV_NIX_BASE=0. Overlays created with init --nix (seed volume) or that override classic build args (BASE_IMAGE, tool pins) build on the classic root Dockerfile instead, which ships Node 22. Run devcontainer resolve to confirm which base your workspace gets (BASE=nix-base / BASE=classic, with the reason); DEV_NIX_BASE=0/=1 force it either way. See nix/base/README.md for the full model.
-
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(classic base — seed overlays andDEV_NIX_BASE=0) — 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).nix/base/Dockerfile.nix-defaulthonors the sameEXTRA_PATHcontract on the Nix-base path. To bump the Claude Code pin, rundevcontainer bump-claude-code— it updatesCLAUDE_CODE_VERSIONand the Nix base'spkgs/claude-code.nixtogether (pass an explicit version to pin it, or--checkto compare without editing);tests/test-claude-code-pin-sync.shguards the two against drift. Likewise, rundevcontainer bump-nixto bump the Nix pin inlib/nix-seed.sh— it rewrites the coupledNIX_VERSIONandNIX_INSTALLER_SHA256defaults together (downloading the target installer to compute its checksum), and accepts an explicit version or--check.
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).
To load completions for the current session, eval the emitted script:
# bash
eval "$(devcontainer completions bash)"
# zsh
eval "$(devcontainer completions zsh)"
# fish
devcontainer completions fish | sourceTo install them permanently, install-completions writes each shell's script to the location it autoloads from (honoring XDG_DATA_HOME/XDG_CONFIG_HOME):
# install for every shell whose binary is present
devcontainer install-completions
# or a specific shell
devcontainer install-completions bash
# preview target paths without writing anything
devcontainer install-completions --print-paths| Shell | Target | Notes |
|---|---|---|
| bash | ${XDG_DATA_HOME:-~/.local/share}/bash-completion/completions/devcontainer |
requires the bash-completion package; loaded on demand |
| zsh | ${XDG_DATA_HOME:-~/.local/share}/zsh/site-functions/_devcontainer |
the dir must be on $fpath before compinit |
| fish | ${XDG_CONFIG_HOME:-~/.config}/fish/completions/devcontainer.fish |
loaded automatically |
Start a new shell after installing.
Warning
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 | Fingerprint of build inputs (image hash) + runtime inputs (staleness hash) | 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 logsand retry: a failed build leaves no fingerprint tag, so the nextuprebuilds. For corrupted cached layers,devcontainer clean-imagesthendocker builder prunebefore retrying. -
Files in
.venv/owned by root, orPermission deniedwriting to the workspace — UID/GID drift between the host and the image. The host UID/GID is part of the image fingerprint, sodevcontainer clean && devcontainer upbuilds a fresh image matching the current host IDs. -
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 isn't authenticated, or shows the wrong account — each container seeds its own private token from a host profile (
DEV_CLAUDE_PROFILE, else the host's active profile). If that token expired, you re-logged-in on the host, or you want a different account, re-seed:# surgical: re-seed the private token from its profile (keeps venv volume) devcontainer fix-credentials # full reset: destroy volumes and rebuild (also rebuilds the venv) devcontainer reset && devcontainer up
To pin a container to a specific profile, set
DEV_CLAUDE_PROFILE=<name>beforeup/fix-credentials.
The container's ~/.claude is a per-worktree Docker volume, fully isolated from the host — including credentials: each container is seeded its own private token rather than sharing the host's credential file. On each entry (up, exec, claude, claude-dangerously-skip-permissions), setup-claude sets up the following:
- Credentials —
setup-claudeseeds a private~/.claude/.credentials.json(a regular file on the isolated volume), copied once from the read-only host profile store at~/.claude-host/credentials/<profile>.json— profile chosen byDEV_CLAUDE_PROFILE, defaulting to the host's active profile — and patches the matchingoauthAccountinto~/.claude.jsonsoauth statusreports the right account. The container refreshes its own token independently: no shared credential inode, so concurrent sessions never race on OAuth refresh (the failure mode of the old shared-mount design). See docs/adr/0001-devcontainer-private-token-isolation.md. Re-seed withdevcontainer fix-credentials; override with an explicit token viadevcontainer set-credentials. - 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) - Session transcripts — every
*.jsonlunder the host project key is mirrored into the container project key soclaude --resumecan continue a session started on the host. Resume locates a session by a cwd-derived project key (which differs between host and container) and each record carries an absolute cwd, so the host workspace prefix is rewritten to the container path as transcripts are copied. Host → container only; transcripts already present container-side are left untouched so continued work isn't clobbered.
Because the mirror runs on entry, a host session started after the container came up isn't visible until the next up/exec/claude. To pull it in immediately without a full re-setup:
devcontainer copy-host-transcriptsContainer-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
Private credentials: Each container seeds its own token from the read-only host profile store; the host credential file is never bind-mounted read-write. A container compromise can read the host profile store (via the :ro .claude-host mount) but cannot write host credentials, and cannot invalidate other containers' or the host's tokens. This also removes the concurrent-refresh auth loss the old read-write shared mount was built to tolerate (see docs/adr/0001-devcontainer-private-token-isolation.md).
Volume persistence: down stops the container but leaves Docker volumes intact. The seeded token now lives in the claude-home volume, so reset scrubs it (re-seeded on the next up) — revoke tokens via your OAuth provider if a container is compromised.
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.