Skip to content

Latest commit

 

History

46 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

herdr-devcontainer — Dev Container panes for Herdr

Latest release Herdr plugin Requires Herdr 0.8.0 or newer Linux and WSL2 MIT License CI

herdr-devcontainer takes a repository's devcontainer.json through the official Dev Containers CLI and opens Herdr shell or coding-agent panes inside the resulting container.

Run Herdr shells and coding-agent panes inside the Dev Container the repository already defines. The plugin uses the official Dev Containers CLI, keeps devcontainer.json as the source of truth, and requires no editor.

Point it at a repository that carries a Dev Container configuration and it resolves the repository, brings the container up, maps your current directory to the matching path inside the container, and replaces itself with a docker exec — so the pane you get is an ordinary interactive shell or agent session, just on the other side of the container boundary. There is no second container format and nothing here re-implements devcontainer.json.

Current boundary: Linux, including WSL2 — the manifest declares platforms = ["linux"]. Installing from GitHub compiles the Rust plugin locally, so a Rust toolchain must be present. The target directory must be a Git repository with a Dev Container configuration, unless that repository is explicitly forced on in plugin config.

Requirements

Herdr 0.8.0 or newer (min_herdr_version in the manifest)
Platform Linux or WSL2
Git any recent version; the plugin shells out to git worktree list
Docker with a reachable daemon
Dev Containers CLI npm install -g @devcontainers/cli
Rust and Cargo 1.74 or newer (rust-version in Cargo.toml)

Rust is required because the plugin manifest's build hook runs:

cargo build --release

The committed Cargo.lock is resolved against that minimum rather than against the newest published dependencies, so a 1.74 toolchain builds this checkout as it stands. CI verifies the declared minimum on every change, so the number above cannot drift away from what actually compiles.

Install

Install the latest reviewed release:

herdr plugin install gambtho/herdr-devcontainer --ref v0.2.0

Herdr clones the tagged source and runs the manifest's cargo build --release hook before registering the plugin. Pass --yes to skip the confirmation prompt.

To follow current development on main instead — which is not a release:

herdr plugin install gambtho/herdr-devcontainer

Verify what got registered:

herdr plugin list --plugin devcontainer
herdr plugin action list --plugin devcontainer

For local development, build once yourself first — herdr plugin link does not run the manifest build hook:

cargo build --release
herdr plugin link /path/to/herdr-devcontainer

Entrypoints

Three panes, and one action per pane that opens it from anywhere:

Pane id Action id What it does
shell devcontainer.open-shell The container user's shell, interactive, inside the repository's Dev Container
command devcontainer.open-command Runs the configured command payload through that same interactive shell; default claude
stop devcontainer.open-stop Popup that identifies the repository's container — and, for a Compose-based Dev Container, every service alongside it — names them, asks for confirmation, and stops them

Open a pane directly:

herdr plugin pane open --plugin devcontainer --entrypoint shell
herdr plugin pane open --plugin devcontainer --entrypoint command

Or invoke the equivalent action. The CLI takes the bare action id, with --plugin to disambiguate:

herdr plugin action invoke open-shell --plugin devcontainer
herdr plugin action invoke open-command --plugin devcontainer
herdr plugin action invoke open-stop --plugin devcontainer

Opening a shell or command pane is the explicit lifecycle trigger. Container lifecycle is never attached to repository events, nothing is stopped automatically, and the plugin never runs docker rm — stop stops containers, it does not remove them.

For a Compose-based Dev Container, stop takes down the whole Compose project, not just the service the pane runs in. That is what devcontainer.json's default shutdownAction of stopCompose asks for, and stopping the app while its database and cache keep running is rarely what "stop the Dev Container" meant. Project membership is read from the container's own com.docker.compose.project label — the same label Compose itself uses — so nothing here re-derives the project name from devcontainer.json. The confirmation lists every container it will stop, by service, name, and id.

Containers started by docker compose run are left alone, matching docker compose stop. The Dev Container's own service is stopped and waited on before the rest, so it releases its database connections first. If the Dev Container has exited on its own while its services keep running, stop says so and offers to stop what is left — an absent Dev Container is not an absent project.

Why use it

  • One development environment. Shells and agents see the same tools, dependencies, users, and workspace path the repository's Dev Container already defines for everyone else.
  • No duplicated configuration. The plugin delegates to devcontainer up; it does not parse or reinterpret devcontainer.json.
  • Visible bring-up. Build and lifecycle-hook output stays in the new pane instead of disappearing behind an editor.
  • No editor required. The official CLI is the only interpreter involved.
  • Serialized startup. A per-repository flock means two panes opened at once cannot race through bring-up.
  • Deterministic container selection. If more than one running container claims the repository, the plugin lists them and refuses to guess.
  • Explicit, confirmed shutdown. Stop is its own action, names every container it will stop — the whole Compose project, where there is one — and proceeds only on y or yes.

