# crib > Dev containers without the ceremony. A CLI tool that reads .devcontainer configs, builds the container, and gets out of the way. Supports Docker and Podman. Full documentation: https://fgrehm.github.io/crib/ Source code: https://github.com/fgrehm/crib --- ## Overview `crib` is a CLI tool that reads your `.devcontainer/devcontainer.json` config, builds the container, and gets out of your way. No agents injected into your container, no SSH tunnels, no IDE integration. Just Docker (or Podman) doing what Docker does. > **🐧 Linux first** > Linux is the primary platform. [macOS and Windows work too](/crib/guides/macos-windows/), with caveats. ## Principles - **Implicit workspace resolution.** `cd` into a project directory and run commands. `crib` walks up from your current directory to find `.devcontainer/devcontainer.json`. No workspace names to remember. - **No agent injection.** All container setup happens via `docker exec` from the host. Nothing gets installed inside your container that you didn't ask for. - **No SSH, no providers, no IDE integration.** `crib` is a CLI tool. It starts containers. What you do inside them is your business. - **Docker and Podman as first-class runtimes.** Auto-detected, configurable via `CRIB_RUNTIME`. - **Human-readable naming.** Containers show up as `crib-myproject-a1b2c3d` in `docker ps`, not opaque hashes. The short suffix makes workspace IDs unique across different directories with the same name. ## Why The [devcontainer spec](https://containers.dev/) is a good idea. A JSON file describes your development environment, and tooling builds a container from it. But the existing tools layer on complexity that gets in the way. [DevPod](https://github.com/loft-sh/devpod) was the most promising open-source option: provider-agnostic, IDE-agnostic, well-designed. But it was built for a broader scope than most people need. Providers, agents injected into containers, SSH tunnels, gRPC, IDE integrations. For someone who just wants to `docker exec` into a container and use their terminal, that is a lot of moving parts between you and your shell. Then [DevPod seems to be effectively abandoned](https://github.com/loft-sh/devpod/issues/1915) when Loft Labs shifted focus to vCluster. The project stopped receiving updates in April 2025, with no official statement and no path forward for the community. `crib` takes a different approach: do less, but do it well. Read the devcontainer config, build the image, run the container, set up the user and lifecycle hooks, done. No agents, no SSH, no providers, no IDE assumptions. Just Docker (or Podman) doing what Docker does. For a detailed breakdown, see the [comparison with alternatives](/crib/guides/comparison/). ## Background This isn't the first time [@fgrehm](https://github.com/fgrehm) has gone down this road. [vagrant-boxen](https://github.com/fgrehm/vagrant-boxen) (2013) tried to make Vagrant machines manageable without needing Puppet or Chef expertise. [Ventriloquist](https://fabiorehm.com/blog/2013/09/11/announcing-ventriloquist/) (2013) combined Vagrant and Docker to give developers portable, disposable dev VMs. [devstep](https://fabiorehm.com/blog/2014/08/26/devstep-development-environments-powered-by-docker-and-buildpacks/) (2014) took it further with "git clone, one command, hack" using Docker and Heroku-style buildpacks. The devcontainer spec has since standardized what that project was trying to achieve, so `crib` builds on that foundation instead of reinventing it. The [experience of using DevPod as a terminal-first developer](https://fabiorehm.com/blog/2025/11/11/devpod-ssh-devcontainers/), treating devcontainers as remote machines you SSH into rather than IDE-managed environments, shaped many of `crib`'s design decisions. The pain points (broken git signing wrappers, unnecessary cache invalidation, port forwarding conflicts, agent-related complexity) all pointed toward the same conclusion: the simplest path is often the best one. --- ## Installation ## Prerequisites `crib` requires a container runtime: - [Docker](https://docs.docker.com/engine/install/) (with Docker Compose v2), or - [Podman](https://podman.io/docs/installation) (with [podman-compose](https://github.com/containers/podman-compose)) `crib` auto-detects which runtime is available. To override, set `CRIB_RUNTIME=docker` or `CRIB_RUNTIME=podman`. > **🐧 Linux only** > `crib` is Linux-only. macOS and Windows support may be added if there's interest. ## Install with mise The easiest way to install and manage `crib` versions: ```bash mise use github:fgrehm/crib ``` See [mise documentation](https://mise.jdx.dev/) for setup instructions. ## Download from GitHub releases Download the latest binary from [GitHub releases](https://github.com/fgrehm/crib/releases): ```bash curl -Lo crib.tar.gz "https://github.com/fgrehm/crib/releases/latest/download/crib_linux_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" tar xzf crib.tar.gz crib install -m 755 crib ~/.local/bin/crib rm crib.tar.gz ``` Make sure `~/.local/bin` is in your `PATH`. You can also install the binary to `/usr/local/bin` or any other directory in your `PATH`. ## Verify ```bash crib version ``` --- ## Commands ## Quick reference | Command | Aliases | Description | |---------|---------|-------------| | `up` | | Create or start the workspace container | | `down` | `stop` | Stop and remove the workspace container | | `remove` | `rm`, `delete` | Remove the workspace container and state | | `shell` | `sh` | Open an interactive shell (detects zsh/bash/sh) | | `run` | | Run a command through a login shell (picks up mise/nvm/rbenv) | | `exec` | | Execute a command directly in the workspace container | | `restart` | | Restart the workspace container (picks up safe config changes) | | `rebuild` | | Rebuild the workspace (down + up) | | `logs` | | Show container logs | | `doctor` | | Check workspace health and diagnose issues | | `cache list` | | List package cache volumes | | `cache clean` | | Remove package cache volumes | | `prune` | | Remove stale and orphan workspace images | | `list` | `ls` | List all workspaces | | `status` | `ps` | Show workspace container status | | `version` | | Show version information | ## Global flags | Flag | Description | |------|-------------| | `--config`, `-C` | Path to the devcontainer config directory | | `--debug` | Enable debug logging | | `--verbose` | Show full compose output (suppressed by default) | ## Commands ### `crib up` Build the container image (if needed) and start the workspace. On first run, this builds the image, creates the container, syncs UID/GID, probes the user environment, and runs all [lifecycle hooks](/crib/guides/lifecycle-hooks/). On subsequent runs, it starts the existing container and runs only the resume hooks (`postStartCommand`, `postAttachCommand`). ### `crib down` Stop and remove the workspace container. This clears lifecycle hook markers, so the next `crib up` runs all hooks from scratch. Use this when you want a clean restart. ### `crib remove` Remove the workspace container, all associated images, and stored state. Shows a preview of what will be deleted and prompts for confirmation before proceeding. ```bash crib remove # preview + confirm crib remove --force # skip confirmation (useful in scripts) crib remove -f # shorthand ``` ### `crib shell` Open an interactive shell inside the container. crib detects the user's shell (zsh, bash, or sh) and uses the environment captured during `crib up` (including tools installed by version managers like mise, nvm, rbenv). ### `crib run` Run a command inside the container through a login shell. This sources shell init files (`.zshrc`, `.bashrc`, `.profile`) before running your command, making tools installed by version managers (mise, asdf, nvm, rbenv) available on PATH. ```bash crib run -- ruby -v crib run -- bundle install crib run -- npm test ``` ### `crib exec` Run a command directly inside the container (raw `docker exec`). Does not source shell init files, so tools installed by version managers may not be on PATH. Use `crib run` instead if the command depends on shell init. ```bash crib exec -- /usr/bin/env crib exec -- bash -c "echo hello" ``` Both `run` and `exec` inherit the probed environment (`remoteEnv`) from `crib up`. ### `crib restart` Restart the workspace, detecting what changed since the last `crib up`. See [Smart Restart](/crib/guides/smart-restart/) for details on how change detection works. ### `crib logs` Show container logs. Defaults to the last 50 lines. For compose workspaces, shows logs from all services. ```bash crib logs # last 50 lines crib logs -f # follow (stream) all logs crib logs --tail 100 # last 100 lines crib logs -a # show all logs (no tail limit) ``` ### `crib doctor` Check workspace health and diagnose issues. Detects orphaned workspaces (source directory deleted), dangling containers (crib label but no workspace state), and stale plugin data. Use `--fix` to auto-clean. ```bash crib doctor # check for issues crib doctor --fix # auto-fix found issues ``` ### `crib cache` Manage package cache volumes created by the [package cache plugin](/crib/guides/plugins/#package-cache). #### `crib cache list` List cache volumes for the current workspace. Shows volume name, provider, and disk usage. ```bash crib cache list # current workspace only crib cache list --all # all workspaces ``` #### `crib cache clean` Remove cache volumes. Without arguments, removes all cache volumes for the current workspace. Pass provider names to remove specific ones. ```bash crib cache clean # remove all for current workspace crib cache clean npm go # remove specific providers only crib cache clean --all # remove all crib cache volumes ``` ### `crib prune` Remove stale and orphan workspace images. Shows a dry-run preview with sizes before prompting for confirmation. - **Stale**: labeled images for an active workspace that are no longer the active build image or snapshot. - **Orphan**: labeled images for a workspace that no longer exists in `~/.crib/workspaces/`. ```bash crib prune # stale images for current workspace + confirm crib prune --all # all workspaces including orphans crib prune --force # skip confirmation ``` ### `crib rebuild` Full rebuild: runs `down` followed by `up`. Use this when the image needs to be rebuilt (changed Dockerfile, base image, or features). Clears any snapshot image so the build starts from scratch. ### `crib list` List all known workspaces and their container status. ### `crib status` Show the status of the current workspace's container, including published ports. For compose workspaces, shows all service statuses with their ports. --- ## Workspaces A workspace is crib's way of tracking a devcontainer project. When you run `crib up` in a project directory, crib creates a workspace that maps your project to a container and stores the state needed to manage it. ## Resolution crib finds your project automatically. From your current directory, it walks up the directory tree looking for: - `.devcontainer/devcontainer.json`, or - `.devcontainer.json` in the project root No workspace names to type. Just `cd` into your project (or any subdirectory) and run commands. ```bash cd ~/projects/myapp/src/components crib up # finds ~/projects/myapp/.devcontainer/devcontainer.json crib exec ls ``` You can override this with `--dir` (start the search from a different directory) or `--config` (point directly at a config directory, skipping the walk-up). ## Naming The workspace ID combines the project directory name with a short hash of the absolute path: ``` {slug}-{7-char-hash} ``` - The slug is the directory name, lowercased with non-alphanumeric characters replaced by hyphens. - The hash is the first 7 characters of the SHA-256 of the absolute project path. This guarantees uniqueness even when two different directories have the same name (e.g. `~/work/myapp` and `~/personal/myapp`). The container is named `crib-{workspace-id}` and labeled `crib.workspace={workspace-id}`: ``` CONTAINER ID IMAGE NAMES a1b2c3d4e5f6 node:20 crib-myapp-a1b2c3d f6e5d4c3b2a1 python:3.12 crib-data-pipeline-f6e5d4c ``` ## State Each workspace stores its state in `~/.crib/workspaces/{id}/`: | File | Purpose | |---|---| | `workspace.json` | Project metadata: source path, config location, timestamps | | `result.json` | Last run result: container ID, merged config, remote user, env | | `hooks/*.done` | Markers for lifecycle hooks that have already run | | `plugins/` | Plugin state (shell history, credentials, etc.) | `result.json` is saved early during `crib up`, before lifecycle hooks finish. This lets you run `crib exec` or `crib shell` in another terminal while hooks are still executing. ## Lifecycle | Command | Effect on workspace | |---|---| | `crib up` | Creates the workspace (if new) and starts the container. Updates `result.json`. | | `crib down` | Stops the container. Clears hook markers so all hooks re-run on next `up`. | | `crib restart` | Restarts or recreates the container depending on what changed. | | `crib rebuild` | Full rebuild: tears down the container, clears hooks, rebuilds the image, starts fresh. | | `crib remove` | Stops the container, removes all workspace images, and deletes the workspace state directory. | `crib down` preserves workspace state. The container is gone, but `crib up` will recreate it and re-run all lifecycle hooks. `crib remove` is a full cleanup: container, images, and state. ## Listing workspaces `crib list` shows all tracked workspaces: ```bash $ crib list WORKSPACE SOURCE myapp-a1b2c3d /home/user/projects/myapp data-pipeline-f6e5d4c /home/user/projects/data-pipeline ``` ## Per-project configuration A `.cribrc` file in the project root can set defaults for that workspace: ``` config=.devcontainer/python ``` This is equivalent to always passing `--config .devcontainer/python` when running crib commands from that project. Useful for projects with multiple devcontainer configs where you want a default. --- ## Lifecycle Hooks The devcontainer spec defines [lifecycle hooks](https://containers.dev/implementors/spec/#lifecycle) that run at different stages. `crib` supports all of them: | Hook | Runs on | When | Runs once? | |------|---------|------|------------| | `initializeCommand` | Host | Before image build/pull, every `crib up` | No | | `onCreateCommand` | Container | After first container creation | Yes | | `updateContentCommand` | Container | After first container creation | Yes | | `postCreateCommand` | Container | After `onCreateCommand` + `updateContentCommand` | Yes | | `postStartCommand` | Container | After every container start | No | | `postAttachCommand` | Container | On every `crib up` | No | Note: in the official spec, `updateContentCommand` re-runs when source content changes (e.g. git pull in Codespaces). `crib` doesn't detect content updates, so it behaves identically to `onCreateCommand`. Similarly, `postAttachCommand` maps to "attach" in editors. `crib` runs it on every `crib up` since there's no separate attach step. Each hook accepts a string, an array, or a map of named commands: ```jsonc // string "postCreateCommand": "npm install" // array "postCreateCommand": ["npm", "install"] // named commands — all run in parallel, all must succeed "postCreateCommand": { "deps": "npm install", "db": "rails db:setup" } ``` For `initializeCommand` (host-side), the array form runs as a direct exec without a shell. For container hooks, both string and array forms are run through `sh -c`. Here's a `devcontainer.json` showing all hooks: ```jsonc { // Host: fail fast if secrets are missing. "initializeCommand": "test -f config/master.key || (echo 'Missing config/master.key' >&2 && exit 1)", // Container, once: install dependencies and set up the database. "onCreateCommand": "bundle install && rails db:setup", // Container, once: same timing as onCreateCommand in crib (see note above). "updateContentCommand": "bundle install", // Container, once: runs after onCreateCommand + updateContentCommand finish. "postCreateCommand": "git config --global --add safe.directory /workspaces/myapp", // Container, every start: launch background services. "postStartCommand": "redis-server --daemonize yes", // Container, every crib up: per-session info. "postAttachCommand": "ruby -v && rails --version" } ``` ## `initializeCommand` `initializeCommand` is the only hook that runs on the host. It runs before the image is built or pulled, making it useful for pre-flight checks and local file setup. **Fail fast when required secrets are missing:** ```jsonc { "initializeCommand": "test -f config/master.key || (echo 'Missing config/master.key' >&2 && exit 1)" } ``` If `config/master.key` is missing, `crib up` fails immediately with a clear message instead of building an image that won't start. **Seed `.env` from a template:** ```jsonc { "initializeCommand": "test -f .env || cp .env.example .env" } ``` This ensures `.env` is present on the host before the container starts, so bind mounts and docker compose `env_file` directives pick it up. **Multiple checks with named commands (run in parallel):** ```jsonc { "initializeCommand": { "env": "test -f .env || cp .env.example .env", "credentials": "test -f config/master.key || (echo 'Missing config/master.key' >&2 && exit 1)" } } ``` ## `onCreateCommand` Runs once after the container is first created. Use it for one-time setup that should survive container restarts. **Install dependencies and set up the database:** ```jsonc { "onCreateCommand": "bundle install && rails db:setup" } ``` **Multiple setup tasks with named commands (run in parallel):** ```jsonc { "onCreateCommand": { "deps": "npm install", "db": "rails db:setup", "tools": "mise install" } } ``` ## `postCreateCommand` Runs once after `onCreateCommand` and `updateContentCommand` finish. Good for configuration that depends on installed dependencies. **Configure git safe directory:** ```jsonc { "postCreateCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}" } ``` ## `postStartCommand` Runs after every container start (including restarts). Use it for services that need to be running. **Start background services:** ```jsonc { "postStartCommand": { "redis": "redis-server --daemonize yes", "postgres": "pg_ctlcluster 16 main start" } } ``` ## `postAttachCommand` Runs on every `crib up`. Use it for per-session output or checks. **Show tool versions:** ```jsonc { "postAttachCommand": "node -v && npm -v" } ``` ## `waitFor` `waitFor` controls when `crib up` reports "Container ready." in its progress output. It doesn't skip any hooks — all hooks still run to completion. It only affects when the ready message appears. Default: `updateContentCommand`. Valid values: `initializeCommand`, `onCreateCommand`, `updateContentCommand`, `postCreateCommand`, `postStartCommand`. **Wait until postCreate before reporting ready:** ```jsonc { "waitFor": "postCreateCommand", "postCreateCommand": "bundle install" } ``` With this config, `crib up` shows "Container ready." only after `bundle install` finishes. Useful when `postCreateCommand` is a prerequisite for the container to be usable. --- ## Smart Restart `crib restart` is faster than `crib rebuild` because it knows what changed. When you edit your devcontainer config, `restart` compares the current config against the stored one and picks the right strategy: | What changed | What happens | Lifecycle hooks | |---|---|---| | Nothing | Simple container restart (`docker restart`) | `postStartCommand` + `postAttachCommand` | | Volumes, mounts, ports, env, runArgs, user | Container recreated with new config | `postStartCommand` + `postAttachCommand` | | Image, Dockerfile, features, build args | Error, suggests `crib rebuild` | - | This follows the [devcontainer spec's Resume Flow](https://containers.dev/implementors/spec/#lifecycle): on restart, only `postStartCommand` and `postAttachCommand` run. Creation-time hooks (`onCreateCommand`, `updateContentCommand`, `postCreateCommand`) are skipped since they already ran when the container was first created. The practical effect: you can tweak a volume mount or add an environment variable, run `crib restart`, and be back in your container in seconds instead of waiting for a full rebuild and all creation hooks to re-execute. ```bash # Changed a volume in docker-compose.yml? Or added a mount in devcontainer.json? crib restart # recreates the container, skips creation hooks # Changed the base image or added a feature? crib restart # tells you to run 'crib rebuild' instead ``` --- ## Built-in Plugins `crib` ships four plugins that run automatically before each container is created. They inject credentials, SSH config, shell history persistence, and shared package caches into every workspace without any devcontainer.json boilerplate. Plugins run during `crib up` and `crib rebuild`. They are fail-open: if a plugin can't find something it needs (no SSH agent running, no Claude credentials on disk), it skips silently and doesn't block container creation. --- ## Shell history Persists your bash/zsh history across container recreations. Without this, every rebuild starts with an empty history. **What it does:** - Creates `~/.crib/workspaces/{id}/plugins/shell-history/.shell_history` on the host - Bind-mounts that directory into the container at `~/.crib_history/` - Sets `HISTFILE=~/.crib_history/.shell_history` in the container Both bash and zsh read `HISTFILE`, so this works regardless of which shell you use inside the container. The history file is mounted as a directory (not a file directly) so the shell can do atomic renames when saving, which avoids `EBUSY` errors on Docker and Podman. **No configuration needed.** It always runs. --- ## Package cache Shares package manager caches across all your workspaces via named Docker volumes. Without this, every rebuild re-downloads all dependencies from scratch. **Configure in `.cribrc`** (in your project root): ``` cache = npm, pip, go ``` Comma-separated list of providers. Each one creates a `crib-cache-{workspace}-{provider}` named volume mounted at the standard cache directory inside the container. Volumes are per-workspace, so different projects don't share cached data. ### Supported providers | Provider | Mount target | Notes | |----------|-------------|-------| | `npm` | `~/.npm` | | | `yarn` | `~/.cache/yarn` | | | `pip` | `~/.cache/pip` | | | `go` | `~/go/pkg/mod` | Also sets `GOMODCACHE` so it works with any `GOPATH` | | `cargo` | `~/.cargo` | Also sets `CARGO_HOME` so it works with devcontainer images that use `/usr/local/cargo` | | `maven` | `~/.m2/repository` | | | `gradle` | `~/.gradle/caches` | | | `bundler` | `~/.bundle` | Sets `BUNDLE_PATH` and `BUNDLE_BIN`; adds `~/.bundle/bin` to PATH via `/etc/profile.d/` | | `apt` | `/var/cache/apt` | System path; disables `docker-clean` so cached `.deb` files persist | | `downloads` | `~/.cache/crib` | General-purpose cache; sets `CRIB_CACHE` env var for easy access | Unknown provider names produce a warning at startup and are skipped. The `downloads` provider is a general-purpose persistent directory for anything that doesn't have its own provider. Use it for large downloads, compiled tools, or any files you want to survive rebuilds: ```bash curl -L -o "$CRIB_CACHE/some-tool.tar.gz" https://example.com/some-tool.tar.gz tar -xzf "$CRIB_CACHE/some-tool.tar.gz" -C /usr/local/bin ``` Use `crib cache list` to see which cache volumes exist and how much space they use, and `crib cache clean` to remove them. See [Commands](/crib/reference/commands/#crib-cache) for details. ### Build-time caching When cache providers are configured, crib also attaches [BuildKit cache mounts](https://docs.docker.com/build/cache/optimize/#use-cache-mounts) to the `RUN` instructions that install DevContainer Features. This speeds up feature installation across rebuilds by reusing cached packages (especially `apt` packages, which most features install). Build-time caching applies to the feature-generated Dockerfile only, not to user Dockerfiles or compose service builds. The build cache is managed by BuildKit and is separate from the runtime named volumes. For `apt`, crib also disables the `docker-clean` hook in the generated Dockerfile so that cached `.deb` files are preserved across builds. > **First run** > The first `crib up` after adding a cache provider still downloads everything (the volume is empty). Subsequent rebuilds reuse the cached data. --- ## Coding agents Shares Claude Code credentials with the container so you can run `claude` without authenticating every time. Two modes are available. ### Host mode (default) Copies your host's `~/.claude/.credentials.json` into the container on each `crib up`. If the file doesn't exist on the host, the plugin is a no-op. A minimal `~/.claude.json` with `{"hasCompletedOnboarding":true}` is also injected to skip the onboarding prompt. It won't overwrite an existing file in the container. This mode is transparent — nothing to configure. ### Workspace mode For teams using a shared Claude organization account, or when you want to authenticate once inside the container and have those credentials persist across rebuilds. Configure it in `devcontainer.json`: ```jsonc { "customizations": { "crib": { "coding-agents": { "credentials": "workspace" } } } } ``` In workspace mode: - Host credentials are **not** injected - `~/.crib/workspaces/{id}/plugins/coding-agents/claude-state/` is bind-mounted to `~/.claude/` inside the container - Credentials you create inside the container (by running `claude` and authenticating) are stored in that directory and survive rebuilds - The `~/.claude.json` onboarding config is still re-injected via `docker exec` on each rebuild (with `IfNotExists` semantics, so your customizations are preserved) This is the right choice when: - Your team shares a Claude organization account that requires SSO or a different login than your personal account - You want credentials scoped to a specific project workspace > **First use** > After switching to workspace mode, run `claude` inside the container and authenticate. From that point on, credentials persist automatically. ### Credential cleanup In both modes, plugin data (including credentials) is stored on the host under `~/.crib/workspaces/{id}/plugins/coding-agents/`. Running `crib remove` deletes the workspace and all plugin data with it. If you delete the project directory without running `crib remove` first, the workspace state (including any cached credentials) remains on disk. To clean it up manually, delete the workspace directory listed by `crib list`, or remove it directly from `~/.crib/workspaces/`. --- ## SSH Shares your SSH configuration with the container so that git operations, remote connections, and commit signing work the same way inside the container as they do on your host. ### SSH agent forwarding If `SSH_AUTH_SOCK` is set on your host and the socket exists, the plugin: - Bind-mounts the socket into the container at `/tmp/ssh-agent.sock` - Sets `SSH_AUTH_SOCK=/tmp/ssh-agent.sock` inside the container This lets `git push`, `ssh`, and other tools use your host's keys without any keys being copied into the container. Make sure your SSH agent is running on the host before `crib up`: ```bash eval $(ssh-agent) ssh-add ~/.ssh/id_ed25519 ``` Or add `ssh-add` to your shell startup file so keys are always loaded. ### SSH config If `~/.ssh/config` exists, it's copied into the container at `~/.ssh/config`. Host aliases, `ProxyJump` rules, and other SSH settings are available inside the container. ### SSH public keys `*.pub` files from `~/.ssh/` are copied into the container. Private keys are never copied. This is enough for git commit signing (see below), since signing uses the forwarded agent rather than the private key directly. `authorized_keys` files are skipped. ### Git SSH signing If your host's global git config has `gpg.format = ssh`, the plugin extracts your signing configuration and generates a minimal `.gitconfig` for the container. The generated config includes: | Setting | Source | |---------|--------| | `user.name` | `git config --global user.name` | | `user.email` | `git config --global user.email` | | `user.signingkey` | `git config --global user.signingkey` (path rewritten to container home) | | `gpg.format` | `ssh` | | `gpg.ssh.program` | `git config --global gpg.ssh.program` (if set) | | `commit.gpgsign` | `git config --global commit.gpgsign` (if set) | | `tag.gpgsign` | `git config --global tag.gpgsign` (if set) | If `user.signingkey` is a path under `~/.ssh/` (e.g. `~/.ssh/id_ed25519-sign.pub`), it's rewritten to the equivalent path inside the container. The public key file is copied there by the SSH public keys step above. **Signing works via the agent.** OpenSSH 8.2+ can sign commits using only the public key file plus the forwarded agent socket — no private key in the container. **Host git config setup:** ```bash # Enable SSH signing globally. git config --global gpg.format ssh git config --global user.signingkey ~/.ssh/id_ed25519.pub # Auto-sign all commits and tags. git config --global commit.gpgsign true git config --global tag.gpgsign true ``` If you already sign commits on your host, nothing else is needed. The plugin reads your existing config. > **Per-project gitconfig** > The generated `.gitconfig` is injected before lifecycle hooks run. If your `postCreateCommand` sets up a dotfiles repo that writes a `.gitconfig`, that will take precedence. The plugin-generated file is a fallback, not a hard override. **If `gpg.format` is not `ssh`,** the plugin skips the gitconfig step entirely. Git settings for GPG signing (OpenPGP) are not forwarded. --- ## Custom Config Directory By default `crib` finds your devcontainer config by walking up from the current directory, looking for `.devcontainer/devcontainer.json`. If your config lives elsewhere (e.g. you have multiple configs or a non-standard name), use `--config` / `-C` to point directly to the folder that contains `devcontainer.json`: ```bash crib -C .devcontainer-custom up crib -C .devcontainer-custom shell ``` To avoid repeating that flag, create a `.cribrc` file in the directory you run `crib` from: ```ini # .cribrc config = .devcontainer-custom ``` An explicit `--config` on the command line takes precedence over `.cribrc`. --- ## macOS & Windows `crib` compiles and runs on macOS and Windows, but it's developed and tested on Linux. This page covers what to expect and how to get the best experience on other platforms. > **Untested** > The recommendations on this page have not been personally tested by the author. They're based on how Docker/Podman work on macOS and Windows generally, and should apply to `crib` since it's just a Docker/Podman client. If you try these and find issues, please [open an issue](https://github.com/fgrehm/crib/issues). ## The short version `crib` talks to Docker (or Podman) through the CLI, it doesn't care what OS you're on. If `docker run hello-world` works, `crib` will work. The catch is **bind mount performance**: on macOS and Windows, Docker runs inside a Linux VM, so every file read/write from your host crosses a virtualization boundary. On Linux this cost is zero. On macOS/Windows it's noticeable. ## macOS ### Install ```bash # Grab the darwin/arm64 or darwin/amd64 binary from releases # https://github.com/fgrehm/crib/releases # Or use mise mise use github:fgrehm/crib ``` ### Docker runtime options Any of these should work with `crib`: - **[Docker Desktop](https://www.docker.com/products/docker-desktop/)**: most common, includes VirtioFS support - **[OrbStack](https://orbstack.dev/)**: lightweight alternative, generally faster mounts - **[Colima](https://github.com/abiosoft/colima)**: open-source, supports VirtioFS with `--vm-type=vz --mount-type=virtiofs` - **[Rancher Desktop](https://rancherdesktop.io/)**: another option, uses Lima under the hood - **[Podman Desktop](https://podman-desktop.io/)**: if you prefer Podman (set `CRIB_RUNTIME=podman`) ### Bind mount performance This is the main thing you'll notice. Your project source is bind-mounted from macOS into the container, which means every file operation goes through the VM's filesystem layer. **How much slower?** Roughly [3x slower than native with VirtioFS](https://www.paolomainardi.com/posts/docker-performance-macos-2025/) enabled. Without VirtioFS (older gRPC-FUSE), it can be [5-6x slower](https://www.docker.com/blog/speed-boost-achievement-unlocked-on-docker-desktop-4-6-for-mac/). For small projects you won't notice. For large projects with tens of thousands of files (think: `node_modules`, large Go modules, big monorepos), you will. **How VS Code sidesteps this:** VS Code's Dev Containers extension runs a server process *inside* the container. File operations happen container-local, avoiding the mount penalty for editor operations. When you use "[Open Folder in Dev Container](https://code.visualstudio.com/docs/devcontainers/containers)" with a volume, the source lives in a Docker volume (native speed) rather than a bind mount. `crib` uses bind mounts by default because it's tool-agnostic, it doesn't assume you're using any particular editor. ### Recommended setup > **Note:** > These recommendations are based on general Docker-on-macOS best practices, not first-hand testing with `crib`. Your mileage may vary. 1. **Enable VirtioFS** in Docker Desktop (Settings > General > "VirtioFS"). It's the default on recent versions but worth confirming. If using Colima: `colima start --vm-type=vz --mount-type=virtiofs`. 2. **Use named volumes for heavy directories.** The `mounts` field in `devcontainer.json` lets you put things like `node_modules` or `vendor` on a Docker volume while keeping your source on a bind mount: ```json { "image": "node:20", "mounts": [ "source=${localWorkspaceFolderBasename}-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume" ] } ``` This is the single biggest performance win. The bind mount handles your source code (which needs to be editable from the host), while heavy dependency trees stay container-local. See the [VS Code performance guide](https://code.visualstudio.com/remote/advancedcontainers/improve-performance) for more on this technique. 3. **Keep your source on the Mac filesystem**, not on a network drive or external volume. APFS + VirtioFS is the fast path. ### SSH agent forwarding The `ssh` plugin forwards your SSH agent into the container. On macOS, Docker Desktop exposes the host SSH agent socket, so this should work out of the box. If you're using Colima or another runtime, you may need to ensure the SSH agent socket is mounted. Check your runtime's documentation. ### File watching File watchers (`inotify` inside the container) may not fire reliably through bind mounts on some runtimes. If your dev server doesn't pick up changes, try polling-based watch modes: - **Webpack/Vite:** `CHOKIDAR_USEPOLLING=true` or `usePolling: true` in config - **nodemon:** `--legacy-watch` - **Go (air/reflex):** Check if your tool supports polling mode OrbStack and Docker Desktop with VirtioFS handle `inotify` events better than older configurations, but polling is the reliable fallback. ## Windows `crib` does not currently publish Windows binaries (only Linux and macOS). That said, it should work on Windows through WSL 2, since WSL runs a real Linux environment where the Linux binary works natively. ### WSL 2 (recommended) The expected approach on Windows is running `crib` *inside* WSL 2 with your source code on the WSL filesystem: 1. Install WSL 2 with a Linux distro (Ubuntu, etc.) 2. Install Docker Desktop with the WSL 2 backend enabled 3. Install `crib`'s Linux binary inside WSL 4. Keep your source code in the WSL filesystem (`/home/you/projects/...`), not on `/mnt/c/...` This matters because files on the WSL 2 filesystem are accessed at near-native speed by Docker. Files on `/mnt/c/` (the Windows filesystem) go through an additional translation layer that's significantly slower. ### Line endings If your Git config uses `core.autocrlf = true` (the Windows default), files will have CRLF line endings on the host but the Linux container expects LF. This can cause issues with shell scripts and other tools. Options: - Set `core.autocrlf = input` globally or per-repo - Add a `.gitattributes` file: `* text=auto eol=lf` ## What about Podman on macOS/Windows? [Podman Desktop](https://podman-desktop.io/) uses a similar VM-based approach to Docker Desktop. Set `CRIB_RUNTIME=podman` and it should work. The same bind mount performance considerations apply, it's the same fundamental architecture (Linux VM on the host) regardless of which runtime you use. ## Known limitations These are things that work on Linux but may have rough edges elsewhere: | Area | Status on macOS/Windows | |------|------------------------| | Bind mount performance | Slower (see above) | | File watching (`inotify`) | May need polling fallback | | UID/GID mapping | Docker Desktop handles this; Colima/Podman may need config | | SSH agent forwarding | Works on Docker Desktop; other runtimes vary | | `userEnvProbe` shell detection | Untested on Windows-native (fine in WSL) | | Integration tests | Not run on macOS/Windows in CI | ## Reporting issues If you hit a platform-specific problem, please open an issue with: - Your OS and version - Docker/Podman runtime and version (`docker version`, `docker info`) - If macOS: which runtime (Docker Desktop, OrbStack, Colima) and filesystem driver (VirtioFS, gRPC-FUSE) - If Windows: WSL 2 or native We want `crib` to work well everywhere, reports from macOS and Windows users help make that happen. ## Further reading - [Docker on macOS performance benchmarks (2025)](https://www.paolomainardi.com/posts/docker-performance-macos-2025/) - thorough comparison of Docker Desktop, Colima, OrbStack, and Lima with VirtioFS - [Docker Desktop VirtioFS announcement](https://www.docker.com/blog/speed-boost-achievement-unlocked-on-docker-desktop-4-6-for-mac/) - the feature that made macOS bind mounts bearable - [VS Code: Improve disk performance](https://code.visualstudio.com/remote/advancedcontainers/improve-performance) - the named volume trick for `node_modules` and similar - [Docker Desktop settings](https://docs.docker.com/desktop/settings-and-maintenance/settings/) - where to enable VirtioFS --- ## Comparison with alternatives > **Living document** > This page reflects the state of things as of March 2026. The tools listed here are actively developed (except DevPod), so details may shift over time. `crib` isn't the only way to use devcontainers outside VS Code. Here's how the alternatives compare for terminal-first developers. ## The landscape There are three main ways to use `devcontainer.json` without VS Code: - **`crib`**: CLI-only, no agents, plugin system for DX niceties - **`devcontainers/cli`**: the official reference implementation, powers VS Code and Codespaces behind the scenes - **DevPod**: client-only tool with SSH agent injection, provider system for running on any backend (local, cloud, Kubernetes). [Effectively abandoned](https://github.com/loft-sh/devpod/issues/1915) since April 2025; a [community fork](https://github.com/skevetter/devpod) is continuing development. ## Quick comparison | | `crib` | `devcontainers/cli` | DevPod | |---|---|---|---| | **Language** | Go | TypeScript (Node.js) | Go | | **Binary** | Native | Bundled Node.js | Native | | **Workspace from CWD** | ✅ Day one | ✅ [v0.82.0](https://github.com/devcontainers/cli/issues/29) | ❌ Named workspaces | | **`shell` command** | ✅ (detects `zsh`/`bash`/`sh`) | ❌ (`exec` only) | ✅ (via SSH) | | **[Smart restart](/crib/guides/smart-restart/)** | ✅ (change detection) | ❌ | ❌ | | **Plugin system** | ✅ (`ssh`, `shell-history`, `coding-agents`) | ❌ | ❌ (providers, not plugins) | | **Stop (keep container)** | ❌ (`stop` = `down`) | ✅ `stop` | ✅ `stop` | | **Stop + remove** | ✅ `down` / `stop` | ✅ `down` | ✅ `delete` | | **`build --push`** | ❌ | ✅ | ❌ | | **`read-configuration`** | ❌ | ✅ (JSON output) | ❌ | | **Feature/template testing** | ❌ | ✅ | ❌ | | **Dotfiles support** | _Can be implemented with plugins_ | ✅ (`--dotfiles-*`) | ✅ | | **macOS / Windows** | [Works, not primary target](/crib/guides/macos-windows/) | ✅ | ✅ | | **Podman (rootless)** | ✅ First-class | Partial | Partial | | **SSH into container** | [Being considered](/crib/contributing/roadmap/) | ❌ | ✅ (agent injection) | | **Remote/cloud backends** | ❌ Local only | ❌ Local only | ✅ (providers) | | **IDE integration** | ❌ By design (_could be a plugin_) | ✅ (VS Code, Codespaces) | ✅ (VS Code, JetBrains) | | **Status** | Active (v0.4.0, Mar 2026) | Active | Abandoned (Apr 2025) | ## When to use what **Use `crib` if** you want a terminal-first workflow, care about Podman support, and want plugins that handle SSH forwarding, shell history, and AI coding tool credentials without touching your `devcontainer.json`. **Use `devcontainers/cli` if** you need CI prebuilds (`build --push`), scripting integration (`read-configuration`), or are authoring features/templates. It's also the safest choice for maximum spec compliance since it *is* the reference implementation. **Use DevPod if** you need remote backends (cloud VMs, Kubernetes) and your team already has it working. The SSH-into-container approach gives native filesystem performance on macOS. Note that the original project has had no updates since April 2025, though a [community fork](https://github.com/skevetter/devpod) is carrying it forward. ## Architecture differences ### How source code gets into the container This is the fundamental difference that affects everything else: **`crib` and `devcontainers/cli`** bind-mount the source from the host into the container. Your editor runs on the host and edits files directly. Simple, but on macOS/Windows this means file operations cross the VM boundary (see [macOS & Windows](/crib/guides/macos-windows/)). **VS Code Dev Containers** runs a VS Code Server *inside* the container. The UI is on the host, but file I/O happens container-local. When you use "[Open Folder in Container](https://code.visualstudio.com/docs/devcontainers/containers)" with a volume, source lives in a Docker volume at native speed. **DevPod** injects an agent binary and SSH server into the container. Your editor (`nvim`, VS Code, JetBrains) connects over SSH. Source can live in a volume (native speed) or be bind-mounted. Editors that connect over SSH edit on the container's filesystem, not through a bind mount, so there's no performance penalty. ### What happens inside the container | | `crib` | `devcontainers/cli` | DevPod | |---|---|---|---| | Agent injected | ❌ | ❌ | ✅ (Go binary) | | SSH server | ❌ | ❌ | ✅ (started by agent) | | Extra processes | None | None | Agent daemon, SSH | | Setup method | `docker exec` | `docker exec` | Agent via SSH/gRPC | `crib` aims for nothing inside the container you didn't ask for (though bundled plugins are enabled by default and can inject mounts, env vars, and files). DevPod's model is "full remote development environment." Neither is wrong, they serve different use cases. ## Plugin system vs Features vs Providers These three extensibility models solve different problems: **DevContainer Features** (all tools support these) are OCI-distributed install scripts that run at image build time. They add tools to the image (`node`, `go`, Docker-in-Docker). They can't do anything at container creation or runtime. **`crib` plugins** run at container creation time. They inject mounts, environment variables, and files into the container. The bundled plugins handle SSH agent forwarding, shell history persistence, and Claude Code credentials, things that need to happen at runtime, not build time. **DevPod providers** are a completely different concept. They control *where* the container runs (local Docker, AWS, Kubernetes, etc.). `crib` is local-only by design, so providers aren't relevant. The gap that `crib` plugins fill: with `devcontainers/cli`, if you want SSH forwarding or persistent history, you write it into your `devcontainer.json` (mounts, env vars, lifecycle hooks). With `crib`, plugins handle it automatically for every workspace. Less boilerplate, works everywhere without per-project config. ## What `crib` doesn't have (and whether it matters) **`build --push` for CI prebuilds.** If you're prebaking images in CI, `devcontainers/cli` is the right tool. `crib` focuses on the local development workflow. You could use `devcontainers/cli` in CI and `crib` locally, they read the same `devcontainer.json`. **`read-configuration` for scripting.** Useful for tooling that needs to parse devcontainer config programmatically. `crib`'s `--json` flag is planned but not shipped yet. **Feature/template testing tools.** You can [test Features locally with `crib`](/crib/guides/authoring-features/#testing-locally-with-crib), but for automated test suites and template scaffolding, use `devcontainers/cli`'s `features test` and `templates apply`. **Stopping without removing the container.** `crib`'s `down` (and its `stop` alias) always removes the container. This is a deliberate choice, lifecycle hook markers are cleared so the next `up` is clean. If you need to pause a container without removing it, use `docker stop` directly. ## Links - [`devcontainers/cli` source](https://github.com/devcontainers/cli) - [devcontainer spec](https://containers.dev/) - [DevPod source](https://github.com/loft-sh/devpod) (abandoned, [discussion](https://github.com/loft-sh/devpod/issues/1963)) - [DevPod community fork](https://github.com/skevetter/devpod) - [VS Code Dev Containers docs](https://code.visualstudio.com/docs/devcontainers/containers) - [Coder Dev Containers integration](https://coder.com/docs/admin/templates/extending-templates/devcontainers) (uses `devcontainers/cli` under the hood) --- ## Troubleshooting ## `.crib-features/` in your project directory When DevContainer Features are installed, `crib` creates a `.crib-features/` directory inside your project's build context during image builds. It's cleaned up automatically after the build, but if the process is killed (e.g. SIGKILL, power loss), it may be left behind. Add it to your global gitignore so it never gets committed in any project: ```bash echo '.crib-features/' >> ~/.config/git/ignore ``` Or if you use `~/.gitignore` as your global ignore file: ```bash echo '.crib-features/' >> ~/.gitignore ``` Make sure git knows where your global ignore file is: ```bash # only needed if you haven't set this before git config --global core.excludesFile '~/.config/git/ignore' ``` Note: `~/.config/git/ignore` is git's default location (since git 1.7.12), so `core.excludesFile` only needs to be set if you use a different path. ## Podman: short-name image resolution Podman requires fully qualified image names by default. If you see errors like: ```text Error: short-name "postgres:16-alpine" did not resolve to an alias and no unqualified-search registries are defined in "/etc/containers/registries.conf" ``` Add Docker Hub as an unqualified search registry: ```ini # /etc/containers/registries.conf (or a drop-in under /etc/containers/registries.conf.d/) unqualified-search-registries = ["docker.io"] ``` This lets Podman resolve short names like `postgres:16-alpine` to `docker.io/library/postgres:16-alpine`, matching Docker's default behavior. ## Podman: "Executing external compose provider" warning When using `podman compose` (which delegates to `podman-compose`), you'll see this on every invocation: ```text >>>> Executing external compose provider "/usr/bin/podman-compose". Please see podman-compose(1) for how to disable this message. <<<< ``` Silence it by adding to `~/.config/containers/containers.conf`: ```ini [engine] compose_warning_logs = false ``` Note: the `PODMAN_COMPOSE_WARNING_LOGS=false` env var is documented but [does not work](https://github.com/containers/podman/issues/23441). ## Podman: missing `pasta` and `aardvark-dns` If you see errors about `pasta` not found or `aardvark-dns binary not found`, install the networking packages: ```bash # Debian/Ubuntu sudo apt install passt aardvark-dns ``` `pasta` provides rootless network namespace setup and `aardvark-dns` enables container DNS resolution. Without them, rootless Podman containers can't start or resolve hostnames. ## `localhost:port` not reachable even though the port is published If a container port is published but `http://localhost:PORT` fails (connection refused or reset), check how `localhost` resolves on your machine: ```bash getent hosts localhost ``` If it resolves to `::1` (IPv6) rather than `127.0.0.1` (IPv4), the port listener and the address don't match. Podman publishes ports to `0.0.0.0` (IPv4 only) by default in rootless mode, so `localhost` → `::1` misses it. **Fix:** Use `127.0.0.1` instead of `localhost`: ``` http://127.0.0.1:PORT ``` Or, if you need `localhost` to work, add an explicit IPv4 entry to `/etc/hosts`: ``` 127.0.0.1 localhost ``` (Most systems have this but some distros only keep the IPv6 entry.) ## Rootless Podman and bind mount permissions In rootless Podman, the host UID is remapped to UID 0 inside the container's user namespace. This means bind-mounted workspace files appear as `root:root` inside the container, so a non-root `remoteUser` (like `vscode`) can't write to them. `crib` automatically adds `--userns=keep-id` when running rootless Podman. This maps your host UID to the same UID inside the container, so workspace files have correct ownership without any manual configuration. If you need to override this behavior, set a different `--userns` value in your `devcontainer.json`: ```jsonc // devcontainer.json { "runArgs": ["--userns=host"] } ``` When an explicit `--userns` is present in `runArgs`, `crib` won't inject `--userns=keep-id`. For Docker Compose workspaces, `crib` injects `userns_mode: "keep-id"` in the compose override. Since podman-compose 1.0+ creates pods by default and `--userns` is incompatible with `--pod`, `crib` also disables pod creation via `x-podman: { in_pod: false }` in the override. ## Workspace files owned by a high UID (100000+) after switching to a non-root user If a lifecycle hook (e.g. `postCreateCommand: npm install`) fails with permission denied, and the workspace contains files owned by a UID like `100000`, it means an earlier container run created those files as root inside a rootless Podman container. **Why this happens:** In rootless Podman with `--userns=keep-id`, container root (UID 0) maps to a subordinate UID on the host (typically `100000`). Any files created inside the container as root — such as from a first run without `remoteUser` set, or before adding `remoteUser` to `devcontainer.json` — are owned by that subordinate UID on the host filesystem. A subsequent run with `remoteUser` set to a non-root user (like `node` or `vscode`) can't write to those files. **Check for affected files:** ```bash ls -lan /path/to/project/node_modules | head -5 ``` If you see a high UID (100000+) instead of your own UID, those files are from a previous root container run. **Fix:** Delete the affected directories from the host and then rebuild: ```bash rm -rf node_modules # or whatever directory was created by the hook crib rebuild ``` If the directory is large and `rm -rf` is slow, you can use `podman unshare` to remove it faster from inside the same user namespace: ```bash podman unshare rm -rf node_modules ``` ## Bind mount changed permissions on host files If you ran `chown` or `chmod` inside a container on a bind-mounted directory and your host files now have wrong ownership, here's how to recover. **Check the damage:** ```bash ls -la ~/.ssh/ ``` If you see an unexpected UID/GID (e.g., `100999` or `root`) instead of your username, the container wrote through to the host. **Rootless Podman/Docker:** Use `podman unshare` (or `docker` equivalent) to run the fix inside the same user namespace that caused the problem: ```bash podman unshare chown -R 0:0 ~/.ssh ``` This maps container root (UID 0) back to your host user through the subordinate UID range, restoring your ownership. Then fix permissions: ```bash chmod 700 ~/.ssh chmod 600 ~/.ssh/* chmod 644 ~/.ssh/*.pub ~/.ssh/allowed_signers 2>/dev/null ``` **Rootful Docker:** Your host user can't chown these back without root: ```bash sudo chown -R $(id -u):$(id -g) ~/.ssh chmod 700 ~/.ssh chmod 600 ~/.ssh/* chmod 644 ~/.ssh/*.pub ~/.ssh/allowed_signers 2>/dev/null ``` **Prevention:** Avoid bind-mounting directories where ownership matters (like `.ssh/`). The SSH plugin copies individual files into the container via `docker exec` rather than bind mounts, which sidesteps this entirely. ## Plugin copy fails with "Read-only file system" If you see warnings like: ```text level=WARN msg="plugin copy: exec failed" target=/home/vscode/.ssh/id_ed25519-sign.pub error="... cannot create /home/vscode/.ssh/id_ed25519-sign.pub: Read-only file system" ``` A compose volume is already mounted at the same path the plugin is trying to write to. This happens when your compose file manually mounts directories like `~/.ssh` or `~/.claude` into the container: ```yaml # compose.yaml — these conflict with the ssh and coding-agents plugins volumes: - "./data/ssh:/home/vscode/.ssh" - "./data/claude:/home/vscode/.claude" ``` The built-in plugins now handle SSH config/keys and Claude credentials automatically, so these compose mounts are redundant. Remove them from your compose file and let the plugins manage the files instead. If you were using the compose mount as persistent storage (e.g. authenticating Claude inside the container), switch to the coding-agents plugin's [workspace mode](/crib/guides/plugins/#workspace-mode) instead. ## `remoteEnv`: `sh` not found after setting PATH with `${PATH}` If you set `PATH` in `remoteEnv` using a bare `${PATH}` reference: ```jsonc { "remoteEnv": { "PATH": "/home/vscode/.local/bin:${PATH}" } } ``` Older versions of crib passed the literal string `${PATH}` to `docker exec -e`, which overwrote the container's real PATH and made basic commands like `sh` unfindable: ```text crun: executable file `sh` not found in $PATH: No such file or directory ``` **Fix:** Update to crib v0.5.0+, which resolves bare `${VAR}` references in `remoteEnv` against the container's environment. Alternatively, use the spec-standard `${containerEnv:PATH}` syntax, which works in all versions: ```jsonc { "remoteEnv": { "PATH": "/home/vscode/.local/bin:${containerEnv:PATH}" } } ``` ## SSH agent not working with Docker-in-Docker If `SSH_AUTH_SOCK` is set inside the container but the socket file doesn't exist at that path, the Docker-in-Docker feature is likely remounting `/tmp` as a fresh tmpfs, hiding the SSH agent bind mount underneath it. **Verify:** Check if `/tmp` is a separate tmpfs inside the container: ```bash crib exec -- findmnt /tmp ``` If it shows `tmpfs` (not the container's root filesystem), DinD has remounted `/tmp`. **Fix:** Update to crib v0.7.0+, which mounts the SSH agent socket at `/run/ssh-agent.sock` instead of `/tmp/ssh-agent.sock` to avoid this conflict. If you're on an older version, you can work around it by adding to your `devcontainer.json`: ```jsonc { "postStartCommand": "ln -sf /proc/1/root/tmp/ssh-agent.sock /tmp/ssh-agent.sock" } ``` This creates a symlink from the DinD-mounted `/tmp` to the original mount point visible in PID 1's mount namespace. ## `crib exec` can't find tools installed by mise/asdf/nvm/rbenv If `crib exec -- ruby -v` fails with "not found" but `crib shell` followed by `ruby -v` works, the tool is installed via a version manager that modifies PATH through shell init files. `crib exec` runs commands directly via `docker exec` without sourcing shell init files, so PATH additions from mise, asdf, nvm, rbenv, etc. aren't available. **Fix:** Use `crib run` instead, which wraps commands in a login shell: ```bash crib run -- ruby -v crib run -- bundle install ``` `crib run` detects the container's shell (zsh, bash, or sh) and runs your command through `$SHELL -lc '...'`, which sources login profiles and sets up PATH correctly. Use `crib exec` for commands that don't depend on shell init (system binaries, scripts with absolute paths) or when you need raw `docker exec` behavior. ## Package cache: `bundler` provider and version managers (mise/rbenv) The `bundler` cache provider sets `BUNDLE_PATH=~/.bundle` so that `bundle install` writes gems into the cached volume. This overrides the default gem location, which means gems end up in the volume instead of the version manager's directory (e.g. `~/.local/share/mise/installs/ruby/3.4.7/lib/ruby/gems/`). This is generally fine for bundler-managed projects. The plugin also sets `BUNDLE_BIN=~/.bundle/bin` and installs a `/etc/profile.d/` script that adds it to PATH, so gem executables like `rspec` and `rubocop` are available in `crib shell` and `crib run` after `bundle install`. `crib exec` doesn't source profile scripts, so use `crib run` for commands that depend on bundler binstubs. ## Go: "permission denied" writing to module cache When using a Go base image (e.g. `golang:1.26`) with a non-root `remoteUser`, you may see: ```text go: writing stat cache: mkdir /go/pkg/mod/cache/download/...: permission denied ``` The `/go/pkg/mod/cache/` directory is owned by root in official Go images. When running as a non-root user, Go can't write to it. **Fix in your Dockerfile:** ```dockerfile RUN chmod -R a+w /go/pkg ``` **Or set a user-writable cache location** in `devcontainer.json`: ```jsonc { "remoteEnv": { "GOMODCACHE": "/home/vscode/.cache/go/pkg/mod" } } ``` This is not a crib issue. It affects any tool that runs Go containers with non-root users. --- ## CHANGELOG All notable changes to this project will be documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.7.0](https://github.com/fgrehm/crib/releases/tag/v0.7.0) - 2026-03-10 ### Added - `crib prune` command removes stale and orphan workspace images. Shows a dry-run preview with sizes before prompting for confirmation. `--all` includes orphan images from workspaces that no longer exist. `--force` / `-f` skips the prompt. - `crib remove` now shows a preview of what will be deleted (container ID, images, state directory) and prompts for confirmation. Use `--force` / `-f` to skip. - All crib-managed images are now labeled with `crib.workspace={wsID}`, enabling label-based discovery without name-pattern heuristics. Applied via `--label` on `docker build` and `--change "LABEL ..."` on `docker commit` (snapshots). - Build images are automatically removed when a new build replaces them (hash change). The old image is deleted after the new one is successfully built. - `crib remove` now deletes all labeled images for the workspace in addition to the container and workspace state. ### Changed - **Breaking**: Workspace IDs now include a 7-character hash of the absolute project path: `{slug}-{hash}` (e.g. `my-app-a1b2c3d`). Workspaces created before this change will not be recognized. Run `crib remove` (if still accessible) or delete `~/.crib/workspaces/` manually, then run `crib up` in each project to create a new workspace with the updated ID. ## [0.6.3](https://github.com/fgrehm/crib/releases/tag/v0.6.3) - 2026-03-09 ### Security - OCI feature archives containing symbolic links are now rejected outright. Previously, crib rejected symlinks whose static target appeared to escape the extraction directory, but a chain of individually-safe symlinks could still compose into a directory escape: an earlier symlink pointing to the extraction root could be overwritten via the chain, redirecting subsequent file writes outside the extraction directory. Feature archives contain scripts and JSON and have no legitimate need for symlinks. ## [0.6.2](https://github.com/fgrehm/crib/releases/tag/v0.6.2) - 2026-03-09 ### Fixed - `crib stop` followed by `crib up` now resumes from a snapshot when available, skipping the full image build and running only resume-flow lifecycle hooks (postStartCommand, postAttachCommand). Previously, stopping and starting a workspace re-ran the entire creation flow. Both single-container and compose paths are covered. `crib rebuild` still forces a full rebuild as before. - SSH agent socket is now mounted at `/run/ssh-agent.sock` instead of `/tmp/ssh-agent.sock`. Docker-in-Docker features remount `/tmp` as a fresh tmpfs, which hid the bind-mounted socket and broke SSH agent forwarding in DinD containers. ## [0.6.1](https://github.com/fgrehm/crib/releases/tag/v0.6.1) - 2026-03-08 ### Fixed - Compose container lookup fallback now filters by service name. Previously, when the primary service failed to start, crib could pick up the wrong container (e.g. postgres instead of rails-app) and show misleading logs in the error message. The fallback now uses `compose ps --format json` with service label matching, which also works on podman-compose (unlike `compose ps -q ` which podman-compose doesn't support). ## [0.6.0](https://github.com/fgrehm/crib/releases/tag/v0.6.0) - 2026-03-08 ### Added - `crib cache clean --force` / `-f` flag to skip confirmation prompt. - DevContainer Feature entrypoints are now applied. Features that declare an `entrypoint` (e.g. docker-in-docker starting `dockerd`) now have their entrypoint baked into the image. Multiple feature entrypoints are chained via a wrapper script. Feature runtime capabilities (`privileged`, `init`, `capAdd`, `securityOpt`, `mounts`, `containerEnv`) are now applied at container creation time for both single-container and compose paths. ### Security - Reject OCI feature archives containing symlinks that escape the extraction directory (absolute targets or relative traversal). Previously, a malicious feature archive could write to arbitrary host paths via symlinks. - CI workflows now use explicit `permissions: contents: read` instead of GitHub's default read-write. ### Changed - **Breaking**: The `-V` shorthand for `--verbose` has been removed. Use `--verbose` instead. The `-v` shorthand remains reserved for `--version`, matching CLI conventions. - CLI now exits with code 2 for usage errors (bad flags, missing arguments) instead of code 1, making it easier to distinguish user mistakes from runtime failures in scripts. - Noisy host-specific environment variables (`LS_COLORS`, `DISPLAY`, `WAYLAND_DISPLAY`, `XDG_SESSION_*`, `DBUS_SESSION_BUS_ADDRESS`, `TERM_PROGRAM`, `COLORTERM`, `DESKTOP_SESSION`, etc.) are now filtered from the probed environment. These are meaningless inside containers and cluttered the output of `crib run -- env`. Users can still force any filtered variable via `remoteEnv` in `devcontainer.json`. ### Fixed - SSH `known_hosts` could not be written inside the container. The SSH plugin created `~/.ssh/` as root but only chowned individual files, leaving the directory root-owned. The container user could not create new files in it. - Plugin environment variables (e.g. `BUNDLE_PATH`, `CARGO_HOME`, `HISTFILE`) now survive `crib restart`. Previously, simple restart paths only preserved plugin `PathPrepend` entries but silently dropped plugin `Env` values. The values survived only because they were present in the stored result from a previous `crib up`, but a plugin that changed an env value between restarts would have the old stored value win over the fresh one. - `crib delete` now removes named volumes declared in compose files (e.g. database data). Previously, `docker compose down` ran without `--volumes`, leaving orphaned volumes behind. - `crib restart` no longer loses software installed by lifecycle hooks (e.g. mise-managed ruby/node) on compose workspaces. The compose override now references the snapshot image, so even if the container is recreated, the hook-installed state is preserved. - Compose restart paths (`crib restart`, `crib up` on stopped containers) now preserve the stored `ImageName` in workspace state. Previously, these paths saved an empty `ImageName`, causing subsequent operations to lose track of the feature image. - Preserve Docker image PATH entries (e.g. `/usr/local/bundle/bin` in ruby images) that login shells drop during the env probe. Previously, `crib exec` could lose these entries, requiring `bundle exec` or similar wrappers. - Plugin PATH additions (e.g. `~/.bundle/bin` for bundler cache) now work with zsh. Previously relied on `/etc/profile.d/` scripts which zsh doesn't source. PATH additions are now injected directly via remoteEnv. - `crib cache clean` and `crib cache list` no longer require workspace state to exist. They now work even if `crib up` was never run or the project was deleted. - `crib cache clean --all` now prompts for confirmation since it removes volumes from other projects. - Sensitive env var values (tokens, keys, passwords) are now redacted in `--debug` output. Previously, values like `GITHUB_TOKEN` appeared in plaintext in exec command logs. - `crib restart` and redundant `crib up` no longer lose probed environment (mise, rbenv, nvm PATH entries) or plugin PATH additions. Previously, restart paths that skip env re-probing overwrote the saved `remoteEnv`, so `crib run` could not find tools like `ruby` or `node` after a restart. - Plugin dispatch in the already-running container path now passes arguments in the correct order. Previously, the remote user was passed as the image name, causing plugins to see an incorrect image and potentially resolve wrong home directory paths. ## [0.5.0](https://github.com/fgrehm/crib/releases/tag/v0.5.0) - 2026-03-05 ### Added - `crib run` command: runs commands through a login shell so tools installed by version managers (mise, asdf, nvm, rbenv) are available on PATH. Use instead of `crib exec` when the command depends on shell init files. - `crib cache list` and `crib cache clean` commands for inspecting and removing package cache volumes. `--all` flag operates across all workspaces. - Package cache volumes are now per-workspace (`crib-cache-{workspace}-{provider}`) instead of shared globally. This prevents cross-contamination between projects. - `crib logs` command with `--follow`/`-f` and `--tail` flags. Shows container logs for single-container workspaces; shows all service logs for compose workspaces. - `crib doctor` command to detect and fix workspace health issues. Checks runtime availability, compose availability, orphaned workspaces (source directory deleted), dangling containers (crib label but no workspace state), and stale plugin data. Use `--fix` to auto-clean. - **Package cache sharing plugin**: shares host package caches (npm, pip, go, cargo, maven, gradle, bundler, apt, downloads) via named Docker volumes. Configure in `.cribrc`: `cache = npm, pip, go`. The `downloads` provider is a general-purpose cache directory at `~/.cache/crib` (exposed via `CRIB_CACHE` env var) for ad hoc file caching. The `bundler` provider sets `BUNDLE_BIN` and adds `~/.bundle/bin` to PATH via `/etc/profile.d/`, so gem executables like `rspec` work directly in `crib shell` and `crib run`. - **Build-time cache mounts**: when package cache providers are configured, crib attaches BuildKit `--mount=type=cache` directives to DevContainer Feature install steps. This speeds up feature installation across rebuilds by reusing cached packages (especially apt). - **Auto-snapshot**: after `crib up` completes create-time hooks, the container is committed to a local snapshot image. On subsequent `crib restart` recreations, the snapshot is used so hook effects are already baked in. If hook definitions change, the snapshot is considered stale and full setup runs instead. `crib rebuild` always starts fresh. - Each plugin now emits a progress line ("Running plugin: \") during `crib up` and `crib rebuild`, visible without any flags. ### Fixed - `bundler` cache provider: mount volume at `~/.bundle` instead of `~/.bundle/cache` to avoid permission errors creating `~/.bundle/bin` (Docker created the parent directory as root). - `crib cache list`: compose workspaces showed the full volume name (including compose project prefix) in the PROVIDER column. Also fixed compose override to declare volumes with explicit `name:` so new volumes use the expected name. - Installation `curl` command now works in zsh (URL was missing quotes, causing a parse error with the embedded `sed` expression). Archive filenames no longer include the version, so `releases/latest/download/crib_linux_amd64.tar.gz` is a stable URL that always points to the latest release. - `.env` files with quoted values (`KEY="value with spaces"`, `KEY='value'`) and inline comments (`KEY=value # comment`) now parse correctly. Previously only bare `KEY=value` syntax was supported. - `workspaceMount` strings with a missing `target` field now produce an explicit error instead of silently creating an unusable mount. - Compose workspaces: stderr noise from `compose up` and `compose down` (e.g. "No container found", SIGTERM warnings) is now suppressed in normal mode. Use `-V` / `--verbose` to see full compose output. - Compose workspaces: `crib up` and `crib rebuild` now detect when the primary container exits immediately after `compose up` (e.g. port conflicts) and report a clear error with container logs, instead of cascading into confusing plugin copy and lifecycle hook failures. - Plugin file copies now bail out on the first exec failure instead of logging identical errors for every remaining copy. - Compose workspaces: `crib restart` now includes plugin-injected env vars and mounts (package cache, SSH agent, shell history, etc.) in the compose override. Previously the simple restart path skipped plugin dispatch, so these were missing until a full `crib down && crib up`. ## [0.4.1](https://github.com/fgrehm/crib/releases/tag/v0.4.1) - 2026-03-02 ### Fixed - Plugins (coding-agents, ssh, shell-history) now run for Docker Compose workspaces with the correct container user. Previously, plugins only ran for single-container devcontainers, and the initial fix defaulted to root when `remoteUser`/`containerUser` weren't set in `devcontainer.json`, causing permission errors (e.g. `zsh: locking failed for /root/.crib_history/.shell_history`). The engine now resolves the user from the compose service and image before dispatching plugins. - Compose override files are now written to a system temp directory instead of inside `.devcontainer/`. Previously, `chown -R` during container setup could change ownership of the override file, making it unremovable by the host user. ## [0.4.0](https://github.com/fgrehm/crib/releases/tag/v0.4.0) - 2026-03-01 ### Added - **Plugin system** with bundled plugin support for `pre-container-run`. Plugins can inject mounts, environment variables, extra run args, and file copies into containers. Fail-open error handling (one broken plugin doesn't block container creation). See `docs/plugin-development.md`. - **coding-agents plugin**: automatically injects Claude Code credentials into containers so AI coding tools work without re-authentication. Detects `~/.claude/.credentials.json` on the host and copies it into the container. Supports a **workspace credentials mode** for teams that need org-specific accounts: set `customizations.crib.coding-agents.credentials` to `"workspace"` in `devcontainer.json` to persist container-created credentials across rebuilds instead of injecting host credentials. - **ssh plugin**: shares SSH configuration with containers. Forwards the SSH agent socket, copies `~/.ssh/config` and public keys, and extracts git SSH signing config (`gpg.format=ssh`) into a minimal `.gitconfig`. Private keys stay on the host; commit signing works via the forwarded agent (OpenSSH 8.2+). - **shell-history plugin**: persists bash/zsh history across container recreations. Bind-mounts a history directory from workspace state and sets `HISTFILE`. - Automatic port publishing for single-container workspaces. `forwardPorts` and `appPort` from `devcontainer.json` are now translated into `--publish` flags on `docker run`, so ports work without manual `runArgs` workarounds. - Published ports shown on `crib ps`, `crib up`, `crib restart`, and `crib rebuild`. For compose workspaces, ports are parsed from `docker compose ps` output. - **Plugin customizations**: plugins now receive `customizations.crib` from `devcontainer.json`, enabling per-project plugin configuration. - Documentation website at [fgrehm.github.io/crib](https://fgrehm.github.io/crib/), with `llms.txt` for AI tool discovery. - `-V` / `--verbose` now prints each lifecycle hook command before running it (e.g. ` $ npm install`), making it easier to diagnose hook failures. - `build.options` from `devcontainer.json` is now passed to `docker build` / `podman build` as extra CLI flags (e.g. `--network=host`, `--progress=plain`). - Object-syntax lifecycle hooks now run their named entries in parallel, matching the devcontainer spec. String and array hooks are unchanged (sequential). - `waitFor` is now respected: a "Container ready." progress message is emitted after the specified lifecycle stage completes (default: `updateContentCommand`). ### Changed - `--debug` now implies `--verbose`: subprocess stdout (hooks, build, compose) is shown when debug logging is active. - `crib shell` now rejects arguments with a helpful error suggesting `crib exec`. ### Fixed - `crib restart` no longer fails with "cannot determine image name" on Dockerfile-based workspaces where the stored result had an empty image name. Falls back to rebuilding the image instead of erroring. - `crib rebuild` no longer fails when no container exists (e.g. first build or after manual container removal). - Container deletion is faster (skips stop grace period). - `make install` now uses correct permissions. ## [0.3.1](https://github.com/fgrehm/crib/releases/tag/v0.3.1) - 2026-02-28 ### Fixed - `down` / `stop` on rootless Podman no longer fails with "no pod with name or ID ... found". The `x-podman: { in_pod: false }` override was only passed during `up`, so `compose down` tried to remove a pod that never existed. - `rebuild` now actually rebuilds images. Previously it passed `Recreate: false` to `Up`, which took the stored-result shortcut and skipped the image build. - Environment probe now runs after lifecycle hooks (in addition to before), so the persisted environment for `shell`/`exec` includes tools installed by hooks (e.g. `mise install` in `bin/setup`). - Filter mise internal state variables (`__MISE_*`, `MISE_SHELL`) from the probed environment. These are session-specific and confused mise when injected into a new shell via `crib shell`. ## [0.3.0](https://github.com/fgrehm/crib/releases/tag/v0.3.0) - 2026-02-27 ### Changed - Rename `stop` to `down` (alias: `stop`). Now stops and removes the container instead of just stopping it, clearing lifecycle hook markers so the next `up` runs all hooks from scratch. - Rename `delete` to `remove` (aliases: `rm`, `delete`). Removes container and workspace state. - Add short aliases: `list` (`ls`), `status` (`ps`), `shell` (`sh`). - `rebuild` no longer needs to re-save workspace state after removing the container (uses `down` instead of the old `delete`). - Display crib version at the start of `up`, `down`, `remove`, `rebuild`, and `restart` commands. Dev builds include commit SHA and build timestamp. - Suppress noisy compose stdout (container name listings) during up/down/restart. Use `--verbose` / `-V` to see full compose output. - `status` / `ps` now shows all compose service statuses for compose workspaces. ### Fixed - Lifecycle hooks (`onCreateCommand`, `updateContentCommand`, `postCreateCommand`) now run after `down` + `up` cycle. Previously, host-side hook markers persisted across container removal, causing hooks to be skipped. - `restart` for compose workspaces now uses `compose up` instead of `compose restart`, fixing failures when dependency services (databases, sidecars) were stopped. - `up` after `down` for compose workspaces no longer rebuilds images. When a stored result exists with a previously built image, the build is skipped and services are started directly. ## [0.2.0](https://github.com/fgrehm/crib/releases/tag/v0.2.0) - 2026-02-26 ### Added - `crib restart` command with smart change detection - No config changes: simple container restart, runs only resume-flow hooks (`postStartCommand`, `postAttachCommand`) - Safe changes (volumes, mounts, ports, env, runArgs, user): recreates container without rebuilding the image, runs only resume-flow hooks - Image-affecting changes (image, Dockerfile, features, build args): reports error and suggests `crib rebuild` - `RestartContainer` method in container driver interface - `Restart` method in compose helper - Smart Restart section in README - New project logo ### Changed - Refactor engine package: extract `change.go`, `restart.go` from `engine.go` - Deduplicate config parsing (`parseAndSubstitute`) and user resolution (`resolveRemoteUser`) ## [0.1.0](https://github.com/fgrehm/crib/releases/tag/v0.1.0) - 2026-02-25 ### Added - Core `crib` CLI for managing dev containers - Support for Docker and Podman via single OCI driver - `.devcontainer` configuration parsing, variable substitution, and merging - All three configuration scenarios: image-based, Dockerfile-based, Docker Compose-based - DevContainer Features support with OCI image resolution and ordering - Workspace state management in `~/.crib/workspaces/` - Implicit workspace resolution from current working directory - Commands: `up`, `stop`, `delete`, `status`, `list`, `exec`, `shell`, `rebuild`, `version` - All lifecycle hooks: `initializeCommand`, `onCreateCommand`, `updateContentCommand`, `postCreateCommand`, `postStartCommand`, `postAttachCommand` - `userEnvProbe` support for probing user environment (mise, rbenv, nvm, etc.) - Image metadata parsing (`devcontainer.metadata` label) with spec-compliant merge rules - `updateRemoteUserUID` with UID/GID sync and conflict resolution - Auto-injection of `--userns=keep-id` / `userns_mode: "keep-id"` for rootless Podman - Container user auto-detection via `whoami` for compose containers - Early result persistence so `exec`/`shell` work while lifecycle hooks are still running - Version info on error output for debugging - Container naming with `crib-{workspace-id}` convention - Container labeling with `crib.workspace={id}` for discovery - Build and test tooling (Makefile, golangci-lint v2, pre-commit hooks) - Debug logging via `--debug` flag --- ## Development ## Branching - All work happens on `main`. No long-lived feature branches. - Releases are tagged (e.g. `v0.3.0`). CI updates `stable` to match automatically after the tag is pushed. - `README.md` on `stable` is the source of truth for released functionality. - `README.md` on `main` may describe unreleased work. ## Building Requires Go 1.26+. ```bash make build # produces bin/crib make test # run unit tests make lint # run linters (golangci-lint v2) make test-integration # integration tests (requires Docker or Podman) make test-e2e # end-to-end tests against the crib binary make setup-hooks # configure git pre-commit hooks ``` --- ## Roadmap Items move between sections as priorities shift. ## Planned ### Zero-config project bootstrapping (`crib init`) Detect project type (Ruby, Node, Go, etc.) from conventions and generate a working devcontainer config without the user writing one. See the [RFC](https://github.com/fgrehm/crib/blob/main/docs/rfcs/init.md) for the full design. ### Transparent command dispatch Run `crib` commands from inside or outside the container, with automatic delegation. ## Considering ### Support `extends` in devcontainer.json Allow devcontainer.json to inherit from another configuration file, enabling teams to maintain shared base configurations with per-project overrides. This is an active proposal in the devcontainer spec ([devcontainers/spec#22](https://github.com/devcontainers/spec/issues/22)), with ongoing implementation work in the DevContainers CLI ([PR #311](https://github.com/devcontainers/cli/pull/311)). Once a decision is made on the spec side, we'll add support. If you have a strong use case or want to contribute, PRs are welcome. ### Standardize container-side plugin paths Currently plugin mounts land at ad-hoc paths (`~/.crib_history/`, `/tmp/ssh-agent.sock`). A standard base (XDG `$XDG_DATA_HOME/crib/` for persistent data, `/tmp/crib/` for runtime sockets) would be cleaner, but we need more plugins and real-world usage to understand the right shape before committing to a convention. ### Machine-readable output (`--json`) Add `--json` or `--format json` flag to commands like `status`, `list` for scripting and tooling integration. The internal data structures already support this. ### ~~Fully qualified workspace paths~~ ~~Workspace IDs are currently derived from the project directory name (e.g. `ruby-project`), which can collide when different orgs or parent directories have projects with the same name. Use a hash or full path to guarantee uniqueness. This is a breaking change (existing workspaces would need migration), so it should land before v1.0.~~ Done. Workspace IDs now use `{slug}-{7-char-sha256}` of the absolute project path. ### ~~`--force` flag for destructive commands~~ ~~Add a `--force` / `-f` flag to commands like `remove`, `rebuild`, and `restart` to skip confirmation prompts. Useful for scripting and CI.~~ Done. `crib remove` and `crib prune` have `--force` / `-f`. ### Colored log output Color-code `crib logs` lines by service name when the terminal supports it, similar to `docker compose logs`. Useful for compose workspaces with multiple services where logs are interleaved. ### Container health checks Detect when a container is unhealthy or stuck and surface it in `crib status` / `crib ps`. ### Build progress indicator Show a spinner or progress bar during `docker commit` and other long-running operations when a TTY is detected. Investigate if `podman`/`docker commit` has machine-readable output we can use. ### Workspace-scoped `crib status` and `crib delete` Accept an explicit workspace name argument so these commands work outside the project directory. Also useful for managing multiple workspaces from a central location. ### ~~`crib prune`~~ ~~Clean all crib state, containers, volumes, and images in one shot. Useful for full reset or uninstall.~~ Done. `crib prune` removes stale and orphan workspace images, with dry-run preview and `--all` for global scope. ### Compose-built image cleanup `crib remove` and `crib prune` don't clean up images produced by `docker compose build` for services that have a `build:` section but no DevContainer Features. These images are unnamed by crib (compose names them `{project}-{service}` on Docker, `{project}_{service}` on Podman) and carry no `crib.workspace` label. Cleaning them up requires either tracking the compose image name in `result.json` at build time, or deriving it from the stored config at remove time (accounting for runtime differences and explicit `image:` overrides in the compose file). ### Enhanced `crib list` Accept arguments to filter/show state details (container status, services, ports, etc.). ### `crib build` command Build the image / container without starting it. Useful for CI or pre-warming caches. ### Debug mode env var Pass `CRIB_DEBUG=1` (or `DEVCONTAINER_BUILD_DEBUG`) into containers during builds and hook execution for easier troubleshooting. Pair with build log capture for a complete debugging story. ### Build log capture Write all build output to `~/.crib/workspaces/{id}/logs/{timestamp}-build.txt` for post-mortem debugging. ### Version tracking in workspace state Record the crib version (semver, commit SHA, build timestamp) in workspace state so we can detect version mismatches and provide upgrade guidance. Surface in `crib doctor` when the workspace was created by a different version. ### Log output scrubbing ~~Redact sensitive env var values (tokens, keys, passwords) in `--debug` exec logs.~~ Done in v0.5.1. Remaining: scrub PII patterns from subprocess output and verbose logs (not just env var names). Examples: email addresses, phone numbers, IP addresses, filesystem paths containing usernames. These can leak through lifecycle hook output, build logs, and error messages. ### ~~Env var filtering for exec/run~~ ~~Skip injecting noisy env vars (e.g. `LS_COLORS`, `LSCOLORS`) that don't serve a purpose inside the container. Could be a configurable allowlist/denylist.~~ Done in v0.6.0. Implemented as a hardcoded skip list in `filterProbedEnv` (`internal/engine/env.go`), covering terminal colors, session-specific vars, version manager internals, and security-sensitive vars. A configurable allowlist/denylist can be added later if needed. ### Remote access plugin (SSH into containers) SSH server inside containers via the plugin system, enabling native filesystem performance on macOS and editor-agnostic remote development. See the [RFC](https://github.com/fgrehm/crib/blob/main/docs/rfcs/remote-access.md) for the full design. ### Revisit save-path abstraction (ADR 001) [ADR 001](decisions/001-no-save-path-abstraction.md) decided against abstracting the 6+ save sites across single/compose/restart paths. Since then, each new feature that touches container state (feature entrypoints, feature metadata, `${containerEnv:*}` resolution) has needed manual wiring into every path. The `resolveConfigEnvFromStored` fix is the latest example of a bug class where restart paths miss critical resolution steps that `setupContainer` handles automatically. Consider a `RestartStateResolver` or similar that encapsulates the restore-from-stored + resolve + plugin-merge sequence so new paths can't silently drop state. ### Reduce cyclomatic complexity hotspots CI gates at gocyclo > 40 (the ratchet that prevents things from getting worse). Several engine functions exceed 15, the practical threshold for maintainability: `upCompose` (38), `syncRemoteUserUID` (29), `generateComposeOverride` (26), `Doctor` (23), `detectConfigChange` (22), `extractTar` (19), `upSingle` (18), `restartRecreateSingle` (18). These should be broken into focused helpers before they become harder to change. ## Not Planned These are explicitly out of scope for `crib`'s design philosophy. - **Remote/cloud providers**: `crib` is local-only by design. - **IDE integration**: No VS Code extension, no JetBrains plugin. CLI only. - **Agent injection**: All setup happens via `docker exec` from the host. - **Kubernetes / cloud backends**: Local container runtimes only (Docker, Podman). --- ## Implementation Notes Notes on quirks, workarounds, and spec compliance gathered during development. ## Quirks and Workarounds ### Rootless Podman requires userns_mode / --userns=keep-id When running rootless Podman, bind-mounted files are owned by the host user's UID inside a user namespace. Without `--userns=keep-id`, the container sees these files as owned by `nobody:nogroup`, breaking all file operations. `crib` auto-injects `--userns=keep-id` for single containers and `userns_mode: "keep-id"` in compose overrides when it detects rootless Podman (non-root UID + `podman` in the runtime command). This is skipped when the user's compose files already set `userns_mode`. For compose, the override also sets `x-podman: { in_pod: false }` because podman-compose creates pods by default and `--userns` and `--pod` are incompatible in Podman. The same `x-podman: { in_pod: false }` directive must also be passed during `compose down`. Without it, podman-compose tries to remove a pod named `pod_crib-` that was never created, causing a "no pod with name or ID ... found" error. `composeDown` generates a temporary override for this. **Files**: - `internal/engine/single.go` (`RunOptions`) - `internal/engine/compose.go` (`generateComposeOverride`, `composeDown`, `writePodmanDownOverride`) - `internal/driver/oci/container.go` (`buildRunArgs`) ### Version managers (mise, rbenv, nvm) not in PATH during lifecycle hooks Lifecycle hooks run via `sh -c ""`. Tools installed by version managers like [mise](https://mise.jdx.dev/) activate in `~/.bashrc` (interactive shell), not in `/etc/profile.d/` (login shell). This means `sh -c` and even `bash -l -c` won't find them. `crib` implements the spec's `userEnvProbe` (default: `loginInteractiveShell`) to probe the container user's environment once during setup, then passes the probed variables to all lifecycle hooks via `docker exec -e` flags. The probed env is also persisted in `result.json` so `crib exec` and `crib shell` inherit it automatically. The probe shell type maps to: | userEnvProbe | Shell flags | |------------------------|-------------| | `loginShell` | `-l -c env` | | `interactiveShell` | `-i -c env` | | `loginInteractiveShell`| `-l -i -c env` | | `none` | skip probing | **Files**: - `internal/engine/setup.go` (`probeUserEnv`, `detectUserShell`) - `internal/engine/env.go` (`mergeEnv`) ### Container user detection for compose containers For single containers, `containerUser` and `remoteUser` from `devcontainer.json` control which user runs hooks. For compose containers, the Dockerfile's `USER` directive sets the running user, but `devcontainer.json` often doesn't set `remoteUser` or `containerUser`. There are two detection mechanisms, used at different stages: **Pre-start (for plugins):** `resolveComposeUser()` runs before the container exists, so plugins get the correct home directory for mounts and file copies. It checks, in order: 1. `remoteUser`/`containerUser` from devcontainer.json (if set, returns early). 2. The `user:` directive from the compose service definition. 3. For build-based services (`build:` instead of `image:`), parses the Dockerfile to find the last `USER` instruction and the base image (`resolveComposeDockerfileInfo`). 4. Inspects the base image metadata via `docker image inspect`. **Post-start (for hooks):** `detectContainerUser()` runs `whoami` inside the running container. If the detected user is root, it falls through to the default "root" behavior. Non-root users (e.g. `vscode` from a `USER vscode` directive) are used as the remote user. **Files**: - `internal/engine/compose.go` (`resolveComposeUser`, `resolveComposeDockerfileInfo`) - `internal/engine/build.go` (`resolveComposeContainerUser`) - `internal/engine/single.go` (`detectContainerUser`, `setupAndReturn`) ### Smart restart with change detection `crib restart` compares the current devcontainer config against the stored config from the last `crib up` to determine the minimal action needed: - **No changes**: Simple `docker restart` / `docker compose restart`, then run the spec's Resume Flow hooks (`postStartCommand` + `postAttachCommand`). - **Safe changes** (volumes, mounts, ports, env, runArgs, user, etc.): Recreate the container with the new config, then run Resume Flow hooks only. Creation-time hooks (`onCreateCommand`, `updateContentCommand`, `postCreateCommand`) are skipped since their marker files still exist. - **Image-affecting changes** (image, Dockerfile, features, build args): Error with a message suggesting `crib rebuild`, since the image needs to be rebuilt. This follows the devcontainer spec's distinction between Creation Flow (all hooks) and Resume Flow (only `postStartCommand` + `postAttachCommand`). The result is that tweaking a volume mount or environment variable takes seconds instead of minutes. Change detection uses JSON comparison of the stored `MergedConfig` against a freshly parsed and substituted config. Fields are classified as "image-affecting" or "safe" based on whether they require a new image build or just container runtime configuration. **Files**: - `internal/engine/engine.go` (`Restart`, `detectConfigChange`, `restartSimple`, `restartWithRecreate`) - `internal/engine/lifecycle.go` (`runResumeHooks`) ### Early result persistence `crib` saves the workspace result (container ID, workspace folder, remote user) as soon as those values are known, before UID sync, environment probing, and lifecycle hooks run. This means `crib exec` and `crib shell` work immediately, even while hooks are still executing or if they fail. A second save after setup completes updates the result with the probed `remoteEnv`. This is particularly useful when iterating on a new devcontainer setup where lifecycle hooks often fail (missing dependencies, broken scripts, etc.). **Files**: - `internal/engine/engine.go` (`Up`, `saveResult`) - `internal/engine/single.go` (`setupAndReturn`) ### UID/GID sync conflicts with existing users When `updateRemoteUserUID` is true (the default), `crib` syncs the container user's UID/GID to match the host user. On images like `ubuntu:24.04`, standard users/groups may already occupy the target UID/GID (e.g. the `ubuntu` user at UID 1000). `crib` detects these conflicts and moves the conflicting user/group to a free UID/GID before performing the sync. **Files**: - `internal/engine/setup.go` (`syncRemoteUserUID`, `execFindUserByUID`, `execFindGroupByGID`) ### chown skipped when UIDs already match After UID sync, if the container and host UIDs already match, `crib` skips `chown -R` on the workspace directory. This avoids failures on rootless Podman where `CAP_CHOWN` doesn't work over bind-mounted files (the kernel denies it even for root inside the user namespace). **Files**: - `internal/engine/setup.go` (`setupContainer`) ### Feature entrypoints and runtime capabilities DevContainer Features can declare an `entrypoint` in `devcontainer-feature.json`. These scripts typically start a daemon and then chain via `exec "$@"` so the container's normal command runs after the daemon is ready (e.g. docker-in-docker starts `dockerd`). Features can also declare runtime capabilities (`privileged`, `init`, `capAdd`, `securityOpt`, `mounts`, `containerEnv`) that must be applied at container creation time, not during the image build. `crib` handles these in two separate phases: **Image build (Dockerfile generation):** `GenerateDockerfile` in `internal/feature/dockerfile.go` bakes entrypoints into the image. For a single feature entrypoint, it emits a simple `ENTRYPOINT ["/path/to/script"]`. For multiple features, it generates a wrapper script at `/usr/local/share/crib-entrypoint.sh` that chains entrypoints in order (later features wrap earlier ones): `exec /last.sh /prev.sh ... /first.sh "$@"`. **Container creation (runtime capabilities):** `applyFeatureMetadata` in `internal/engine/single.go` applies `privileged`, `init`, `capAdd`, `securityOpt`, `mounts`, and `containerEnv` from feature metadata to `RunOptions`. For compose, `generateComposeOverride` writes these into the override YAML. When features declare entrypoints and `overrideCommand` is true (the default for image/Dockerfile containers), only `CMD` is overridden, not `ENTRYPOINT`. This preserves the feature entrypoint while still keeping the container alive with a sleep loop. The `HasFeatureEntrypoints` flag is persisted in `result.json` so restart paths that don't rebuild the image can apply the same logic. Feature-declared volume mounts (e.g. docker-in-docker's `/var/lib/docker`) use named Docker volumes (`dind-var-lib-docker-${devcontainerId}`). Named volumes are managed by the Docker/Podman daemon and persist independently of containers. This means the volume's contents (layer cache, container state, etc.) survive `crib restart`, `crib rebuild`, and even `crib remove` followed by `crib up`, as long as the volume itself isn't explicitly deleted. For docker-in-docker, this means image layers built inside the container are cached across rebuilds. **Files**: - `internal/feature/dockerfile.go` (`GenerateDockerfile`) - `internal/engine/single.go` (`buildRunOptions`, `applyFeatureMetadata`) - `internal/engine/compose.go` (`generateComposeOverride`) - `internal/engine/build.go` (`buildResult.hasEntrypoints`, `featureToMetadata`) - `internal/workspace/result.go` (`HasFeatureEntrypoints`) ### overrideCommand default differs by scenario Per the spec, `overrideCommand` defaults to `true` for image/Dockerfile containers and `false` for compose containers. `crib`'s compose path handles this in the override YAML generation (injecting entrypoint/command only when the flag is explicitly or implicitly true). The single container path treats `nil` as `true`. When features set an `ENTRYPOINT` in the image, `overrideCommand: true` overrides only `CMD` (not `ENTRYPOINT`), so the feature daemon starts before the keep-alive command. **Files**: - `internal/engine/single.go` (`buildRunOptions`) - `internal/engine/compose.go` (`generateComposeOverride`) ### Plugins must be wired into both single-container and compose paths The engine has two separate code paths for container creation: `upSingle()` for image/Dockerfile devcontainers and `upCompose()` for Docker Compose devcontainers. They diverge because single containers use `docker run` with `RunOptions`, while compose delegates to `docker compose up` with a generated override YAML. Any feature that affects container creation (plugins, mounts, env vars, labels) must be wired into **both** paths. The shared entry point is `dispatchPlugins()`, which builds the plugin request and returns the response without merging it into any target: - **Single-container**: `runPreContainerRunPlugins()` merges the response into `RunOptions` (mounts, env, runArgs), then `execPluginCopies()` runs after container creation. - **Compose**: the response is passed to `generateComposeOverride()` which writes plugin mounts as `volumes:` entries and plugin env as `environment:` entries in the override YAML. `runArgs` are ignored (compose owns the container config). `execPluginCopies()` runs after `compose up` finds the container. The `restart.go` file has the same split: `restartRecreateSingle` vs `restartRecreateCompose`. **Files**: - `internal/engine/single.go` (`dispatchPlugins`, `runPreContainerRunPlugins`, `execPluginCopies`) - `internal/engine/compose.go` (`upCompose`, `generateComposeOverride`) - `internal/engine/restart.go` (`restartRecreateSingle`, `restartRecreateCompose`) ### Feature installation for compose containers DevContainer Features (e.g. `ghcr.io/devcontainers/features/node:1`) need special handling for compose-based containers. Unlike single containers where `crib` controls the entire image build, compose services define their own images or Dockerfiles. `crib` handles this by pre-building a feature image on top of the service's base image: 1. Parse compose files to extract the service's image or build config 2. For build-based services, run `compose build` first to produce the base image 3. Generate a feature Dockerfile that layers features on the base image (same `GenerateDockerfile` and `PrepareContext` used for single containers) 4. Build the feature image via `doBuild` (with prebuild hash caching) 5. Override the service image in the compose override YAML The compose `build` step for the primary service is skipped in the main flow since the feature build already produced the final image. Other services still build normally. `build.options` (extra Docker build CLI flags) applies to the feature image build (step 4 above, via `doBuild`) but **not** to the base service image build (step 2, via `compose build`). This is spec-correct: the compose service image is managed by `docker-compose.yml`, not by `devcontainer.json`'s `build` section. If you need extra flags for the compose service build, set them in the compose file directly (e.g. `build.args`, `build.network`). **Files**: - `internal/engine/compose.go` (`buildComposeFeatures`, `generateComposeOverride`) - `internal/engine/build.go` (`buildComposeFeatureImage`, `resolveComposeContainerUser`) - `internal/compose/project.go` (`GetServiceInfo`) ### Environment probe runs twice: before and after lifecycle hooks `probeUserEnv` runs the user's login shell (`zsh -l -i -c env`) to capture environment variables set by shell profile files (mise, nvm, rbenv, etc.). This probe runs twice during `setupContainer`: 1. **Before hooks**: provides lifecycle hooks with the user's shell environment (PATH, tool paths, etc.) so hooks don't need to explicitly set up their own environment. 2. **After hooks**: captures any changes made by hooks (e.g. `mise install` adding new tool paths to PATH). This is the version that gets persisted for `crib shell`/`crib exec`. Without the post-hook probe, the saved PATH would be missing tools installed during lifecycle hooks (e.g. a `bin/setup` script that runs `mise install`). Tool-manager internal state variables (`__MISE_*`, `MISE_SHELL`) are filtered from the probed env. These are session-specific and would confuse tool managers when injected into a new shell session via `crib shell`. **Files**: - `internal/engine/setup.go` (`setupContainer`) - `internal/engine/env.go` (`mergeEnv`) ### TTY detection for exec uses isatty, not ModeCharDevice `crib exec` passes `-i -t` to `docker exec` / `podman exec` only when stdin is an interactive terminal. The detection must use a proper `isatty` syscall (`term.IsTerminal(fd)`) rather than Go's `os.ModeCharDevice` file mode check. `/dev/null` is a character device on Linux, so `ModeCharDevice` returns true for it. This causes `crib exec` to pass `-t` when stdin is `/dev/null` (e.g. in CI, pipes, or `exec.Command` with no stdin). Docker strictly validates the TTY and errors with "the input device is not a TTY." Podman silently ignores `-t` without a real TTY. **Files**: - `cmd/exec.go` (`stdinIsTerminal`) ### Image lifecycle management `crib` labels all images it builds or commits with `crib.workspace={wsID}` (the same label key used for containers). This enables discovery via `docker images --filter label=crib.workspace` without relying on name-pattern heuristics. | Image type | How the label is applied | |------------|------------------------| | Build image (`crib-{wsID}:{hash}`) | `--label` flag on `docker build` / `podman build` | | Snapshot image (`crib-{wsID}:snapshot`) | `--change "LABEL ..."` on `docker commit` / `podman commit` | Compose-built images (those produced by `docker compose build`) are not labeled because adding a `build:` section to the compose override triggers a build attempt even for image-only services that have no Dockerfile. Images are cleaned up automatically at three points: 1. **During build:** when the prebuild hash changes, the previous build image is removed before saving the new result. Base images (those not prefixed `crib-`) are never touched. 2. **On `crib remove`:** all labeled images for the workspace are swept via `ListImages`, plus the active build image from `result.json`. 3. **On `crib prune`:** stale images (labeled but not referenced by `result.json`) and orphan images (workspace no longer exists in `~/.crib/workspaces/`) are removed. Supports `--all` (global) and dry-run preview with sizes. All removals are best-effort: failures are logged and skipped so a single in-use image doesn't block cleanup of the rest. Existing unlabeled images from before this change are not discovered by label-based cleanup. Clean them up manually with `docker rmi $(docker images --filter reference='crib-*' -q)`. **Files**: - `internal/driver/oci/image.go` (`ListImages`, `BuildOptions.Labels`, `CommitContainer`) - `internal/engine/build.go` (`cleanupPreviousBuildImage`) - `internal/engine/engine.go` (`cleanupWorkspaceImages`, `PreviewRemove`) - `internal/engine/prune.go` (`PruneImages`) - `cmd/prune.go` (`crib prune`) ## Spec Compliance ### Fully Implemented | Feature | Notes | |---------|-------| | Config file discovery | All three search paths | | Image/Dockerfile/Compose scenarios | All three paths | | Lifecycle hooks | All 6 hooks with marker-file idempotency | | DevContainer Features | OCI, HTTPS, local; ordering algorithm; feature lifecycle hooks; compose support; entrypoints and runtime capabilities (`privileged`, `init`, `capAdd`, `securityOpt`, `mounts`, `containerEnv`) | | Variable substitution | All 7 variables including `${localEnv}` and `${containerEnv}` | | Image metadata | Parsing `devcontainer.metadata` label, merge rules | | `updateRemoteUserUID` | UID/GID sync with conflict resolution | | `userEnvProbe` | Shell detection, env probing, merge with remoteEnv | | `overrideCommand` | Both single and compose paths | | `mounts` | String and object format, bind and volume types | | `forwardPorts` | Published as `-p` flags for single containers; compose uses native port config | | `appPort` (legacy) | Same handling as `forwardPorts`, deduplicated | | `init`, `privileged`, `capAdd`, `securityOpt` | Passed through to runtime | | `runArgs` | Passed through as extra CLI args | | `workspaceMount` / `workspaceFolder` | Custom mount parsing, variable expansion | | `containerEnv` / `remoteEnv` | Including `${containerEnv:VAR}` resolution | | Compose `runServices` | Selective service starting | | Build options | `dockerfile`, `context`, `args`, `target`, `cacheFrom`, `options` (extra CLI flags; for compose, applies to feature layer builds only — see quirk above) | | `waitFor` | "Container ready." progress message fires after the named stage; all hooks still run to completion; default `updateContentCommand` | | Parallel object hooks | Object-syntax hooks (named entries) run concurrently via `errgroup`; all must succeed | ### Parsed but Not Enforced These fields are parsed from `devcontainer.json` and merged from image metadata, but `crib` does not act on them. This is intentional for a CLI-only tool. | Feature | Reason | |---------|--------| | `portsAttributes` | Display/behavior hints for IDE port UI | | `shutdownAction` | `crib` manages container lifecycle explicitly via `down`/`remove` | | `hostRequirements` | Validation not implemented; runtime will fail naturally | --- ## Plugin Development Guide for writing bundled (in-process) plugins. External plugin support is planned but not yet implemented. ## Architecture ``` internal/plugin/ plugin.go -> Plugin interface and types manager.go -> Registration and event dispatch codingagents/ -> Claude Code credentials plugin shellhistory/ -> Persistent shell history plugin ssh/ -> SSH agent forwarding, keys, and git signing ``` Plugins are registered in `cmd/root.go` via `setupPlugins()` and dispatched by the engine during both `upSingle()` (initial container creation) and `restartRecreateSingle()` (container recreation triggered by `crib restart`). ## Plugin Interface Every plugin implements `plugin.Plugin`: ```go type Plugin interface { Name() string PreContainerRun(ctx context.Context, req *PreContainerRunRequest) (*PreContainerRunResponse, error) } ``` ### Request `PreContainerRunRequest` provides context about the workspace and the container that is about to be created: | Field | Description | |-------------------|-------------------------------------------------| | `WorkspaceID` | Unique workspace identifier | | `WorkspaceDir` | `~/.crib/workspaces/{id}/` (for staging files) | | `SourceDir` | Project root on host | | `Runtime` | `"docker"` or `"podman"` | | `ImageName` | Resolved image name | | `RemoteUser` | User inside the container (from config) | | `WorkspaceFolder` | Path inside container (e.g. `/workspaces/proj`) | | `ContainerName` | `crib-{workspace-id}` | | `Customizations` | `customizations.crib` from devcontainer.json | The `Customizations` field contains the `crib` namespace from devcontainer.json customizations. Plugins can look up their own config key: ```go func getMyConfig(customizations map[string]any) map[string]any { if customizations == nil { return nil } if v, ok := customizations["my-plugin"]; ok { if m, ok := v.(map[string]any); ok { return m } } return nil } ``` Example devcontainer.json: ```json { "customizations": { "crib": { "my-plugin": { "setting": "value" } } } } ``` ### Response `PreContainerRunResponse` specifies what to inject. Return `nil` for no-op. | Field | Type | Merge rule | When applied | |-----------|---------------------|-----------------------------|---------------------------| | `Mounts` | `[]config.Mount` | Appended in plugin order | Before container creation | | `Env` | `map[string]string` | Merged, last plugin wins | Before container creation | | `RunArgs` | `[]string` | Appended in plugin order | Before container creation | | `Copies` | `[]plugin.FileCopy` | Appended in plugin order | After container creation | ### FileCopy `FileCopy` describes a file to inject into the container after creation via `docker exec`: ```go type FileCopy struct { Source string // path on host Target string // path inside container Mode string // chmod mode (e.g. "0600"), empty for default User string // chown user (e.g. "vscode"), empty for default IfNotExists bool // if true, skip copy when target already exists } ``` Copies run as root and use `sh -c "mkdir -p && cat > "` with stdin piped. ## Writing a Plugin ### 1. Create the package ``` internal/plugin/yourplugin/ plugin.go plugin_test.go ``` ### 2. Implement the interface ```go package yourplugin import ( "context" "github.com/fgrehm/crib/internal/plugin" ) type Plugin struct{} func New() *Plugin { return &Plugin{} } func (p *Plugin) Name() string { return "your-plugin" } func (p *Plugin) PreContainerRun(_ context.Context, req *plugin.PreContainerRunRequest) (*plugin.PreContainerRunResponse, error) { // Return nil for no-op (e.g. when prerequisite files don't exist). // Return response with mounts/env/copies as needed. return nil, nil } ``` ### 3. Register in setupPlugins In `cmd/root.go`: ```go import "github.com/fgrehm/crib/internal/plugin/yourplugin" func setupPlugins(eng *engine.Engine, d *oci.OCIDriver) { // ... mgr.Register(yourplugin.New()) // ... } ``` ### 4. Write tests (TDD) Follow the pattern in existing plugins. Tests should cover: - Plugin name - Happy path (returns expected mounts/env/copies) - No-op path (returns nil when prerequisites missing) - User variants (vscode, root, empty) - File staging (if applicable) ## Key Patterns ### Staging directory Plugins can stage files in `{req.WorkspaceDir}/plugins/{plugin-name}/`. This directory persists across container recreations but is scoped to the workspace. ### Inferring remote home The container doesn't exist during `pre-container-run`, so you can't run `getent` or similar. Use the convention: ```go func inferRemoteHome(user string) string { if user == "" || user == "root" { return "/root" } return "/home/" + user } ``` This matches devcontainer base images and covers the vast majority of cases. ### Bind mount: file vs directory **Never bind-mount a single file** if any process inside the container might do an atomic rename on it (write to `.file.new`, then `rename()` over the original). This causes `EBUSY` errors on Docker/Podman because the mount holds the inode. Instead, **bind-mount the parent directory** and point env vars to the file inside it. This lets processes create temp files alongside and rename freely. Example from the shell-history plugin: - Mount: `~/.crib/workspaces/{id}/plugins/shell-history/` -> `~/.crib_history/` - Env: `HISTFILE=~/.crib_history/.shell_history` The coding-agents plugin uses `FileCopy` instead of mounts for the same reason (Claude Code does atomic renames on `~/.claude.json`). ### Error handling - Return `error` for problems that should be visible to the user. - The manager logs plugin errors as warnings and skips the plugin (fail-open), so one broken plugin doesn't block container creation. - Return `nil, nil` for graceful no-op (e.g. prerequisite files don't exist on the host). ## Execution Flow ``` upSingle() restartRecreateSingle() buildImage() removeContainer() buildRunOptions() <- base config buildRunOptions() <- base config runPreContainerRunPlugins() runPreContainerRunPlugins() manager.RunPreContainerRun() manager.RunPreContainerRun() plugin1.PreContainerRun() plugin1.PreContainerRun() plugin2.PreContainerRun() plugin2.PreContainerRun() ...merge responses... ...merge responses... merge into RunOptions merge into RunOptions driver.RunContainer() driver.RunContainer() execPluginCopies() execPluginCopies() setupAndReturn() setupAndReturn() ``` ## Bundled Plugins ### coding-agents Shares Claude Code credentials with containers. Two modes: **Host mode (default):** Copies `~/.claude/.credentials.json` from the host into the container. Uses `FileCopy` (not bind mounts) because Claude Code does atomic renames on `~/.claude.json`. **Workspace mode:** Configured via devcontainer.json: ```json { "customizations": { "crib": { "coding-agents": { "credentials": "workspace" } } } } ``` In workspace mode, host credentials are not injected. Instead, a persistent directory is bind-mounted to `~/.claude/` inside the container. The user authenticates inside the container on first use, and credentials survive container rebuilds. A minimal `~/.claude.json` with `{"hasCompletedOnboarding":true}` is re-injected via FileCopy on each rebuild to skip the Claude Code onboarding flow. This file is not persisted because Claude Code does atomic renames on it. ### shell-history Persists bash/zsh history across container recreations by bind-mounting a history directory from workspace state and setting `HISTFILE`. ### ssh Shares SSH configuration and enables agent forwarding. Four components: 1. **SSH agent forwarding:** Bind-mounts the host's `SSH_AUTH_SOCK` socket into the container at `/tmp/ssh-agent.sock` and sets the `SSH_AUTH_SOCK` env var. No-op if the agent is not running. 2. **SSH config:** Copies `~/.ssh/config` into the container so host aliases, proxy settings, and other SSH config are available. 3. **SSH public keys:** Copies `*.pub` files from `~/.ssh/` into the container. Private keys are not copied. Git commit signing works via the forwarded SSH agent (requires OpenSSH 8.2+, which can sign using only the public key plus the agent). 4. **Git SSH signing config:** If the host's git config has `gpg.format = ssh`, the plugin extracts signing-related settings (`user.name`, `user.email`, `user.signingkey`, `gpg.format`, `gpg.ssh.program`, `commit.gpgsign`, `tag.gpgsign`) and generates a minimal `.gitconfig` for the container. The `user.signingkey` path is rewritten from the host home to the container home. Skipped entirely if git is not configured for SSH signing. ## Future: External Plugins Currently all plugins are bundled Go code compiled into the crib binary. A planned external plugin system will let users write plugins as standalone executables in any language. The protocol uses environment variables for input and JSON on stdout for output. To illustrate what this looks like, here are the two existing bundled plugins reimagined as bash scripts. ### shell-history (bash version) ```bash #!/usr/bin/env bash # shell-history — persist bash/zsh history across container recreations set -euo pipefail PLUGIN_DIR="${CRIB_WORKSPACE_DIR}/plugins/shell-history" HIST_FILE=".shell_history" # Infer remote home from user. if [ -z "${CRIB_REMOTE_USER}" ] || [ "${CRIB_REMOTE_USER}" = "root" ]; then REMOTE_HOME="/root" else REMOTE_HOME="/home/${CRIB_REMOTE_USER}" fi MOUNT_TARGET="${REMOTE_HOME}/.crib_history" # Create plugin dir and touch the history file if it doesn't exist. mkdir -p "${PLUGIN_DIR}" [ -f "${PLUGIN_DIR}/${HIST_FILE}" ] || touch "${PLUGIN_DIR}/${HIST_FILE}" # Output JSON response. cat < "${PLUGIN_DIR}/claude.json" # Infer remote home and owner. if [ -z "${CRIB_REMOTE_USER}" ] || [ "${CRIB_REMOTE_USER}" = "root" ]; then REMOTE_HOME="/root" OWNER="root" else REMOTE_HOME="/home/${CRIB_REMOTE_USER}" OWNER="${CRIB_REMOTE_USER}" fi # Output JSON response with file copies (not mounts, to avoid EBUSY on # atomic renames). cat <` in devcontainer.json or `~/.config/crib/config.toml`). --- ## DevContainer Spec Reference > **🤖 AI-generated reference** > This page was distilled from the [Dev Container Specification](https://containers.dev/implementors/spec/) by Claude (Opus 4.6) for quick lookup when working on crib. It may contain inaccuracies or be out of date. For the authoritative reference, see the [official spec](https://containers.dev/implementors/spec/). --- ## Config File Discovery > [Spec: Dev Containers](https://containers.dev/implementors/spec/) Tools search for `devcontainer.json` in this order: 1. `.devcontainer/devcontainer.json` 2. `.devcontainer.json` 3. `.devcontainer//devcontainer.json` (one level deep only) Multiple files may coexist. When more than one is found, the tool should let the user choose. The search starts at the **project workspace folder**, typically the git repository root. --- ## Three Configuration Scenarios > [Spec: Dev Containers](https://containers.dev/implementors/spec/) | Scenario | Required Fields | Notes | |---|---|---| | **Image-based** | `image` | Pulls a container image directly | | **Dockerfile-based** | `build.dockerfile` | Builds from a Dockerfile | | **Docker Compose-based** | `dockerComposeFile`, `service` | Uses Compose to orchestrate one or more services | Compose configurations handle images and Dockerfiles natively, so `image` and `build.dockerfile` are not used in that scenario. --- ## devcontainer.json Properties > [JSON Reference](https://containers.dev/implementors/json_reference/) ### General Properties (All Scenarios) | Property | Type | Default | Description | |---|---|---|---| | `name` | string | - | Display name for the dev container | | `features` | object | - | Feature IDs mapped to their options | | `overrideFeatureInstallOrder` | string[] | - | Override automatic Feature install ordering | | `forwardPorts` | (number\|string)[] | `[]` | Ports to forward from container to host | | `portsAttributes` | object | - | Per-port configuration (see [Port Attributes](#port-attributes)) | | `otherPortsAttributes` | object | - | Defaults for ports not listed in `portsAttributes` | | `containerEnv` | object | - | Env vars set on the container itself | | `remoteEnv` | object | - | Env vars injected by the tool post-ENTRYPOINT | | `containerUser` | string | root or last Dockerfile USER | User for all container operations | | `remoteUser` | string | same as containerUser | User for lifecycle hooks and tool processes | | `updateRemoteUserUID` | boolean | `true` | Sync UID/GID to local user (Linux only) | | `userEnvProbe` | enum | `loginInteractiveShell` | Shell type for probing env vars (`none`, `interactiveShell`, `loginShell`, `loginInteractiveShell`) | | `overrideCommand` | boolean | `true` (image/Dockerfile), `false` (Compose) | Replace default command with a sleep loop | | `shutdownAction` | enum | `stopContainer` or `stopCompose` | Action on close (`none`, `stopContainer`, `stopCompose`) | | `init` | boolean | `false` | Use tini init process | | `privileged` | boolean | `false` | Run in privileged mode | | `capAdd` | string[] | `[]` | Linux capabilities to add | | `securityOpt` | string[] | `[]` | Security options | | `mounts` | (string\|object)[] | - | Additional mounts (Docker `--mount` syntax) | | `customizations` | object | - | Tool-specific properties (namespaced by tool) | | `hostRequirements` | object | - | See [Host Requirements](#host-requirements) | ### Image / Dockerfile Properties | Property | Type | Default | Description | |---|---|---|---| | `image` | string | **required** (image scenario) | Container registry image | | `build.dockerfile` | string | **required** (Dockerfile scenario) | Path to Dockerfile, relative to devcontainer.json | | `build.context` | string | `"."` | Docker build context directory | | `build.args` | object | - | Docker build arguments | | `build.options` | string[] | `[]` | Extra Docker build CLI flags | | `build.target` | string | - | Multi-stage build target | | `build.cacheFrom` | string\|string[] | - | Cache source images | | `workspaceMount` | string | - | Custom mount for source code | | `workspaceFolder` | string | - | Default path opened inside the container | | `runArgs` | string[] | `[]` | Extra `docker run` CLI flags | | `appPort` | int\|string\|array | `[]` | **Legacy**, use `forwardPorts` instead | ### Docker Compose Properties | Property | Type | Default | Description | |---|---|---|---| | `dockerComposeFile` | string\|string[] | **required** | Path(s) to Compose file(s) | | `service` | string | **required** | Primary service to connect to | | `runServices` | string[] | all services | Subset of services to start. The primary `service` is always started regardless. | | `workspaceFolder` | string | `"/"` | Default path opened inside the container | ### Lifecycle Hooks | Property | Type | Runs | When | |---|---|---|---| | `initializeCommand` | string\|string[]\|object | On **host** | Initialization (may run multiple times) | | `onCreateCommand` | string\|string[]\|object | In container | First creation only | | `updateContentCommand` | string\|string[]\|object | In container | After content is available (creation only) | | `postCreateCommand` | string\|string[]\|object | In container | After user setup (creation only, background by default) | | `postStartCommand` | string\|string[]\|object | In container | Every container start | | `postAttachCommand` | string\|string[]\|object | In container | Every tool attachment | | `waitFor` | enum | - | Block until this stage completes. Default: `updateContentCommand`. Values: `initializeCommand`, `onCreateCommand`, `updateContentCommand`, `postCreateCommand`, `postStartCommand` | Command type details: - **string**: Executed via the default shell (`/bin/sh`). - **string[]**: Direct exec, no shell interpretation. - **object**: Keys are unique names, values are string or string[]. All entries run in **parallel**. Every entry must succeed for the stage to succeed. ### Host Requirements | Property | Type | Description | |---|---|---| | `hostRequirements.cpus` | integer | Minimum CPU count | | `hostRequirements.memory` | string | Minimum RAM (suffix: `tb`, `gb`, `mb`, `kb`) | | `hostRequirements.storage` | string | Minimum storage (same suffixes) | | `hostRequirements.gpu` | boolean\|string\|object | `true` = required, `"optional"` = preferred, or `{"cores": N, "memory": "Xgb"}` | ### Port Attributes Properties inside `portsAttributes` entries: | Property | Type | Default | Description | |---|---|---|---| | `label` | string | - | Display name | | `protocol` | enum | - | `http` or `https` | | `onAutoForward` | enum | `notify` | `notify`, `openBrowser`, `openBrowserOnce`, `openPreview`, `silent`, `ignore` | | `requireLocalPort` | boolean | `false` | Require the same port number locally | | `elevateIfNeeded` | boolean | `false` | Auto-elevate permissions for low ports | --- ## Variable Substitution > [JSON Reference: Variables](https://containers.dev/implementors/json_reference/) | Variable | Usable In | Description | |---|---|---| | `${localEnv:VAR}` | Any property | Host env var. Supports default: `${localEnv:VAR:fallback}` | | `${containerEnv:VAR}` | `remoteEnv` only | Container env var. Supports default: `${containerEnv:VAR:fallback}` | | `${localWorkspaceFolder}` | Any property | Full path to the opened local folder | | `${containerWorkspaceFolder}` | Any property | Full path inside the container | | `${localWorkspaceFolderBasename}` | Any property | Local folder basename only | | `${containerWorkspaceFolderBasename}` | Any property | Container folder basename only | | `${devcontainerId}` | `name`, lifecycle hooks, `mounts`, env vars, user properties, `customizations` | Stable unique ID across rebuilds | Substitution happens at the time the value is applied (runtime, not build time). `${containerEnv}` is restricted to `remoteEnv` because container env vars only exist after the container is running. --- ## Lifecycle > [Spec: Lifecycle](https://containers.dev/implementors/spec/) ### Creation Flow ``` initializeCommand (host, may run multiple times) | v Image build / pull (Features applied as Dockerfile layers) | v Container creation (mounts, containerEnv, containerUser applied) | (remoteUser/remoteEnv NOT applied yet) v onCreateCommand (in container, with remoteUser/remoteEnv) | v updateContentCommand (in container) | v postCreateCommand (in container, background by default) | v [implementation-specific] (tool-specific init, e.g. extension install) | v postStartCommand (in container) | v postAttachCommand (in container) ``` The `waitFor` property controls at which point the tool reports the environment as ready and proceeds to implementation-specific steps. Default is `updateContentCommand`. ### Resume Flow ``` Restart containers | v [implementation-specific steps] | v postStartCommand | v postAttachCommand ``` Remote env vars and user configuration apply during resume. ### Hook Execution Details - Commands within an **object** run in **parallel**. All must succeed. - Feature-provided lifecycle hooks run **before** user-defined hooks, in Feature installation order. - `onCreateCommand`, `updateContentCommand`, and `postCreateCommand` run only on first creation. - `postStartCommand` runs on every start (creation and resume). - `postAttachCommand` runs on every tool attachment. - Remote env vars and `userEnvProbe` results are available for all post-creation hooks. --- ## Users and Permissions > [Spec: Users](https://containers.dev/implementors/spec/) Two distinct user concepts: - **Container User** (`containerUser` for image/Dockerfile, `user` in Compose): Runs all container operations including the ENTRYPOINT. - **Remote User** (`remoteUser`): Runs lifecycle hooks and tool processes. Defaults to `containerUser` if not set. This separation allows different permissions for container operations vs. developer activity. ### updateRemoteUserUID - Linux only, default `true`. - When enabled and a remote/container user is specified, the tool updates the image user's UID/GID to match the local user before container creation. - Prevents permission mismatches on bind mounts. - Implementations may skip this when not using bind mounts or when the container engine provides automatic UID translation. --- ## Environment Variables > [Spec: Environment Variables](https://containers.dev/implementors/spec/) Two classes: | Type | Property | Set When | Available | |---|---|---|---| | **Container** | `containerEnv` | At container build/create time | Entire container lifecycle | | **Remote** | `remoteEnv` | Post-ENTRYPOINT by the tool | Lifecycle hooks and tool processes | Remote env vars support `${containerEnv:VAR}` substitution since the container is already running when they are applied. ### userEnvProbe Tools probe the user's environment using the configured shell type and merge the resulting variables with `remoteEnv` for injected processes. This emulates the behavior developers expect from their profile/rc files. --- ## Workspace Mount and Folder > [Spec: Workspace](https://containers.dev/implementors/spec/) - `workspaceMount` (image/Dockerfile only): Defines the source mount for the workspace. The default is a bind mount of the local folder into `/workspaces/`. - `workspaceFolder`: The default working directory inside the container. - Both should reference the repository root (where `.git` lives) for proper source control. - For monorepos, `workspaceFolder` can point to a subfolder while `workspaceMount` targets the repo root. --- ## Image Metadata > [Spec: Image Metadata](https://containers.dev/implementors/spec/) Configuration can be baked into images via the `devcontainer.metadata` label. The label value is a JSON string containing either: - An **array** of metadata objects (one per Feature, plus one for the devcontainer.json config). - A **single top-level object**. The array format is preferred because subsequent builds can simply append entries. ### Storable Properties These properties can appear in image metadata: `forwardPorts`, `portsAttributes`, `otherPortsAttributes`, `containerEnv`, `remoteEnv`, `remoteUser`, `containerUser`, `updateRemoteUserUID`, `userEnvProbe`, `overrideCommand`, `shutdownAction`, `init`, `privileged`, `capAdd`, `securityOpt`, `mounts`, `customizations`, `hostRequirements`, all lifecycle hooks (`onCreateCommand` through `postAttachCommand`), and `waitFor`. ### Metadata Merge Rules When merging image metadata with `devcontainer.json`, the local file is considered **last** (highest priority where order matters). | Strategy | Properties | |---|---| | Boolean OR (true if any is true) | `init`, `privileged` | | Union without duplicates | `capAdd`, `securityOpt` | | Union, last wins on conflicts | `forwardPorts` | | Collected list (append in order) | All lifecycle hooks, `entrypoint` | | Collected list, last wins on conflicts | `mounts` | | Last value wins (scalar) | `waitFor`, `containerUser`, `remoteUser`, `userEnvProbe`, `shutdownAction`, `updateRemoteUserUID`, `overrideCommand` | | Per-variable, last wins | `remoteEnv`, `containerEnv` | | Per-port, last wins | `portsAttributes` | | Last value wins | `otherPortsAttributes` | | Maximum value wins | `hostRequirements` (cpus, memory, storage, gpu) | | Tool-specific logic | `customizations` | --- ## Features > [Features Specification](https://containers.dev/implementors/features/) | > [Features Overview](https://containers.dev/features/) Features are modular, self-contained units that add tools, runtimes, and capabilities to dev containers without writing complex Dockerfiles. ### Feature Structure A Feature is a directory containing: - `devcontainer-feature.json` (metadata, required) - `install.sh` (entrypoint script, required) - Additional supporting files (optional) ### devcontainer-feature.json #### Required | Property | Type | Description | |---|---|---| | `id` | string | Unique within the repository, must match directory name | | `version` | string | Semver (e.g., `1.0.0`) | | `name` | string | Human-friendly display name | #### Optional Metadata | Property | Type | Description | |---|---|---| | `description` | string | Feature overview | | `documentationURL` | string | Docs link | | `licenseURL` | string | License link | | `keywords` | string[] | Search terms | | `deprecated` | boolean | Deprecation flag | | `legacyIds` | string[] | Previous IDs (for renaming) | #### Configuration | Property | Type | Description | |---|---|---| | `options` | object | User-configurable parameters (see [Feature Options](#feature-options)) | | `containerEnv` | object | Env vars added as Dockerfile `ENV` before `install.sh` | | `privileged` | boolean | Requires privileged mode | | `init` | boolean | Requires tini init | | `capAdd` | string[] | Linux capabilities | | `securityOpt` | string[] | Security options | | `entrypoint` | string | Custom startup script path | | `mounts` | object | Additional mounts | #### Dependencies | Property | Type | Description | |---|---|---| | `dependsOn` | object | Hard dependencies (recursive). Feature fails if unresolvable. Values include options and version. | | `installsAfter` | string[] | Soft ordering (non-recursive). Only affects ordering of already-queued Features. Ignored if the referenced Feature is not being installed. | #### Lifecycle Hooks Features can declare their own lifecycle hooks: `onCreateCommand`, `updateContentCommand`, `postCreateCommand`, `postStartCommand`, `postAttachCommand`. Same types as the main config. #### Customizations | Property | Type | Description | |---|---|---| | `customizations` | object | Tool-specific config. Arrays merge as union, objects merge values. | ### Feature Options ```json "options": { "optionId": { "type": "string|boolean", "description": "What this option controls", "proposals": ["val1", "val2"], "enum": ["strict1", "strict2"], "default": "val1" } } ``` - `proposals`: Suggested values, free-form input allowed. - `enum`: Strict allowed values only. - `default`: Fallback when the user provides nothing. Option IDs are converted to environment variables for `install.sh`: ``` replace non-word chars with _ -> strip leading digits/underscores -> UPPERCASE ``` Written to `devcontainer-features.env` and sourced before the script runs. ### Built-in Variables for install.sh | Variable | Description | |---|---| | `_REMOTE_USER` | Configured remote user | | `_CONTAINER_USER` | Container user | | `_REMOTE_USER_HOME` | Remote user's home directory | | `_CONTAINER_USER_HOME` | Container user's home directory | Features always execute as **root** during image build. ### Referencing Features In `devcontainer.json`: ```json "features": { "ghcr.io/devcontainers/features/go:1": {}, "ghcr.io/user/repo/node:18": {"version": "18"}, "https://example.com/feature.tgz": {}, "./local-feature": {"optionA": "value"} } ``` Three source types: 1. **OCI Registry**: `//[:]` 2. **HTTPS Tarball**: Direct URL to `.tgz` 3. **Local Directory**: `./path` relative to devcontainer.json ### Installation Order Algorithm > [Features Spec: Installation Order](https://containers.dev/implementors/features/) **Step 1 - Build dependency graph:** - Traverse `dependsOn` recursively and `installsAfter` non-recursively. - Deduplicate using [Feature Equality](#feature-equality). **Step 2 - Assign round priority:** - Default: 0. - `overrideFeatureInstallOrder` assigns priorities: `array_length - index` (first item = highest priority). **Step 3 - Round-based sorting:** 1. Identify Features whose dependencies are all satisfied. 2. From those, commit only Features with the maximum `roundPriority`. 3. Return lower-priority Features to the worklist for the next round. 4. Within a round, stable sort lexicographically by: resource name, version tag, options count, option keys/values, canonical name. 5. If a round makes no progress, fail (circular dependency). ### Feature Equality | Source | Equal When | |---|---| | OCI Registry | Manifest digests match AND options are identical | | HTTPS Tarball | Content hashes match AND options are identical | | Local Directory | Always unique | ### Dockerfile Layer Generation - Each Feature's `install.sh` runs as its own Dockerfile layer (for caching). - `containerEnv` values become `ENV` instructions **before** `install.sh` runs. - `install.sh` is invoked as: `chmod +x install.sh && ./install.sh` - Default shell: `/bin/sh`. - `privileged` and `init`: required if **any** Feature needs them (boolean OR across all). - `capAdd` and `securityOpt`: union across all Features. ### Feature Lifecycle Hooks - Feature hooks execute **before** user-defined hooks. - Hooks run in Feature installation order. - Object-syntax commands within a single Feature run in parallel. - All hooks run from the project workspace folder. --- ## Features Distribution (OCI) > [Features Distribution](https://containers.dev/implementors/features-distribution/) ### Packaging Distributed as `devcontainer-feature-.tgz` tarballs containing the Feature directory. ### OCI Artifact Format | Artifact | Media Type | |---|---| | Config | `application/vnd.devcontainers` | | Feature layer | `application/vnd.devcontainers.layer.v1+tar` | | Collection metadata | `application/vnd.devcontainers.collection.layer.v1+json` | ### Naming `//[:version]` Example: `ghcr.io/devcontainers/features/go:1.2.3` ### Version Tags Multiple tags are pushed for each release: `1`, `1.2`, `1.2.3`, and `latest`. ### Collection Metadata An auto-generated `devcontainer-collection.json` aggregates all Feature metadata in a namespace. Published at `/:latest`. ### Manifest Annotations Published manifests include a `dev.containers.metadata` annotation containing the escaped JSON from `devcontainer-feature.json`. ### Authentication 1. Docker config: `$HOME/.docker/config.json` 2. Environment variable: `DEVCONTAINERS_OCI_AUTH` (format: `service|user|token`) --- ## Templates > [Templates Specification](https://containers.dev/implementors/templates/) | > [Templates Distribution](https://containers.dev/implementors/templates-distribution/) | > [Templates Overview](https://containers.dev/templates/) Templates are pre-configured dev environment blueprints. They are not directly relevant to crib's runtime behavior but are useful context for understanding the ecosystem. ### Structure ``` template/ devcontainer-template.json .devcontainer.json (or .devcontainer/devcontainer.json) (supporting files) ``` ### devcontainer-template.json Required: `id`, `version`, `name`, `description`. Optional: `documentationURL`, `licenseURL`, `platforms`, `publisher`, `keywords`, `options`, `optionalPaths`. ### Option Substitution Template options use `${templateOption:optionId}` syntax. The tool replaces these placeholders with user-selected values when applying a template. ### Distribution Same OCI pattern as Features (tarballs with custom media types, semver tagging). The namespace for Templates **must be different** from the namespace for Features. --- ## devcontainerId Computation > [Spec: devcontainerId](https://containers.dev/implementors/spec/) Computed from container labels: 1. Serialize labels as sorted JSON (keys alphabetically, no whitespace). 2. Compute SHA-256 of the UTF-8 encoded string. 3. Base-32 encode the hash. 4. Left-pad to 52 characters with `0`. The ID is deterministic across rebuilds and unique per Docker host. --- ## JSON Schema > [JSON Schema](https://containers.dev/implementors/json_schema/) - Conforms to **JSON Schema Draft 7**. - Permits comments (JSONC), disallows trailing commas. - Base schema: `devContainer.base.schema.json`. - Main schema: `devContainer.schema.json` (references base + tool-specific schemas). - Source: [devcontainers/spec on GitHub](https://github.com/devcontainers/spec). --- ## Official Reference Links | Resource | URL | |---|---| | Main Specification | https://containers.dev/implementors/spec/ | | JSON Reference | https://containers.dev/implementors/json_reference/ | | JSON Schema | https://containers.dev/implementors/json_schema/ | | Features Overview | https://containers.dev/features/ | | Features Spec (Implementors) | https://containers.dev/implementors/features/ | | Features Distribution | https://containers.dev/implementors/features-distribution/ | | Templates Overview | https://containers.dev/templates/ | | Templates Spec (Implementors) | https://containers.dev/implementors/templates/ | | Templates Distribution | https://containers.dev/implementors/templates-distribution/ | | Supporting Tools | https://containers.dev/supporting | | GitHub Repository | https://github.com/devcontainers/spec |