Reusable .devcontainer/ toolkit for projects that want the normal VS Code Dev
Containers workflow with the current Codegeist/planner development toolchain.
This repository is a reusable devcontainer kit that can be added to other
repositories at .devcontainer/, either as a Git subtree or as a Git submodule.
The current Dockerfile intentionally carries the full Codegeist/planner-style
toolchain, including Docker CE, Node 24, VS Code, GitHub CLI, Maven, GraalVM,
Hugo, Nix, OpenCode tooling, Repomix, and related CLI tools.
The consuming project should use the standard VS Code flow:
- Clone the consuming repository.
- Open the repository folder in VS Code.
- Run
Dev Containers: Reopen in Container.
The kit should not require a root-level launcher such as start.sh for normal
VS Code usage. It should also not open VS Code from its own scripts. VS Code and
the Dev Containers extension own the container lifecycle.
The devcontainer user follows the host $USER: remoteUser and
containerUser both use ${localEnv:USER}. initialize.sh writes matching
generated Docker build arguments and numeric runtime user values so the user
exists in the image and bind-mounted files use the host UID for both the
numeric user and group.
The same configuration can be smoke-tested without opening VS Code by running the Dev Containers CLI against the repository root:
npx --yes @devcontainers/cli up --workspace-folder <repo-root>VS Code opens the container workspace at /workspace. Without BRANCH, the
Compose runtime mounts the repository root there. With BRANCH,
initializeCommand creates or reuses the matching Git worktree and
docker-compose.yml mounts that worktree at /workspace while still mounting
the repository root at its host path for linked-worktree Git metadata.
Use one of the two installation modes below to place this kit at
.devcontainer/ in a consuming repository. After installing it, add these local
files to the consuming repository's .gitignore:
/.local.env
/compose.local.yml
/.worktrees/Open the consuming repository root in VS Code and run
Dev Containers: Reopen in Container:
code .To select a managed Git worktree at /workspace, still start VS Code from the
consuming repository root and set BRANCH:
BRANCH=develop0 code .The first start creates local generated files when missing:
- root
.local.env - root
compose.local.yml .devcontainer/.gen.env.devcontainer/compose.local.gen.yml.worktrees/<branch>whenBRANCHis set
Do not edit .devcontainer/.gen.env or
.devcontainer/compose.local.gen.yml; they are regenerated by
initialize.sh. Put manual overrides in root .local.env and
compose.local.yml instead.
Clone this repository with submodules initialized because .opencode/ is a
shared OpenCode workspace submodule:
git clone --recurse-submodules <this-repo-url>
cd <repo>If the repository was already cloned without submodules, initialize them later:
git submodule update --init --recursiveRun the local test suite from this repository root:
task tests-runOpen the current Git root with the real VS Code entrypoint:
task code-open
task code-open -- develop0BRANCH=develop0 task code-open is still accepted for shell-driven runs. When a
branch is passed as a task argument, the command writes .devcontainer/.env so
VS Code and Docker Compose keep the branch even if an existing VS Code process
handles the code request.
Run the fixture-backed reality test when you need to exercise the same command against a temporary consuming repository:
task code-open-test
task code-open-test -- develop0The reality test intentionally leaves its temporary fixture and VS Code-started container in place because VS Code is opened against that fixture.
This repository includes .opencode/ as a Git submodule pointing to
https://github.com/codegeist-ai/codegeist-agent-kit. It provides shared
OpenCode commands, rules, and skills used while maintaining this kit.
Keep it initialized for development work:
git submodule update --init --recursive .opencodeThe submodule is not part of the consuming .devcontainer/ runtime contract;
it is repository-local AI workflow support for this kit.
Use a Git subtree when a project should vendor this kit into .devcontainer/
without making consumers initialize a submodule. The consuming repository stores
the kit files directly in its history, while still allowing updates from the
upstream kit repository.
Add the kit to a consuming repository:
git remote add devcontainer-kit <kit-repo-url>
git fetch devcontainer-kit
git subtree add --prefix=.devcontainer devcontainer-kit <branch> --squashReplace <kit-repo-url> with this repository URL and <branch> with the kit
branch to consume, for example main.
Update the vendored kit later:
git fetch devcontainer-kit
git subtree pull --prefix=.devcontainer devcontainer-kit <branch> --squashCommit the subtree add or pull like any other source change in the consuming repository.
If the consuming repository already has a .devcontainer/ directory, move or
remove that directory first. git subtree add expects the target prefix to be
absent or empty.
The consuming repository should ignore local files generated next to the subtree:
/.local.env
/compose.local.yml
/.worktrees/The kit also writes generated runtime files inside .devcontainer/:
.gen.env and compose.local.gen.yml. They are ignored by the kit's own
.gitignore and should not be edited manually.
initializeCommand is not a bootstrap mechanism for downloading the kit. If
.devcontainer/devcontainer.json is not present in the checkout, VS Code cannot
discover or run the devcontainer configuration at all.
Use a Git submodule when the consuming repository should keep the kit as a
separate repository mounted at .devcontainer/.
Add the kit to a consuming repository:
git submodule add <kit-repo-url> .devcontainer
git commit -m "chore(devcontainer): add shared kit submodule"Clone or update consuming repositories with submodules initialized:
git submodule update --init --recursiveThe same generated root files must still be ignored by the consuming repository:
/.local.env
/compose.local.yml
/.worktrees/The .devcontainer/.gen.env and .devcontainer/compose.local.gen.yml files are
generated by the kit inside the submodule checkout and intentionally ignored
there.
The test suite includes a real submodule-consuming fixture that starts
BRANCH=dev0 through devcontainer up, verifies /workspace, nested Docker,
and a commit/fast-forward merge flow from inside the container.
The primary entrypoint is devcontainer.json.
The intended flow is:
VS Code opens repository
Dev Containers extension reads .devcontainer/devcontainer.json
initializeCommand creates local compose/env files when missing
Docker Compose builds and starts the workspace service
VS Code attaches to the workspace service
This means the kit should avoid old launcher-style behavior in the normal VS Code path:
- no
code --new-windowfrom repository scripts - no recursive reopen-in-container behavior
- no root
start.shdependency - no project-specific assumptions such as
CODEGEIST_*
initializeCommand is the only standard devcontainer lifecycle hook that runs
on the host before the container exists. It is useful for preparing local files
that Docker Compose or the container runtime will consume.
Recommended shape:
{
"initializeCommand": ".devcontainer/initialize.sh"
}The initializer must be:
- idempotent
- non-interactive
- fast enough for repeated starts
- safe when run multiple times in one VS Code session
- limited to host-side preparation
It must not:
- open VS Code
- start long-running foreground processes
- start or remove the devcontainer project
- delete user data or running containers
- assume it runs only once
- rely on exporting variables back into VS Code
It may:
- create root
.local.envfrom.devcontainer/.local.env.example - create root
compose.local.ymlfrom.devcontainer/compose.local.yml.example - write
.devcontainer/.gen.envand.devcontainer/compose.local.gen.ymlgenerated runtime values such as the container hostname and numeric runtime user - update generated local env values when their content changed
- compute host UID
- compute a stable project name
- compute the host short name
- create local cache/config directories
- validate required host tools and print clear errors
initializeCommand cannot export environment variables back into the already
running VS Code process. Values produced by initialize.sh should therefore be
written to files, not expected to appear as ${localEnv:...} values.
Preferred pattern:
initialize.shwrites root.local.env.initialize.shwrites rootcompose.local.yml.initialize.shwrites.devcontainer/.gen.env.initialize.shwrites.devcontainer/compose.local.gen.yml..devcontainer/docker-compose.ymlreads.gen.envand../.local.envwithenv_file.devcontainer.jsonincludescompose.local.gen.ymland../compose.local.yml..devcontainer/docker-compose.ymlowns the workspace and parent Git mounts.devcontainer.jsonuses${localEnv:USER}forremoteUserandcontainerUser.- Container-side tools read normal environment variables from Compose.
Example Compose shape:
services:
workspace:
env_file:
- path: .gen.env
required: false
- path: ../.local.env
required: falseAvoid generating values for devcontainer.json to read through
${localEnv:...}. The Dev Containers extension may have captured host
environment variables before initializeCommand writes anything, and clients may
need a restart to pick up changed host environment.
Official Dev Container lifecycle behavior relevant to this kit:
initializeCommandruns on the host machine.- It can run during container creation and on later starts.
- It may run more than once in a session.
- String commands run through
/bin/sh. - Array commands execute directly without a shell.
- If a lifecycle command fails, later lifecycle commands are skipped.
Implementation consequence: initialize.sh must fail only for real blockers and
must produce clear diagnostics. Best-effort setup should not make the whole
container unusable unless the missing state is required.
VS Code and the devcontainer CLI do not behave identically in every path.
Known problem areas from upstream discussions include:
devcontainer buildmay not runinitializeCommandlike VS Code does.- CLI rebuild ordering around existing containers can differ from VS Code.
initializeCommandhas been observed to run on reopen, not only first create.
Tests should therefore cover the behavior the kit relies on directly. A build test alone is not enough to prove host initialization behavior.
The kit should be tested as close to the real workflow as possible with the
Dev Containers CLI. Prefer devcontainer up over only validating files or
running docker compose directly, because devcontainer up exercises the same
configuration model that VS Code uses.
Primary smoke command:
npx --yes @devcontainers/cli up --workspace-folder <fixture-repo>If the CLI is installed globally, the shorter form is equivalent:
devcontainer up --workspace-folder <fixture-repo>The smoke fixture should look like a real consuming repository:
- repository root contains project files
.devcontainer/contains this kit- VS Code/devcontainer configuration is read from
.devcontainer/devcontainer.json initializeCommandruns through the devcontainer lifecycle, not by calling the initializer directly as the only assertion
Recommended test layers:
- fast contract tests for generated files and static configuration
devcontainer read-configurationfor schema/config resolutiondevcontainer upfor the real lifecycle, includinginitializeCommanddevcontainer execordocker execfor observable checks inside the running workspace service
The tests should verify at least:
- root
.local.envis created or preserved byinitializeCommand .devcontainer/.gen.envand.devcontainer/compose.local.gen.ymlare regenerated byinitializeCommand- the generated container hostname matches host, repo, and branch context
- the generated runtime user and group match the host UID
- repeated
devcontainer upruns stay safe and idempotent - no VS Code window is opened by kit scripts
- no project-specific names such as
CODEGEIST_*are required - the workspace service starts and accepts a basic command
- generated local files are not accidentally tracked
Use direct docker compose tests only for focused Compose behavior that the CLI
does not expose clearly. Do not treat a plain Compose test as a substitute for a
full devcontainer smoke test.
Expected target layout when consumed as a subtree at .devcontainer/:
.local.env # generated, ignored by the consuming repo
compose.local.yml # generated, ignored by the consuming repo
.devcontainer/
.gen.env # generated, ignored by the kit
compose.local.gen.yml # generated, ignored by the kit
devcontainer.json
docker-compose.yml
Dockerfile
entrypoint.sh
initialize.sh
.local.env.example
compose.local.yml.example
tests/
Roles:
devcontainer.jsonis the VS Code entrypoint.initialize.shperforms host-side setup forinitializeCommand.docker-compose.ymldefines the workspace runtime and the root/worktree bind mounts.Dockerfilebuilds the workspace image.entrypoint.shruns inside the container..local.env.exampledocuments root.local.envvalues.compose.local.yml.exampleis copied to rootcompose.local.yml..gen.envexposes generated runtime values to the container.compose.local.gen.ymlsets generated Compose-only values such as hostname.tests/verifies host initialization and container configuration contracts.
The generated root files intentionally live one directory above .devcontainer/.
This keeps machine-local state out of the vendored subtree so many consuming
projects can share the same .devcontainer kit without modifying those subtree
files.
The generated .gen.env and compose.local.gen.yml files intentionally live in
.devcontainer/ because they are kit-owned dynamic runtime state. They are
rewritten by initialize.sh; users should put manual overrides in root
.local.env and compose.local.yml instead.
launch.sh is not part of the required normal VS Code workflow. If a launcher
is kept for compatibility or manual convenience, it should be documented as
optional and must not be required by devcontainer.json.
The kit provides BRANCH for worktree selection through the Dev Containers
lifecycle. VS Code is still launched from the repository root, but the container
workspace is /workspace; BRANCH changes which checkout is mounted there.
Use it from a consuming repository where this kit is vendored at
.devcontainer/:
BRANCH=develop0 code .During initializeCommand, initialize.sh creates or reuses
.worktrees/<branch>, initializes any consuming-repository submodules when the
repository defines them, creates root .local.env from
.devcontainer/.local.env.example when missing, and links the worktree root
.local.env back to the main root file. Without BRANCH, /workspace is the
repository root. With BRANCH, /workspace is the selected worktree.
When BRANCH is set, docker-compose.yml uses the same BRANCH value to mount
../.worktrees/<branch> at /workspace. The second bind mount always mounts
the repository root at ${PWD}; start VS Code or Dev Containers CLI from the
repository root so that linked-worktree Git metadata resolves inside the
container. compose.local.yml remains available for local overrides, but it does
not own the workspace or parent Git mounts.
Changing BRANCH after a container already exists does not automatically remount
the running container. Rebuild or remove the existing devcontainer first, then
start VS Code again with the new BRANCH value.
Use code-open as the real editor entrypoint from a consuming repository root.
It refuses to run outside Git, from a Git subdirectory, or without
.devcontainer/devcontainer.json.
task code-open
task code-open -- develop0Use the manual reality test when you want to verify that same entrypoint through
a temporary consuming repository. It creates a temporary Git repository, copies
this kit into .devcontainer/, and then invokes the real code-open task
against that fixture.
task code-open-testTo verify branch selection through the normal root-start flow:
task code-open-test -- develop0BRANCH=develop0 task code-open-test is still accepted when an environment
variable is more convenient. The task-argument form records BRANCH in the
workspace .devcontainer/.env, which makes branch selection stable when
code . is forwarded to an already running VS Code process.
The temporary fixture is intentionally left on disk because VS Code is opened against it.
Generated or machine-local files should not be committed.
Typical examples:
.local.env.devcontainer/.envcompose.local.yml- generated runtime metadata
- editor state
- tool caches
If a value must affect the runtime, write it to a local env file or another documented generated file that Compose reads explicitly.
The kit should remain reusable across repositories, while currently preserving the copied Codegeist/planner image contents.
Avoid:
- product-specific names
- repository-specific paths
- assumptions about root
start.sh - hard-coded nested dependency names outside
.devcontaineritself - committed root
.env,.local.env, orcompose.local.yml - automatic VS Code window management
Prefer:
- stable Dev Container spec properties
- static
devcontainer.jsonwhere possible - local generated env files for host-dependent values
- explicit optional extension points for consuming repositories
- tests that exercise the exact documented contract
- Docker-in-Docker is enabled by default because it is part of the current tested smoke path. A future variant may make it opt-in if consuming projects need a lighter image.
- SSH helper behavior is intentionally left to consuming repositories for now.
- The base image currently keeps the copied Codegeist/planner toolchain intact; future work can split generic tools from project-specific features when there is a concrete consumer need.