Keybindings

Bind the fully qualified action id (<plugin id>.<action id>):

[[keys.command]]
key = "prefix+d"
type = "plugin_action"
command = "devcontainer.open-shell"
description = "dev container shell"

[[keys.command]]
key = "prefix+shift+s"
type = "plugin_action"
command = "devcontainer.open-stop"
description = "stop dev container"

prefix+shift+s for stop is deliberate: Herdr's default close_workspace binding is prefix+shift+d, so binding stop to prefix+D would put a destructive plugin action on top of a destructive built-in one.

These are examples, not reserved defaults. Check what your own map already uses before adopting them — prefix+? opens Herdr's help, and herdr config check validates config.toml and prints diagnostics.

Configuration

Optional, at $XDG_CONFIG_HOME/herdr-devcontainer/config.toml, falling back to ~/.config/herdr-devcontainer/config.toml when XDG_CONFIG_HOME is unset or empty:

# Any command available inside the container: claude, codex, opencode, ...
command = "claude"

# Ceiling for `devcontainer up`, in seconds.
up_timeout_secs = 300

[repos."/home/you/workspace/foo"]
enabled = "auto"                                 # "auto" (default) | "true" | "false"
config = ".devcontainer/alt/devcontainer.json"   # repo-relative
shell = "/bin/zsh"                               # default: the container user's login shell
env = ["ANTHROPIC_BASE_URL=http://proxy:8080"]   # passed as `docker exec -e`
Setting Meaning
command Payload for the command pane; default claude
up_timeout_secs Bring-up timeout in seconds; default 300
enabled = "auto" Require a Dev Container config at a standard or explicitly configured path
enabled = "true" Skip detection entirely and let devcontainer up decide what is valid
enabled = "false" Refuse to open container panes for that repository
config Alternate repo-relative devcontainer.json path
shell Shell to exec into, overriding the one probed from the container
env KEY=value assignments passed to docker exec -e

An env entry that is not KEY=value is dropped with a warning rather than forwarded: docker exec -e NAME with no = exports the host's variable of that name into the container, which is never what the setting asked for.

Under auto, detection checks, in order:

.devcontainer/devcontainer.json
.devcontainer.json

Repo keys are matched against the canonicalized repo root. A config value is repo-relative by contract: absolute paths and .. traversal are rejected in every mode, including enabled = "true".

Guarantees worth knowing:

  • A missing config file means defaults.
  • An unreadable one is an error, not a silent fallback — a permission or I/O failure can never quietly re-enable a repo you set to enabled = "false".
  • Unknown keys are warnings, not errors.

Repository and worktree behavior

The plugin resolves the main Git worktree and uses that canonical path as repository and container identity. That matches the devcontainer.local_folder label the Dev Containers CLI writes itself, and it means every linked worktree of a repository shares one Dev Container rather than racing to create independent ones that would collide on ports and similar resources.

That label only matches when the CLI created the container. Labels are written once, at creation, and VS Code on Windows writes local_folder as the host's UNC view of the WSL path — \\wsl.localhost\Ubuntu\home\you\repo — which no POSIX repository root can equal. Nothing rewrites it afterward: Docker Compose reuses containers by its own project name, so a container VS Code created stays reachable through devcontainer up while carrying a label the plugin cannot match. Discovery therefore also looks containers up by devcontainer.config_file, which the CLI resolves from inside WSL and so holds a POSIX path on those same containers. Both are exact-match lookups; the plugin never guesses at how a host renders a path.

That also defines the main limitation:

A linked worktree checked out outside the main repository directory is not automatically mounted inside the main repository's container. When the current directory cannot be mapped under the repository root, the pane starts at the container's workspace root and prints a notice rather than pretending the external checkout is available inside the container.

For a current directory underneath the main repository root, the relative host path is appended to the container's remoteWorkspaceFolder and used as the docker exec working directory. That directory comes from Herdr's invocation context (the focused pane, then the workspace), not from the wrapper's own working directory — Herdr runs a plugin pane from the plugin root, so reading the process cwd would report every launch as an out-of-repo checkout.

Environment inside the pane

Panes exec the container user's shell interactively — the shell from the container's passwd entry, sh (with a printed note) when that cannot be read or names nologin/false. Interactive is the part that matters: ~/.zshrc and ~/.bashrc are sourced only by an interactive shell, and that is where Dev Container setup scripts put PATH entries and API endpoints. A login-only shell reads ~/.zprofile and stops, which is how an agent in a container silently bypasses a configured proxy.

Shells are also started as login shells, so /etc/profile and ~/.zprofile apply — except bash, which is the one shell that reads ~/.bashrc only when interactive and not a login shell. Images that ship a ~/.bashrc and no profile file at all are common enough (devcontainers/base is one) that adding -l for bash would lose exactly the environment this is here to collect, so bash gets -i/-ic and every other known shell gets -li/-lic.

"Known" is literal: sh, ash, dash, busybox, zsh, ksh, mksh, pdksh, yash, and fish are each verified to accept those combined flags. A shell outside that list is driven with bare -c instead, because not every shell takes them — tcsh -lic fails with "Unknown option", which would produce a pane that never opens. That fallback is the one case where a command pane is not interactive and no startup file is guaranteed: an unknown shell is run plainly and left to its own rc conventions. Name it in shell and it is used as given, but the flag rules above still apply to it by basename.

Set shell for a repository to override the probe.

One consequence worth knowing: rc files run, so an rc file that changes directory wins over the mapped working directory. docker exec -w still puts the shell in the right place — a cd /app in ~/.zshrc (some images bake one in) simply runs afterwards. If subdirectory panes matter more than that cd, make the rc line conditional; there is nothing this plugin can do from outside without discarding the rc file's environment along with it.

What this does not do is apply remoteEnv from devcontainer.json. That value is a merge of the config file, every Feature's contributed metadata, and the image's devcontainer.metadata label, and devcontainer up does not report the resolved result — so parsing the config file alone would produce a partial environment that looks authoritative. A pane's environment therefore comes from three places only: what the image itself sets, what the shell's startup files add, and what you list in env. Often the shell covers what remoteEnv would have, because the same setup scripts write both — but that is a coincidence of how a repository is built, not a guarantee. Anything you actually depend on belongs in env.

How it works

For a shell or command pane, the wrapper:

  1. resolves the repository's main Git worktree from Herdr context, falling back to Git on the pane and process working directories;
  2. detects the repository's Dev Container configuration;
  3. verifies the Dev Containers CLI is on PATH and the Docker daemon answers;
  4. discovers existing containers by the devcontainer.local_folder and devcontainer.config_file labels;
  5. refuses to continue when more than one of them is running;
  6. acquires a per-repository lock;
  7. runs devcontainer up and validates its success result;
  8. maps the pane's directory into remoteWorkspaceFolder;
  9. probes the container user's login shell; and
  10. replaces itself with docker exec -i -t.

Two rules run through the whole implementation. Host-side subprocesses are invoked as direct argv arrays with no shell in between, so repository-controlled paths cannot inject host commands — only the configured payload is interpreted, by the container's own shell inside the container. And uncertainty is never absence: a Docker check that fails, or a docker ps line that will not parse, is an error, never "there is no container."

The shell probe is the one deliberate exception, because a container that will not name its user's shell is still a container worth opening. It degrades to sh rather than failing — but it says so, and says why, so the degraded pane is never mistaken for a working one.

The full design, including the wrapper flow step by step, is in docs/superpowers/specs/2026-08-11-herdr-devcontainer-plugin-design.md.

Trust and security

This plugin is not a security boundary, and opening a Dev Container is not sandboxing.

  • Installing a Herdr plugin runs its build and runtime commands as your user. Review herdr-plugin.toml and the source before installing code you do not trust.
  • devcontainer up executes repository-controlled build and lifecycle code — Dockerfile steps, postCreateCommand, and the rest. Opening a Dev Container for a repository you do not trust runs that repository's code, exactly as it does in VS Code Dev Containers. This plugin inherits that trust model and does not add to it.
  • What a Dev Container does give you is a consistent environment, not an isolated one.
  • The pane payload never comes from repository content. It is either a plain login shell or the command in your own plugin config.

Development and tests

cargo build --release
cargo test
cargo test --test integration -- --ignored

The ignored integration suite performs a real container bring-up and requires Docker, the Dev Containers CLI, and script(1).

Nothing in this repository parses herdr-plugin.toml, so a green Rust test run says nothing about the manifest being valid. That is only verified by linking or installing the plugin into a real Herdr and exercising all three entrypoints.

Status

The current release is v0.2.0, matching the version in both Cargo.toml and the plugin manifest. Pin to it with --ref to install a reviewed revision:

herdr plugin install gambtho/herdr-devcontainer --ref v0.2.0

An install without --ref follows the current default branch instead.

License

MIT. See LICENSE.

About

Herdr plugin for opening shells and coding agents inside a repo's Dev Container via the official Dev Containers CLI.

Topics

Resources

Stars

7 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages