Setup Devcontainer
Generates .devcontainer/ from the templates in templates/: one
independent Tool Container per selected AI CLI — Claude Code, Codex,
Antigravity, and/or GitHub Copilot — instead of a single shared container
bundling every tool together. See
CONTEXT.md for the vocabulary used throughout this skill
(Tool Container, Shared Container, Collision, Concurrent Workspace, Private
Checkout, Local Checkout, Shared Checkout, Cross-Container Leakage).
- Shared base image (
base.Dockerfile) — Node.js, the GitHub CLI, and a
fixed vscode user/UID/GID, built once and reused (via Docker's own layer
sharing) across every Tool Container instead of reinstalled per tool.
Rebuilt and retagged only when its rendered content changes, always with
the user's confirmation before bumping the version.
- One Tool Container per selected tool — its own Dockerfile (extending
the shared base),
devcontainer.json, post-create script, and Compose
service. Fully isolated: a permission grant, config volume, or install step
for one tool never reaches another's container. This skill builds and tags
each tool's image itself (docker build, same as the base image) and the
Compose service references that pre-built tag via image:, never build:
— VS Code's Dev Containers CLI is known to pass --pull when it builds a
Compose service itself, which forces Docker to try re-resolving any
locally-built image referenced via FROM (including our own shared base)
from a registry, and fails hard since it was never pushed anywhere. Pre-
building ourselves means VS Code never has a build step to run at all for
these services — just an already-present image to start.
- Private Checkout — every Tool Container clones its own copy of the repo
from
origin into its own named volume (via onCreateCommand, before
postCreateCommand ever runs), instead of bind-mounting the host's
checkout. No Tool Container can read another's uncommitted work, unpushed
branches, or claude --worktree worktrees — the isolation docker-compose.yml
and devcontainer.json already gave each tool's compute now extends to its
filesystem too. Doesn't auto-sync with origin or any other Tool
Container — git fetch/pull manually; post-attach.sh prints a static
reminder of this on every attach.
- Local Checkout — a Private Checkout with no GitHub
origin at all: for
a brand-new or deliberately local-only project, with no resolvable repo
and no GH_TOKEN requirement. Chosen once per repo, at step 1, instead of
naming an existing GitHub repo; every Tool Container in that run gets one.
Initializes to a local git init (default) or a genuinely bare workspace,
by a separate yes/no answer — never automatic. Connectable to a real
GitHub repo later with no skill-level regeneration at all, just plain git
(see "Connecting a Local Checkout to a real GitHub repo").
- Concurrent Workspace — every Tool Container is a service in the same
docker-compose.yml, each with its own Private Checkout. Opening two
tools' containers in two separate VS Code windows runs them side by side,
with no shared on-disk state between them.
- SSH layer — deploy-key/signing-key automation for agent-driven
git push and signed commits. Optional per tool, addable to any tool after
the fact without touching that tool's existing files. Each SSH-enabled Tool
Container registers and owns its own key pair — not shared with any other
tool, so a compromised or runaway agent in one container can't read the
key material another container's git push/commit signing depends on.
- YOLO alias — a shell alias for fast, unattended iteration, named after
the tool's actual CLI invocation, not its folder name:
claude-yolo,
codex-yolo, agy-yolo (Antigravity's binary is agy, not antigravity),
copilot-yolo. Optional per tool — some developers don't want a
no-holds-barred agent available inside a given Tool Container at all.
- Container identity banner — every Tool Container prints a one-line
banner naming itself (e.g.
── Claude Code Tool Container ──) at the top
of every new terminal, so it's always obvious which CLI's container a
given shell belongs to. Every non-Copilot Tool Container also disables VS
Code's own built-in terminal.integrated.initialHint terminal hint via
customizations.vscode.settings — that hint suggests "Type copilot to
use Copilot CLI" based on the local VS Code window's Copilot/Chat
entitlement state, not on what's actually installed in the attached
container, so left enabled it misleadingly nudges toward Copilot even
inside, say, a Claude Code Tool Container. Left enabled only in Copilot's
own Tool Container, where the suggestion happens to be correct.
1. Detect the target repo
git remote get-url origin
Parse owner/repo from it (works for both git@github.com:owner/repo.git and
https://github.com/owner/repo forms) — this is {{REPO_SLUG}}. {{REPO_NAME}} is the repo
part alone, used in volume names and SSH key titles. {{LOCAL_CHECKOUT}} is "false".
If there's no origin remote yet, ask one question: "Does a GitHub repository already exist for
this project? If yes, name it (owner/repo) — Tool Containers will clone from there, and
GH_TOKEN will be required. If no, or you're not ready to connect yet, Tool Containers start as
a local-only Local Checkout — connectable to GitHub later (see 'Connecting a Local Checkout
to a real GitHub repo')."
- Named an existing repo: resolve
{{REPO_SLUG}}/{{REPO_NAME}} from it, same as the
existing-origin case above. The named repo must already exist on GitHub — cloning it is what
populates each Tool Container's workspace. {{LOCAL_CHECKOUT}} is "false".
- Local Checkout:
{{REPO_SLUG}} stays empty. {{REPO_NAME}} falls back to the local working
directory's basename (basename "$(pwd)"), sanitized to Docker's naming rules (lowercase,
invalid characters replaced with -) — state the resolved name back to the user as part of
your summary; don't decide it silently. {{LOCAL_CHECKOUT}} is "true". Also ask, as a
separate yes/no question: "Initialize this workspace with git init?" Record the answer as
{{LOCAL_CHECKOUT_GIT_INIT}} ("true"/"false"). If yes, resolve {{GIT_DEFAULT_BRANCH}}
from the host's git config --global init.defaultBranch — substitute it as an empty string if
the host has none configured (the baked-in script checks for that emptiness at runtime to
decide whether to pass --initial-branch to git init at all; always substitute this
placeholder with something, even "", same as every other placeholder — never leave the
literal {{GIT_DEFAULT_BRANCH}} token in the written file).
Done when you have {{REPO_SLUG}} (possibly empty), {{REPO_NAME}}, and {{LOCAL_CHECKOUT}} —
plus, if Local Checkout, {{LOCAL_CHECKOUT_GIT_INIT}} and, if that's yes, {{GIT_DEFAULT_BRANCH}}.
2. Discover existing Tool Containers
test -f .devcontainer/base.Dockerfile && echo "has base image"
for t in claude-code codex antigravity copilot; do
test -f ".devcontainer/$t/devcontainer.json" && echo "$t exists"
done
test -f .devcontainer/devcontainer.json && echo "LEGACY Shared Container detected"
.devcontainer/devcontainer.json exists at the top level (no
base.Dockerfile, no per-tool subfolders): this is the old, single Shared
Container from before this skill split into Tool Containers. There is no
in-place converter. Tell the user to remove .devcontainer/ entirely and
re-run this skill fresh — do not attempt to generate anything on top of it.
base.Dockerfile exists and one or more <tool>/devcontainer.json exist:
this repo already has Tool Container(s) from a prior run of this skill.
Skip to Adding another Tool Container
later for any newly-requested tool,
and to Adding SSH to a tool later if the
request is only to add SSH to an already-existing tool. Do not regenerate
already-existing tools' files — except: for each already-existing
tool, check whether it predates Private Checkout
(jq -e '.onCreateCommand' .devcontainer/<tool>/devcontainer.json; empty
or an error means it does). If any do and the user hasn't already asked
to migrate them, tell them these tools are still on the old shared
bind-mounted model and point them at Migrating a Tool Container to
Private Checkout — don't
migrate silently as a side effect of an unrelated request.
- Neither exists: fresh setup, continue to step 3.
For any tool whose Tool Container you're about to generate or reopen, also
check for a leftover container from an unrelated prior setup of this same
tool in this same workspace folder. The Dev Containers CLI labels containers
by devcontainer.local_folder=<absolute workspace path> and, for
Compose-based containers, com.docker.compose.service=<tool> — independent
of what the current config says, so a stale container survives even after
its old config was deleted or never committed, and reopening will silently
reuse it instead of building fresh:
docker ps -a --filter "label=devcontainer.local_folder=$(pwd)" --filter "label=com.docker.compose.service=<tool>" --format '{{.ID}} {{.Image}}'
If this returns anything, warn the user before they reopen that tool: a
container built from a different setup won't have the vscode user this
setup expects, and reopening fails with a cryptic unable to find user vscode: no matching entries in passwd file that gives no hint the real
cause is the leftover container, not the new config. Offer to remove it
(docker rm -f <id>), but don't remove it without asking — it may hold
state the user still wants. Skip this check entirely if docker isn't
installed or isn't running; note that it couldn't be checked rather than
failing the rest of the skill over it.
Done when you know which of the four tools already have a Tool Container,
whether a legacy Shared Container needs a migration message instead of
generation, and whether any tool about to be (re)opened has a stale leftover
container to warn about.
3. Ask tool selection and per-tool options
Ask the user which tools they want (skip any already answered in their
request, and skip any tool that already has a Tool Container per step 2 —
those go through the append-flows instead):
- Claude Code, OpenAI Codex CLI (
codex), Google Antigravity CLI
(agy), GitHub Copilot CLI (copilot) — a multi-select. Pre-check
Claude Code as the common case; it's fully optional and symmetric with the
other three, just recommended by default.
If {{LOCAL_CHECKOUT}} is "true" (step 1), skip the SSH Layer question below entirely for
every tool in this run — there's no GitHub repo yet to register deploy/signing keys against. Tell
the user why it's not being offered: "SSH layer isn't available yet — this repo has no GitHub
connection; add it once one exists (see 'Connecting a Local Checkout to a real GitHub repo')."
The YOLO alias and skills-sync questions are unaffected — both are independent of git/GitHub
remote status, ask them normally.
For each newly selected tool, ask independently:
- SSH Layer (skip if
{{LOCAL_CHECKOUT}} is "true", per above): Does this repo need
agent-driven git push and signed commits from this tool's Tool Container? (Adds
deploy-key/signing-key automation — this tool registers and owns its own key pair, not shared
with any other Tool Container that also has it enabled.)
- YOLO alias: Should this tool get its
-yolo alias for fast, unattended
iteration — claude-yolo, codex-yolo, agy-yolo, or copilot-yolo,
matching the tool's actual CLI command, not its folder name (Antigravity's
is agy-yolo, never antigravity-yolo)? (Note for Copilot: copilot-yolo
just echoes instructions to manually type /sandbox enable inside the
session.) Each tool's -yolo alias reduces its permission checkpoints for
faster iteration; the exact tradeoff differs per tool — see the README's
"YOLO aliases" section for specifics.
Record these answers — they decide which template variants steps 5–6 use.
Both are addable later per tool without redoing anything already generated
(see the append-flows below).
4. Resolve placeholders
{{REPO_SLUG}}, {{REPO_NAME}} — from step 1.
{{GIT_EMAIL_DEFAULT}}, {{GIT_NAME_DEFAULT}} — run git config --global user.email and
git config --global user.name on the host. If either is unset, don't invent a default: use
${GIT_USER_EMAIL:?Set GIT_USER_EMAIL in .devcontainer/.env} (no -default fallback) in
the base post-create script instead of the :- form, and drop the parenthetical in
.env.example's comment.
{{SKILLS_SOURCES_COMMANDS}} (any selected tool — Claude Code, Codex, Antigravity, and Copilot
all support this identically) — ask the user one combined question, asked once regardless of
how many of the four tools are selected: sync AI-agent skills into every selected Tool Container
automatically on every start? Two ready-made suites are available: mattpocock/skills (a broad
general-purpose skill baseline) and ken-guru/skills (this collection — includes this very
Skill, useful if a layer needs adding later from inside the container). For each, ask yes/no. In
the same prompt, also invite the user to name any other individual skills they want, in
owner/repo/skill-name form (e.g. anthropics/skills/frontend-design) — mixing and matching
freely, including picking specific skills out of the two suites above instead of taking them
whole. If the user just wants both suites in full, saying yes to both and skipping the rest is
the fast path. The same answer applies identically to every selected tool — this question is
about which skills, not which tool; the tool-specific part is handled entirely by the
rendering step below, invisibly to the user.
Validate live in this same conversation before rendering anything, for every individually
named skill pick, from any source including the two named defaults (a whole-suite accept
needs no validation — --skill '*' can't typo): npx -y skills add <source> --list (or -l)
lists what that source actually contains. If the source doesn't resolve, or a named skill isn't
in the list, tell the user and re-ask rather than rendering a broken command — this is the
intended defense against typos, since skills.sh's own API requires authentication this context
doesn't have, so the CLI's own listing is used instead. This applies even to a skill picked out
of mattpocock/skills or ken-guru/skills individually rather than taken as a whole suite —
those two sources are pre-named, not pre-validated for every skill inside them.
Render one block per selected tool, since the underlying npx skills CLI installs to a
specific agent's own skills directory, not a shared one — each tool's block is identical to
every other's except for its -a value, taken from this fixed mapping:
| Tool |
-a value |
| Claude Code |
claude-code |
| Codex |
codex |
| Antigravity |
antigravity |
| Copilot |
github-copilot |
Within each tool's block, render one line per distinct source, in the order first
mentioned:
- A source accepted as a whole suite (either of the two defaults, or any other source the user
chose to take in full):
npx -y skills add <source> --skill '*' -a <tool's agent name> -y --copy -g.
- A source with only individual picks (not accepted as a whole suite):
npx -y skills add <source> --skill '<name1>' --skill '<name2>' ... -a <tool's agent name> -y --copy -g, listing
only that source's picked skills.
- A source both accepted as a whole suite and separately named for an individual pick:
render only the whole-suite line for it — the individual pick is redundant, not
contradictory, so drop it silently rather than flagging it back to the user.
Use tool T's resulting multi-line block everywhere {{SKILLS_SOURCES_COMMANDS}} appears inside
tool T's own template(s) — never mix one tool's -a value into another tool's file. Also
record, for {{SKILLS_SOURCES_SUMMARY}} below: only naming a trusted source matters here —
-y --copy -g installs and re-syncs that source's skills unattended on every container start,
with no per-skill review step, and an installed skill's instructions can influence what the
agent does inside the container. Tell the user this caution as part of asking the question, not
as an afterthought.
{{SKILLS_SOURCES_SUMMARY}} — the chosen sources and picks as a short human-readable list for
the README's prose (e.g. `mattpocock/skills` (full), `ken-guru/skills` (full), `anthropics/skills/frontend-design`) — distinguishing whole-suite sources from individual
picks, since the README's "Automatic skill sync" section states both. If the user named nothing
(declined both defaults and no individual picks), render as "none configured."
{{SELECTED_TOOLS_SUMMARY}} — a short human-readable list of the tools selected across this
run and any already-existing ones (e.g. Claude Code, Codex), for the README's prose.
{{TOOL_DISPLAY_NAME}} — the tool's display name for the identity banner (step 6), matching its
devcontainer.json name field exactly: claude-code → Claude Code, codex → Codex,
antigravity → Antigravity, copilot → Copilot.
{{TOOL_NAME}} — the tool's own folder/service slug (claude-code, codex, antigravity,
copilot — the same value as <tool> throughout this skill). Only needed when substituting
templates/post-create-ssh-block.sh (step 6), which is
shared across every tool and needs it to name that tool's own SSH deploy/signing keys and
volume distinctly from every other tool's.
5. Build or reuse the shared base image
{{BASE_IMAGE_VERSION}} is the bare version string (v1, v2, ...);
{{BASE_IMAGE_TAG}} is the full image reference built from it:
{{REPO_NAME}}-tool-container-base:{{BASE_IMAGE_VERSION}}.
- If
.devcontainer/base.Dockerfile doesn't exist yet: write it from
templates/base.Dockerfile, substituting
{{REPO_SLUG}}, {{LOCAL_CHECKOUT}}, {{LOCAL_CHECKOUT_GIT_INIT}}, and
{{GIT_DEFAULT_BRANCH}} (all from step 1) — the baked-in Private
Checkout clone script needs them to know whether to clone, git init, or
leave the workspace bare. Set {{BASE_IMAGE_VERSION}} to v1.
Build it:
docker build -t {{REPO_NAME}}-tool-container-base:v1 -f .devcontainer/base.Dockerfile .devcontainer.
Record the version and a content hash of the file
(sha256sum .devcontainer/base.Dockerfile) into
.devcontainer/.base-image-version as <version> <sha256>.
- If it already exists: compute the sha256 of
templates/base.Dockerfile's current rendered
content and compare it to the hash recorded in
.devcontainer/.base-image-version.
- Unchanged: skip rebuilding. Use the version already recorded in
.devcontainer/.base-image-version as {{BASE_IMAGE_VERSION}}.
- Changed: tell the user the shared base layer's template has changed
and this would affect every Tool Container that extends it, and ask
whether to bump the version (e.g.
v1 → v2) and rebuild. Never bump or
rebuild silently.
- Confirmed: overwrite
.devcontainer/base.Dockerfile, build and tag
the bumped version, update .devcontainer/.base-image-version with the
new version and hash, and use the new version as
{{BASE_IMAGE_VERSION}}. A version bump also forces every already-
generated tool's image to rebuild in step 6 (their tags are versioned
identically to the base — see below), even though their own
Dockerfiles didn't change.
- Declined: leave
.devcontainer/base.Dockerfile and the recorded
version/hash untouched, and use the existing version as
{{BASE_IMAGE_VERSION}} for this run's new tool(s).
Done when .devcontainer/base.Dockerfile exists, docker image inspect {{REPO_NAME}}-tool-container-base:{{BASE_IMAGE_VERSION}} succeeds, and
.devcontainer/.base-image-version records that exact version alongside a
hash matching the file actually on disk.
6. Generate the compose file and each selected tool's folder
For each newly selected tool (claude-code, codex, antigravity, or copilot):
.devcontainer/<tool>/Dockerfile ← templates//Dockerfile, substitute
{{BASE_IMAGE_TAG}} with the tag resolved in step 5.
Build and tag this tool's own image:
docker build -t {{REPO_NAME}}-<tool>:{{BASE_IMAGE_VERSION}} -f .devcontainer/<tool>/Dockerfile .devcontainer.
The Compose service references this exact pre-built tag via image: (see below) — never
build: — specifically so VS Code's Dev Containers CLI never has a build step to run for these
services at all. This matters because that CLI is known to pass --pull when it does build a
Compose service, which forces Docker to try re-resolving any locally-built image referenced via
FROM (our shared base) from a registry — and since the base was never pushed anywhere, that
pull fails outright and aborts the whole "Reopen in Container" attempt. Pre-building ourselves
sidesteps the bug entirely rather than working around it.
.devcontainer/<tool>/devcontainer.json:
use templates//devcontainer.json (or
templates//devcontainer.with-ssh.json if this tool's SSH
answer was yes), substitute {{REPO_NAME}}, and write it. Both variants carry
onCreateCommand, which runs /usr/local/bin/clone-checkout.sh (baked into
the base image in step 5) to create this tool's Private Checkout — a fresh
git clone into this tool's own named volume, not the host's checkout.
.devcontainer/<tool>/post-create.sh — generated by
scripts/render-tool-container.sh, which assembles the fixed
7-block template order (base identity setup, the identity banner, the shared install-cli skeleton,
this tool's own install block, its yolo-alias block, and the SSH block pair) deterministically,
chmod'ing the result +x:
scripts/render-tool-container.sh \
--tool <tool> --tool-name <tool> --tool-display-name "<TOOL_DISPLAY_NAME>" \
--repo-name "{{REPO_NAME}}" --repo-slug "{{REPO_SLUG}}" \
--out .devcontainer/<tool>/post-create.sh \
[--ssh] [--yolo] \
[--git-email-default "<value>"] [--git-name-default "<value>"]
Pass --ssh / --yolo only when this tool's SSH / yolo answers (step 3) were yes. Pass
--git-email-default/--git-name-default only when step 4 resolved an actual host default for
that field — omit the flag entirely (don't pass an empty string) when step 4's :?-required case
applies, since render treats "flag absent" as the signal to emit the hard-require line rather than
a -default fallback. Verify the result with
scripts/verify-tool-container.sh, passing it the exact same
flags:
scripts/verify-tool-container.sh \
--file .devcontainer/<tool>/post-create.sh \
--tool <tool> --tool-name <tool> --tool-display-name "<TOOL_DISPLAY_NAME>" \
--repo-name "{{REPO_NAME}}" --repo-slug "{{REPO_SLUG}}" \
[--ssh] [--yolo] [--git-email-default "<value>"] [--git-name-default "<value>"]
The SSH block (templates/post-create-ssh-block.sh) never
fails the build: an under-scoped or missing GH_TOKEN (or an unset DEVCONTAINER_HOST) degrades
to skipping the rest of the SSH setup and recording why in ~/.ssh/.ssh-setup-skipped, rather than
aborting postCreateCommand — which would otherwise also skip every block concatenated after it.
Registers this tool's own deploy/signing key pair on its own {{REPO_NAME}}-<tool>-ssh volume — no
longer one shared pair per repo — and, on first run against a repo that still has the old
shared-title deploy key registered, auto-removes it. Immediately after it, the warnings block
(templates/post-create-warnings-block.sh) appends a
snippet to ~/.bashrc that surfaces any of this SSH layer's three standing warnings (setup
skipped, signing key unregistered, deploy key missing on GitHub) at the top of every new terminal,
not just once at attach — postCreateCommand/postAttachCommand each fire once per rebuild/attach,
not per terminal tab.
.devcontainer/<tool>/post-start.sh (every selected tool gets one — Claude Code, Codex,
Antigravity, and Copilot all sync skills identically) ←
templates//post-start.sh, substituted with that tool's own
{{SKILLS_SOURCES_COMMANDS}} block from step 4 (using that tool's -a value, never another
tool's). Always rewritten (even on an already-existing Tool Container) to ensure skill sync
stays current.
Make the new .devcontainer/<tool>/*.sh files executable: chmod +x .devcontainer/<tool>/*.sh.
If Codex was newly selected, append the following caveat to .devcontainer/README.md (under
a "Gotchas" or "CLI Notes" section, creating one if it doesn't exist):
Codex Linux sandbox: Codex's Tool Container carries capAdd/securityOpt grants so
Codex's own Bubblewrap sandbox (codex-yolo's --sandbox workspace-write) can actually create
its namespace — scoped to Codex's own container only, never any other tool's. This skill does
not include a runtime health probe to verify the sandbox is confining anything on your specific
host — if codex-yolo ever behaves as though unsandboxed, that's the first thing to check by
hand.
If Antigravity was newly selected, append the following caveat to .devcontainer/README.md
(same section):
Antigravity CLI Auth: agy stores auth in the system keyring, not a file. The
.antigravity volume mount will not persist its login across rebuilds in a bare container. You
may need to re-auth agy each time, or add a keyring daemon yourself later if that gets
annoying.
If any newly or already-selected tool has the SSH answer yes:
.devcontainer/.env.example gets templates/env.ssh-block.example appended, idempotently, and its GH_TOKEN comment gets: Required permissions: Administration (read/write) — needed to manage deploy keys — plus whatever else you use gh for.
scripts/patch-if-absent.sh append .devcontainer/.env.example "DEVCONTAINER_HOST=your-hostname-here" templates/env.ssh-block.example
.devcontainer/README.md gets templates/README.ssh-block.md appended, idempotently, and the baseline template's closing "SSH deploy key and signing key automation — Not set up here" section is deleted (superseded by the real section):
scripts/patch-if-absent.sh append .devcontainer/README.md "## SSH deploy key and signing key" templates/README.ssh-block.md
scripts/patch-if-absent.sh delete-section .devcontainer/README.md "## SSH deploy key and signing key automation"
If step 5 bumped {{BASE_IMAGE_VERSION}} this run (the Confirmed branch), rebuild and
retag every already-existing tool's image too, at the new version — same build command as
above, run again for each tool that already has a Tool Container even though none of its own
files (Dockerfile, devcontainer.json, post-create.sh) need rewriting. Their Compose service's
image: reference is versioned identically to the base, so without this their tag would point at
an image that no longer exists.
Always (every run, regardless of which tools are new):
.devcontainer/docker-compose.yml ← rebuilt from templates/docker-compose.yml: concatenate every currently-selected tool's templates//compose-fragment.yml (substituted {{REPO_NAME}} and {{BASE_IMAGE_VERSION}}) under services:, and list one {{REPO_NAME}}-<tool>-config: volume line and one {{REPO_NAME}}-<tool>-checkout: volume line per selected tool, plus one {{REPO_NAME}}-<tool>-ssh: volume line per SSH-enabled tool (one per tool now, not one shared line for the whole repo), under volumes:. The checkout volume backs that tool's Private Checkout — the named volume onCreateCommand's clone script populates, replacing the old shared bind mount. Safely rebuild, don't hand-edit around: since this file only ever holds what this skill generated, it's fine to regenerate it wholesale from the current set of selected tools each run — never drop an already-existing tool's service just because this particular run didn't ask about it again.
.devcontainer/post-attach.sh ← templates/post-attach.sh, substituted. Every selected tool gets this and its postAttachCommand wiring — not just SSH-enabled ones — since it carries Private Checkout's staleness hint (a static reminder to git fetch, shown on every attach) unconditionally; the SSH-specific logic inside guards itself when that particular tool's SSH layer isn't enabled. Write once (identical content across every tool); chmod +x it.
.devcontainer/.env.example ← templates/env.baseline.example, substituted, if it doesn't already exist.
.devcontainer/README.md ← templates/README.baseline.md, substituted, if it doesn't already exist. If it already exists, update {{SELECTED_TOOLS_SUMMARY}}'s rendered value in place, and backfill the "Automatic skill sync" and "YOLO aliases" sections (matching heading) from the current template if either is missing, inserting each at the same position it holds in the current template — a README from before these sections existed should end up with them added, not left stale. Render each with current values regardless of what's configured this run (e.g. {{SKILLS_SOURCES_SUMMARY}} renders as "none configured" when no source is set up), the same as the rest of the baseline template already does for tools that aren't selected. If a section is already present, leave it as-is — this backfill only inserts what's missing, it doesn't reconcile wording drift in a section that already exists. Render each candidate section's current content to a scratch file first (substituted, same as the rest of this step), then, in this order (YOLO aliases before Automatic skill sync, so Automatic skill sync's anchor is guaranteed present even backfilling into a README old enough to be missing both):
scripts/patch-if-absent.sh insert-before .devcontainer/README.md "## YOLO aliases" "## Gotchas fixed here (and why)" <rendered-yolo-aliases-section>
scripts/patch-if-absent.sh insert-before .devcontainer/README.md "## Automatic skill sync" "## YOLO aliases" <rendered-skill-sync-section>
Add .devcontainer/.env to .gitignore if it isn't already ignored.
Remove .claude/worktrees/ from .gitignore if a prior run of this skill added it (check for
the exact line and delete it; leave every other line untouched). It existed only because
claude --worktree used to create isolated git worktrees directly inside the bind-mounted
/workspace, which the host saw as untracked noise in git status since the mount was the same
filesystem, not container-isolated. Now that every Tool Container has its own Private Checkout,
a worktree created inside one lives only in that tool's own volume — invisible to the host, so
nothing to gitignore. This is a no-op for a fresh repo (the line was never added); for a repo
migrating from before Private Checkout, this actively cleans it up.
Done when every file above exists, every tool's devcontainer.json parses as valid JSON
(jq empty .devcontainer/<tool>/devcontainer.json) with a non-null onCreateCommand
(jq -e '.onCreateCommand' .devcontainer/<tool>/devcontainer.json), docker-compose.yml parses
as valid YAML with exactly one service per selected tool and one {{REPO_NAME}}-<tool>-checkout:
volume line per selected tool (plus one {{REPO_NAME}}-<tool>-ssh: line per SSH-enabled tool),
every selected tool's image actually exists at the tag its Compose service references
(docker image inspect {{REPO_NAME}}-<tool>:{{BASE_IMAGE_VERSION}} succeeds for each), the
clone-checkout script landed executable in the base image
(docker run --rm {{REPO_NAME}}-tool-container-base:{{BASE_IMAGE_VERSION}} test -x /usr/local/bin/clone-checkout.sh),
every with-ssh devcontainer.json's mounts entry references that tool's own SSH volume (not
another tool's, and not a stale shared name), no {{...}} placeholder remains in any written file
(grep -rn '{{' .devcontainer/), and every selected tool's post-create.sh passes
scripts/verify-tool-container.sh for the exact flags it was
rendered with (stronger than the blanket {{ grep above: also checks the right blocks landed for
this tool's ssh/yolo combination and that the git-identity lines took the right shape).
Runtime behavior — the clone actually succeeding, SSH keys actually registering, the staleness
hint actually appearing — is intentionally not part of this per-run check; it can only be
confirmed by actually attaching a container, same scope boundary this checklist already draws for
skill-sync and yolo-alias behavior. Cross-container isolation (two Tool Containers' Private
Checkouts genuinely independent of each other) is a one-time sanity check worth doing yourself the
first time you use more than one tool in a repo, not something to re-verify on every subsequent
setup-devcontainer run — see the README's "Running tools concurrently" section.
7. Report next steps
Tell the user, adapted to which tools were selected and which have SSH/yolo:
- Install Docker Desktop and the Dev Containers VS Code extension.
- Copy
.devcontainer/.env.example to .devcontainer/.env and fill in GH_TOKEN{{, and
DEVCONTAINER_HOST (run hostname) if any tool's SSH layer is present}}.
- For each selected tool: reopen the repo in that Tool Container
(Dev Containers: Reopen in Container, pick the tool's name).
- Run that tool's CLI and log in.
- {{If more than one tool was selected: to use two at once, open a second
VS Code window (File > New Window) on this same repo and reopen it in a
different tool's container there — see the README's "Running tools
concurrently" section.}}
- {{If any tool has the SSH layer present: on attach,
post-attach.sh
prints a public key — paste it into github.com/settings/ssh as a Signing
Key, then touch ~/.ssh/.signing-key-registered. Do this once per
SSH-enabled Tool Container — each tool has its own key pair and its own
registration marker now, so this doesn't carry over between tools.}}
- {{For each tool with its YOLO alias present: a new shell in that tool's
container has its alias available for fast, unattended iteration —
claude-yolo, codex-yolo, agy-yolo, or copilot-yolo (matching the
tool's actual CLI command, not its folder name). See the README's "YOLO
aliases" section for what each one's permission tradeoff actually means
before using it — Antigravity's in particular needs one manual setup step
there before agy-yolo is meaningfully safe.}}
Done when the user has been told every applicable item above, adapted to which tools, SSH layer,
and YOLO aliases are present.
Adding another Tool Container later
For a repo that already has Tool Container(s) from a prior run of this skill and now wants an
additional tool. See docs/adding-tool-later.md.
Adding SSH to a tool later
For a tool that already has a Tool Container and now needs agent-driven git push / signed
commits. See docs/adding-ssh-later.md.
Migrating a Tool Container to Private Checkout
For a tool whose devcontainer.json predates Private Checkout (step 2's detection: no
onCreateCommand) — moving it from the old shared bind-mounted workspace to its own isolated
clone, opt-in per tool, never automatic. See
docs/migrating-private-checkout.md.
Connecting a Local Checkout to a real GitHub repo
For a repo generated as Local Checkout (step 1) that now has a real GitHub repository to push
to. Needs no skill-level regeneration — see
docs/connecting-local-checkout.md.
1---2name: setup-devcontainer3description: Set up isolated Claude Code, Codex, Antigravity, and/or GitHub Copilot devcontainers (Ubuntu base image, Node, GitHub CLI, persistent auth, automatic skill sync) in the current repo — one independent Tool Container per selected CLI, runnable concurrently — optionally layering on SSH deploy-key/signing-key automation for agent-driven git push and signed commits. Use when the user wants to add a devcontainer for one or more AI CLIs, add a new AI CLI to an existing devcontainer setup, add SSH key automation to a Tool Container that already exists, migrate an old Tool Container to Private Checkout, or connect a Local Checkout to a real GitHub repo.4---56# Setup Devcontainer78Generates `.devcontainer/` from the templates in [templates/](templates/): one9independent **Tool Container** per selected AI CLI — Claude Code, Codex,10Antigravity, and/or GitHub Copilot — instead of a single shared container11bundling every tool together. See12[CONTEXT.md](CONTEXT.md) for the vocabulary used throughout this skill13(Tool Container, Shared Container, Collision, Concurrent Workspace, Private14Checkout, Local Checkout, Shared Checkout, Cross-Container Leakage).1516- **Shared base image** (`base.Dockerfile`) — Node.js, the GitHub CLI, and a17 fixed `vscode` user/UID/GID, built once and reused (via Docker's own layer18 sharing) across every Tool Container instead of reinstalled per tool.19 Rebuilt and retagged only when its rendered content changes, always with20 the user's confirmation before bumping the version.21- **One Tool Container per selected tool** — its own Dockerfile (extending22 the shared base), `devcontainer.json`, post-create script, and Compose23 service. Fully isolated: a permission grant, config volume, or install step24 for one tool never reaches another's container. This skill builds and tags25 each tool's image itself (`docker build`, same as the base image) and the26 Compose service references that pre-built tag via `image:`, never `build:`27 — VS Code's Dev Containers CLI is known to pass `--pull` when it builds a28 Compose service itself, which forces Docker to try re-resolving *any*29 locally-built image referenced via `FROM` (including our own shared base)30 from a registry, and fails hard since it was never pushed anywhere. Pre-31 building ourselves means VS Code never has a build step to run at all for32 these services — just an already-present image to start.33- **Private Checkout** — every Tool Container clones its own copy of the repo34 from `origin` into its own named volume (via `onCreateCommand`, before35 `postCreateCommand` ever runs), instead of bind-mounting the host's36 checkout. No Tool Container can read another's uncommitted work, unpushed37 branches, or `claude --worktree` worktrees — the isolation `docker-compose.yml`38 and `devcontainer.json` already gave each tool's compute now extends to its39 filesystem too. Doesn't auto-sync with `origin` or any other Tool40 Container — `git fetch`/`pull` manually; `post-attach.sh` prints a static41 reminder of this on every attach.42- **Local Checkout** — a Private Checkout with no GitHub `origin` at all: for43 a brand-new or deliberately local-only project, with no resolvable repo44 and no `GH_TOKEN` requirement. Chosen once per repo, at step 1, instead of45 naming an existing GitHub repo; every Tool Container in that run gets one.46 Initializes to a local `git init` (default) or a genuinely bare workspace,47 by a separate yes/no answer — never automatic. Connectable to a real48 GitHub repo later with no skill-level regeneration at all, just plain git49 (see "Connecting a Local Checkout to a real GitHub repo").50- **Concurrent Workspace** — every Tool Container is a service in the same51 `docker-compose.yml`, each with its own Private Checkout. Opening two52 tools' containers in two separate VS Code windows runs them side by side,53 with no shared on-disk state between them.54- **SSH layer** — deploy-key/signing-key automation for agent-driven55 `git push` and signed commits. Optional per tool, addable to any tool after56 the fact without touching that tool's existing files. Each SSH-enabled Tool57 Container registers and owns its own key pair — not shared with any other58 tool, so a compromised or runaway agent in one container can't read the59 key material another container's `git push`/commit signing depends on.60- **YOLO alias** — a shell alias for fast, unattended iteration, named after61 the tool's actual CLI invocation, not its folder name: `claude-yolo`,62 `codex-yolo`, `agy-yolo` (Antigravity's binary is `agy`, not `antigravity`),63 `copilot-yolo`. Optional per tool — some developers don't want a64 no-holds-barred agent available inside a given Tool Container at all.65- **Container identity banner** — every Tool Container prints a one-line66 banner naming itself (e.g. `── Claude Code Tool Container ──`) at the top67 of every new terminal, so it's always obvious which CLI's container a68 given shell belongs to. Every non-Copilot Tool Container also disables VS69 Code's own built-in `terminal.integrated.initialHint` terminal hint via70 `customizations.vscode.settings` — that hint suggests "Type `copilot` to71 use Copilot CLI" based on the local VS Code window's Copilot/Chat72 entitlement state, not on what's actually installed in the attached73 container, so left enabled it misleadingly nudges toward Copilot even74 inside, say, a Claude Code Tool Container. Left enabled only in Copilot's75 own Tool Container, where the suggestion happens to be correct.7677## 1. Detect the target repo7879```bash80git remote get-url origin81```8283Parse `owner/repo` from it (works for both `git@github.com:owner/repo.git` and84`https://github.com/owner/repo` forms) — this is `{{REPO_SLUG}}`. `{{REPO_NAME}}` is the `repo`85part alone, used in volume names and SSH key titles. `{{LOCAL_CHECKOUT}}` is `"false"`.8687If there's no `origin` remote yet, ask one question: "Does a GitHub repository already exist for88this project? If yes, name it (`owner/repo`) — Tool Containers will clone from there, and89`GH_TOKEN` will be required. If no, or you're not ready to connect yet, Tool Containers start as90a local-only **Local Checkout** — connectable to GitHub later (see 'Connecting a Local Checkout91to a real GitHub repo')."9293- **Named an existing repo**: resolve `{{REPO_SLUG}}`/`{{REPO_NAME}}` from it, same as the94 existing-`origin` case above. The named repo must already exist on GitHub — cloning it is what95 populates each Tool Container's workspace. `{{LOCAL_CHECKOUT}}` is `"false"`.96- **Local Checkout**: `{{REPO_SLUG}}` stays empty. `{{REPO_NAME}}` falls back to the local working97 directory's basename (`basename "$(pwd)"`), sanitized to Docker's naming rules (lowercase,98 invalid characters replaced with `-`) — state the resolved name back to the user as part of99 your summary; don't decide it silently. `{{LOCAL_CHECKOUT}}` is `"true"`. Also ask, as a100 separate yes/no question: "Initialize this workspace with `git init`?" Record the answer as101 `{{LOCAL_CHECKOUT_GIT_INIT}}` (`"true"`/`"false"`). If yes, resolve `{{GIT_DEFAULT_BRANCH}}`102 from the host's `git config --global init.defaultBranch` — substitute it as an empty string if103 the host has none configured (the baked-in script checks for that emptiness at runtime to104 decide whether to pass `--initial-branch` to `git init` at all; always substitute this105 placeholder with *something*, even `""`, same as every other placeholder — never leave the106 literal `{{GIT_DEFAULT_BRANCH}}` token in the written file).107108Done when you have `{{REPO_SLUG}}` (possibly empty), `{{REPO_NAME}}`, and `{{LOCAL_CHECKOUT}}` —109plus, if Local Checkout, `{{LOCAL_CHECKOUT_GIT_INIT}}` and, if that's yes, `{{GIT_DEFAULT_BRANCH}}`.110111## 2. Discover existing Tool Containers112113```bash114test -f .devcontainer/base.Dockerfile && echo "has base image"115for t in claude-code codex antigravity copilot; do116 test -f ".devcontainer/$t/devcontainer.json" && echo "$t exists"117done118test -f .devcontainer/devcontainer.json && echo "LEGACY Shared Container detected"119```120121- **`.devcontainer/devcontainer.json` exists at the top level** (no122 `base.Dockerfile`, no per-tool subfolders): this is the old, single Shared123 Container from before this skill split into Tool Containers. There is no124 in-place converter. Tell the user to remove `.devcontainer/` entirely and125 re-run this skill fresh — do not attempt to generate anything on top of it.126- **`base.Dockerfile` exists and one or more `<tool>/devcontainer.json` exist**:127 this repo already has Tool Container(s) from a prior run of this skill.128 Skip to [Adding another Tool Container129 later](docs/adding-tool-later.md) for any newly-requested tool,130 and to [Adding SSH to a tool later](docs/adding-ssh-later.md) if the131 request is only to add SSH to an already-existing tool. Do not regenerate132 already-existing tools' files — **except**: for each already-existing133 tool, check whether it predates Private Checkout134 (`jq -e '.onCreateCommand' .devcontainer/<tool>/devcontainer.json`; empty135 or an error means it does). If any do and the user hasn't already asked136 to migrate them, tell them these tools are still on the old shared137 bind-mounted model and point them at [Migrating a Tool Container to138 Private Checkout](docs/migrating-private-checkout.md) — don't139 migrate silently as a side effect of an unrelated request.140- **Neither exists**: fresh setup, continue to step 3.141142For any tool whose Tool Container you're about to generate or reopen, also143check for a leftover container from an unrelated prior setup of this same144tool in this same workspace folder. The Dev Containers CLI labels containers145by `devcontainer.local_folder=<absolute workspace path>` and, for146Compose-based containers, `com.docker.compose.service=<tool>` — independent147of what the current config says, so a stale container survives even after148its old config was deleted or never committed, and reopening will silently149reuse it instead of building fresh:150151```bash152docker ps -a --filter "label=devcontainer.local_folder=$(pwd)" --filter "label=com.docker.compose.service=<tool>" --format '{{.ID}} {{.Image}}'153```154155If this returns anything, warn the user before they reopen that tool: a156container built from a different setup won't have the `vscode` user this157setup expects, and reopening fails with a cryptic `unable to find user158vscode: no matching entries in passwd file` that gives no hint the real159cause is the leftover container, not the new config. Offer to remove it160(`docker rm -f <id>`), but don't remove it without asking — it may hold161state the user still wants. Skip this check entirely if `docker` isn't162installed or isn't running; note that it couldn't be checked rather than163failing the rest of the skill over it.164165Done when you know which of the four tools already have a Tool Container,166whether a legacy Shared Container needs a migration message instead of167generation, and whether any tool about to be (re)opened has a stale leftover168container to warn about.169170## 3. Ask tool selection and per-tool options171172Ask the user which tools they want (skip any already answered in their173request, and skip any tool that already has a Tool Container per step 2 —174those go through the append-flows instead):175176- **Claude Code**, **OpenAI Codex CLI** (`codex`), **Google Antigravity CLI**177 (`agy`), **GitHub Copilot CLI** (`copilot`) — a multi-select. Pre-check178 Claude Code as the common case; it's fully optional and symmetric with the179 other three, just recommended by default.180181If `{{LOCAL_CHECKOUT}}` is `"true"` (step 1), skip the SSH Layer question below entirely for182every tool in this run — there's no GitHub repo yet to register deploy/signing keys against. Tell183the user why it's not being offered: "SSH layer isn't available yet — this repo has no GitHub184connection; add it once one exists (see 'Connecting a Local Checkout to a real GitHub repo')."185The YOLO alias and skills-sync questions are unaffected — both are independent of git/GitHub186remote status, ask them normally.187188For each **newly** selected tool, ask independently:189190- **SSH Layer** (skip if `{{LOCAL_CHECKOUT}}` is `"true"`, per above): Does this repo need191 agent-driven `git push` and signed commits from this tool's Tool Container? (Adds192 deploy-key/signing-key automation — this tool registers and owns its own key pair, not shared193 with any other Tool Container that also has it enabled.)194- **YOLO alias**: Should this tool get its `-yolo` alias for fast, unattended195 iteration — `claude-yolo`, `codex-yolo`, `agy-yolo`, or `copilot-yolo`,196 matching the tool's actual CLI command, **not** its folder name (Antigravity's197 is `agy-yolo`, never `antigravity-yolo`)? (Note for Copilot: `copilot-yolo`198 just echoes instructions to manually type `/sandbox enable` inside the199 session.) Each tool's `-yolo` alias reduces its permission checkpoints for200 faster iteration; the exact tradeoff differs per tool — see the README's201 "YOLO aliases" section for specifics.202203Record these answers — they decide which template variants steps 5–6 use.204Both are addable later per tool without redoing anything already generated205(see the append-flows below).206207## 4. Resolve placeholders208209- `{{REPO_SLUG}}`, `{{REPO_NAME}}` — from step 1.210- `{{GIT_EMAIL_DEFAULT}}`, `{{GIT_NAME_DEFAULT}}` — run `git config --global user.email` and211 `git config --global user.name` on the host. If either is unset, don't invent a default: use212 `${GIT_USER_EMAIL:?Set GIT_USER_EMAIL in .devcontainer/.env}` (no `-default` fallback) in213 the base post-create script instead of the `:-` form, and drop the parenthetical in214 `.env.example`'s comment.215- `{{SKILLS_SOURCES_COMMANDS}}` (any selected tool — Claude Code, Codex, Antigravity, and Copilot216 all support this identically) — ask the user one combined question, asked once regardless of217 how many of the four tools are selected: sync AI-agent skills into every selected Tool Container218 automatically on every start? Two ready-made suites are available: `mattpocock/skills` (a broad219 general-purpose skill baseline) and `ken-guru/skills` (this collection — includes this very220 Skill, useful if a layer needs adding later from inside the container). For each, ask yes/no. In221 the same prompt, also invite the user to name any other individual skills they want, in222 `owner/repo/skill-name` form (e.g. `anthropics/skills/frontend-design`) — mixing and matching223 freely, including picking specific skills out of the two suites above instead of taking them224 whole. If the user just wants both suites in full, saying yes to both and skipping the rest is225 the fast path. The same answer applies identically to every selected tool — this question is226 about *which skills*, not *which tool*; the tool-specific part is handled entirely by the227 rendering step below, invisibly to the user.228229 Validate live in this same conversation before rendering anything, for **every individually230 named skill pick, from any source including the two named defaults** (a whole-suite accept231 needs no validation — `--skill '*'` can't typo): `npx -y skills add <source> --list` (or `-l`)232 lists what that source actually contains. If the source doesn't resolve, or a named skill isn't233 in the list, tell the user and re-ask rather than rendering a broken command — this is the234 intended defense against typos, since skills.sh's own API requires authentication this context235 doesn't have, so the CLI's own listing is used instead. This applies even to a skill picked out236 of `mattpocock/skills` or `ken-guru/skills` individually rather than taken as a whole suite —237 those two sources are pre-named, not pre-validated for every skill inside them.238239 Render one block **per selected tool**, since the underlying `npx skills` CLI installs to a240 specific agent's own skills directory, not a shared one — each tool's block is identical to241 every other's except for its `-a` value, taken from this fixed mapping:242243 | Tool | `-a` value |244 | --- | --- |245 | Claude Code | `claude-code` |246 | Codex | `codex` |247 | Antigravity | `antigravity` |248 | Copilot | `github-copilot` |249250 Within each tool's block, render one line per **distinct source**, in the order first251 mentioned:252 - A source accepted as a whole suite (either of the two defaults, or any other source the user253 chose to take in full): `npx -y skills add <source> --skill '*' -a <tool's agent name> -y --copy -g`.254 - A source with only individual picks (not accepted as a whole suite): `npx -y skills add255 <source> --skill '<name1>' --skill '<name2>' ... -a <tool's agent name> -y --copy -g`, listing256 only that source's picked skills.257 - A source both accepted as a whole suite **and** separately named for an individual pick:258 render only the whole-suite line for it — the individual pick is redundant, not259 contradictory, so drop it silently rather than flagging it back to the user.260261 Use tool T's resulting multi-line block everywhere `{{SKILLS_SOURCES_COMMANDS}}` appears inside262 tool T's own template(s) — never mix one tool's `-a` value into another tool's file. Also263 record, for `{{SKILLS_SOURCES_SUMMARY}}` below: only naming a trusted source matters here —264 `-y --copy -g` installs and re-syncs that source's skills unattended on every container start,265 with no per-skill review step, and an installed skill's instructions can influence what the266 agent does inside the container. Tell the user this caution as part of asking the question, not267 as an afterthought.268- `{{SKILLS_SOURCES_SUMMARY}}` — the chosen sources and picks as a short human-readable list for269 the README's prose (e.g. `` `mattpocock/skills` (full), `ken-guru/skills` (full),270 `anthropics/skills/frontend-design` ``) — distinguishing whole-suite sources from individual271 picks, since the README's "Automatic skill sync" section states both. If the user named nothing272 (declined both defaults and no individual picks), render as "none configured."273- `{{SELECTED_TOOLS_SUMMARY}}` — a short human-readable list of the tools selected across this274 run and any already-existing ones (e.g. `` Claude Code, Codex ``), for the README's prose.275- `{{TOOL_DISPLAY_NAME}}` — the tool's display name for the identity banner (step 6), matching its276 `devcontainer.json` `name` field exactly: `claude-code` → `Claude Code`, `codex` → `Codex`,277 `antigravity` → `Antigravity`, `copilot` → `Copilot`.278- `{{TOOL_NAME}}` — the tool's own folder/service slug (`claude-code`, `codex`, `antigravity`,279 `copilot` — the same value as `<tool>` throughout this skill). Only needed when substituting280 [templates/post-create-ssh-block.sh](templates/post-create-ssh-block.sh) (step 6), which is281 shared across every tool and needs it to name that tool's own SSH deploy/signing keys and282 volume distinctly from every other tool's.283284## 5. Build or reuse the shared base image285286`{{BASE_IMAGE_VERSION}}` is the bare version string (`v1`, `v2`, ...);287`{{BASE_IMAGE_TAG}}` is the full image reference built from it:288`{{REPO_NAME}}-tool-container-base:{{BASE_IMAGE_VERSION}}`.289290- If `.devcontainer/base.Dockerfile` doesn't exist yet: write it from291 [templates/base.Dockerfile](templates/base.Dockerfile), substituting292 `{{REPO_SLUG}}`, `{{LOCAL_CHECKOUT}}`, `{{LOCAL_CHECKOUT_GIT_INIT}}`, and293 `{{GIT_DEFAULT_BRANCH}}` (all from step 1) — the baked-in Private294 Checkout clone script needs them to know whether to clone, `git init`, or295 leave the workspace bare. Set `{{BASE_IMAGE_VERSION}}` to `v1`.296 Build it:297 `docker build -t {{REPO_NAME}}-tool-container-base:v1 -f .devcontainer/base.Dockerfile .devcontainer`.298 Record the version and a content hash of the file299 (`sha256sum .devcontainer/base.Dockerfile`) into300 `.devcontainer/.base-image-version` as `<version> <sha256>`.301- If it already exists: compute the sha256 of302 [templates/base.Dockerfile](templates/base.Dockerfile)'s current rendered303 content and compare it to the hash recorded in304 `.devcontainer/.base-image-version`.305 - **Unchanged**: skip rebuilding. Use the version already recorded in306 `.devcontainer/.base-image-version` as `{{BASE_IMAGE_VERSION}}`.307 - **Changed**: tell the user the shared base layer's template has changed308 and this would affect every Tool Container that extends it, and ask309 whether to bump the version (e.g. `v1` → `v2`) and rebuild. Never bump or310 rebuild silently.311 - **Confirmed**: overwrite `.devcontainer/base.Dockerfile`, build and tag312 the bumped version, update `.devcontainer/.base-image-version` with the313 new version and hash, and use the new version as314 `{{BASE_IMAGE_VERSION}}`. A version bump also forces every already-315 generated tool's image to rebuild in step 6 (their tags are versioned316 identically to the base — see below), even though their own317 Dockerfiles didn't change.318 - **Declined**: leave `.devcontainer/base.Dockerfile` and the recorded319 version/hash untouched, and use the existing version as320 `{{BASE_IMAGE_VERSION}}` for this run's new tool(s).321322Done when `.devcontainer/base.Dockerfile` exists, `docker image inspect323{{REPO_NAME}}-tool-container-base:{{BASE_IMAGE_VERSION}}` succeeds, and324`.devcontainer/.base-image-version` records that exact version alongside a325hash matching the file actually on disk.326327## 6. Generate the compose file and each selected tool's folder328329For **each newly selected tool** (`claude-code`, `codex`, `antigravity`, or `copilot`):330331- `.devcontainer/<tool>/Dockerfile` ← [templates/<tool>/Dockerfile](templates/), substitute332 `{{BASE_IMAGE_TAG}}` with the tag resolved in step 5.333- **Build and tag this tool's own image**:334 `docker build -t {{REPO_NAME}}-<tool>:{{BASE_IMAGE_VERSION}} -f .devcontainer/<tool>/Dockerfile .devcontainer`.335 The Compose service references this exact pre-built tag via `image:` (see below) — never336 `build:` — specifically so VS Code's Dev Containers CLI never has a build step to run for these337 services at all. This matters because that CLI is known to pass `--pull` when it *does* build a338 Compose service, which forces Docker to try re-resolving any locally-built image referenced via339 `FROM` (our shared base) from a registry — and since the base was never pushed anywhere, that340 pull fails outright and aborts the whole "Reopen in Container" attempt. Pre-building ourselves341 sidesteps the bug entirely rather than working around it.342- `.devcontainer/<tool>/devcontainer.json`:343 use [templates/<tool>/devcontainer.json](templates/) (or344 [templates/<tool>/devcontainer.with-ssh.json](templates/) if this tool's SSH345 answer was yes), substitute `{{REPO_NAME}}`, and write it. Both variants carry346 `onCreateCommand`, which runs `/usr/local/bin/clone-checkout.sh` (baked into347 the base image in step 5) to create this tool's Private Checkout — a fresh348 `git clone` into this tool's own named volume, not the host's checkout.349- `.devcontainer/<tool>/post-create.sh` — generated by350 [scripts/render-tool-container.sh](scripts/render-tool-container.sh), which assembles the fixed351 7-block template order (base identity setup, the identity banner, the shared install-cli skeleton,352 this tool's own install block, its yolo-alias block, and the SSH block pair) deterministically,353 chmod'ing the result +x:354355 ```bash356 scripts/render-tool-container.sh \357 --tool <tool> --tool-name <tool> --tool-display-name "<TOOL_DISPLAY_NAME>" \358 --repo-name "{{REPO_NAME}}" --repo-slug "{{REPO_SLUG}}" \359 --out .devcontainer/<tool>/post-create.sh \360 [--ssh] [--yolo] \361 [--git-email-default "<value>"] [--git-name-default "<value>"]362 ```363364 Pass `--ssh` / `--yolo` only when this tool's SSH / yolo answers (step 3) were yes. Pass365 `--git-email-default`/`--git-name-default` only when step 4 resolved an actual host default for366 that field — omit the flag entirely (don't pass an empty string) when step 4's `:?`-required case367 applies, since render treats "flag absent" as the signal to emit the hard-require line rather than368 a `-default` fallback. Verify the result with369 [scripts/verify-tool-container.sh](scripts/verify-tool-container.sh), passing it the exact same370 flags:371372 ```bash373 scripts/verify-tool-container.sh \374 --file .devcontainer/<tool>/post-create.sh \375 --tool <tool> --tool-name <tool> --tool-display-name "<TOOL_DISPLAY_NAME>" \376 --repo-name "{{REPO_NAME}}" --repo-slug "{{REPO_SLUG}}" \377 [--ssh] [--yolo] [--git-email-default "<value>"] [--git-name-default "<value>"]378 ```379380 The SSH block ([templates/post-create-ssh-block.sh](templates/post-create-ssh-block.sh)) never381 fails the build: an under-scoped or missing `GH_TOKEN` (or an unset `DEVCONTAINER_HOST`) degrades382 to skipping the rest of the SSH setup and recording why in `~/.ssh/.ssh-setup-skipped`, rather than383 aborting `postCreateCommand` — which would otherwise also skip every block concatenated after it.384 Registers this tool's own deploy/signing key pair on its own `{{REPO_NAME}}-<tool>-ssh` volume — no385 longer one shared pair per repo — and, on first run against a repo that still has the old386 shared-title deploy key registered, auto-removes it. Immediately after it, the warnings block387 ([templates/post-create-warnings-block.sh](templates/post-create-warnings-block.sh)) appends a388 snippet to `~/.bashrc` that surfaces any of this SSH layer's three standing warnings (setup389 skipped, signing key unregistered, deploy key missing on GitHub) at the top of every new terminal,390 not just once at attach — `postCreateCommand`/`postAttachCommand` each fire once per rebuild/attach,391 not per terminal tab.392- `.devcontainer/<tool>/post-start.sh` (every selected tool gets one — Claude Code, Codex,393 Antigravity, and Copilot all sync skills identically) ←394 [templates/<tool>/post-start.sh](templates/), substituted with that tool's own395 `{{SKILLS_SOURCES_COMMANDS}}` block from step 4 (using that tool's `-a` value, never another396 tool's). Always rewritten (even on an already-existing Tool Container) to ensure skill sync397 stays current.398- Make the new `.devcontainer/<tool>/*.sh` files executable: `chmod +x .devcontainer/<tool>/*.sh`.399400If **Codex** was newly selected, append the following caveat to `.devcontainer/README.md` (under401a "Gotchas" or "CLI Notes" section, creating one if it doesn't exist):402403> **Codex Linux sandbox**: Codex's Tool Container carries `capAdd`/`securityOpt` grants so404> Codex's own Bubblewrap sandbox (`codex-yolo`'s `--sandbox workspace-write`) can actually create405> its namespace — scoped to Codex's own container only, never any other tool's. This skill does406> not include a runtime health probe to verify the sandbox is confining anything on your specific407> host — if `codex-yolo` ever behaves as though unsandboxed, that's the first thing to check by408> hand.409410If **Antigravity** was newly selected, append the following caveat to `.devcontainer/README.md`411(same section):412413> **Antigravity CLI Auth**: `agy` stores auth in the system keyring, not a file. The414> `.antigravity` volume mount will not persist its login across rebuilds in a bare container. You415> may need to re-auth `agy` each time, or add a keyring daemon yourself later if that gets416> annoying.417418If **any** newly or already-selected tool has the SSH answer yes:419420- `.devcontainer/.env.example` gets [templates/env.ssh-block.example](templates/env.ssh-block.example) appended, idempotently, and its `GH_TOKEN` comment gets: `Required permissions: Administration (read/write) — needed to manage deploy keys — plus whatever else you use gh for.`421422 ```bash423 scripts/patch-if-absent.sh append .devcontainer/.env.example "DEVCONTAINER_HOST=your-hostname-here" templates/env.ssh-block.example424 ```425- `.devcontainer/README.md` gets [templates/README.ssh-block.md](templates/README.ssh-block.md) appended, idempotently, and the baseline template's closing "SSH deploy key and signing key automation — Not set up here" section is deleted (superseded by the real section):426427 ```bash428 scripts/patch-if-absent.sh append .devcontainer/README.md "## SSH deploy key and signing key" templates/README.ssh-block.md429 scripts/patch-if-absent.sh delete-section .devcontainer/README.md "## SSH deploy key and signing key automation"430 ```431432If step 5 bumped `{{BASE_IMAGE_VERSION}}` this run (the **Confirmed** branch), rebuild and433retag **every already-existing tool's image** too, at the new version — same build command as434above, run again for each tool that already has a Tool Container even though none of its own435files (Dockerfile, devcontainer.json, post-create.sh) need rewriting. Their Compose service's436`image:` reference is versioned identically to the base, so without this their tag would point at437an image that no longer exists.438439Always (every run, regardless of which tools are new):440441- `.devcontainer/docker-compose.yml` ← rebuilt from [templates/docker-compose.yml](templates/docker-compose.yml): concatenate every currently-selected tool's [templates/<tool>/compose-fragment.yml](templates/) (substituted `{{REPO_NAME}}` and `{{BASE_IMAGE_VERSION}}`) under `services:`, and list one `{{REPO_NAME}}-<tool>-config:` volume line **and** one `{{REPO_NAME}}-<tool>-checkout:` volume line per selected tool, **plus** one `{{REPO_NAME}}-<tool>-ssh:` volume line per SSH-enabled tool (one per tool now, not one shared line for the whole repo), under `volumes:`. The checkout volume backs that tool's Private Checkout — the named volume `onCreateCommand`'s clone script populates, replacing the old shared bind mount. **Safely rebuild, don't hand-edit around**: since this file only ever holds what this skill generated, it's fine to regenerate it wholesale from the current set of selected tools each run — never drop an already-existing tool's service just because this particular run didn't ask about it again.442- `.devcontainer/post-attach.sh` ← [templates/post-attach.sh](templates/post-attach.sh), substituted. Every selected tool gets this and its `postAttachCommand` wiring — not just SSH-enabled ones — since it carries Private Checkout's staleness hint (a static reminder to `git fetch`, shown on every attach) unconditionally; the SSH-specific logic inside guards itself when that particular tool's SSH layer isn't enabled. Write once (identical content across every tool); `chmod +x` it.443- `.devcontainer/.env.example` ← [templates/env.baseline.example](templates/env.baseline.example), substituted, if it doesn't already exist.444- `.devcontainer/README.md` ← [templates/README.baseline.md](templates/README.baseline.md), substituted, if it doesn't already exist. If it already exists, update `{{SELECTED_TOOLS_SUMMARY}}`'s rendered value in place, and **backfill the "Automatic skill sync" and "YOLO aliases" sections** (matching heading) from the current template if either is missing, inserting each at the same position it holds in the current template — a README from before these sections existed should end up with them added, not left stale. Render each with current values regardless of what's configured this run (e.g. `{{SKILLS_SOURCES_SUMMARY}}` renders as "none configured" when no source is set up), the same as the rest of the baseline template already does for tools that aren't selected. If a section is already present, leave it as-is — this backfill only inserts what's missing, it doesn't reconcile wording drift in a section that already exists. Render each candidate section's current content to a scratch file first (substituted, same as the rest of this step), then, **in this order** (YOLO aliases before Automatic skill sync, so Automatic skill sync's anchor is guaranteed present even backfilling into a README old enough to be missing both):445446 ```bash447 scripts/patch-if-absent.sh insert-before .devcontainer/README.md "## YOLO aliases" "## Gotchas fixed here (and why)" <rendered-yolo-aliases-section>448 scripts/patch-if-absent.sh insert-before .devcontainer/README.md "## Automatic skill sync" "## YOLO aliases" <rendered-skill-sync-section>449 ```450- Add `.devcontainer/.env` to `.gitignore` if it isn't already ignored.451- Remove `.claude/worktrees/` from `.gitignore` if a prior run of this skill added it (check for452 the exact line and delete it; leave every other line untouched). It existed only because453 `claude --worktree` used to create isolated git worktrees directly inside the *bind-mounted*454 `/workspace`, which the host saw as untracked noise in `git status` since the mount was the same455 filesystem, not container-isolated. Now that every Tool Container has its own Private Checkout,456 a worktree created inside one lives only in that tool's own volume — invisible to the host, so457 nothing to gitignore. This is a no-op for a fresh repo (the line was never added); for a repo458 migrating from before Private Checkout, this actively cleans it up.459460Done when every file above exists, every tool's `devcontainer.json` parses as valid JSON461(`jq empty .devcontainer/<tool>/devcontainer.json`) with a non-null `onCreateCommand`462(`jq -e '.onCreateCommand' .devcontainer/<tool>/devcontainer.json`), `docker-compose.yml` parses463as valid YAML with exactly one service per selected tool and one `{{REPO_NAME}}-<tool>-checkout:`464volume line per selected tool (plus one `{{REPO_NAME}}-<tool>-ssh:` line per SSH-enabled tool),465every selected tool's image actually exists at the tag its Compose service references466(`docker image inspect {{REPO_NAME}}-<tool>:{{BASE_IMAGE_VERSION}}` succeeds for each), the467clone-checkout script landed executable in the base image468(`docker run --rm {{REPO_NAME}}-tool-container-base:{{BASE_IMAGE_VERSION}} test -x /usr/local/bin/clone-checkout.sh`),469every with-ssh `devcontainer.json`'s `mounts` entry references *that tool's own* SSH volume (not470another tool's, and not a stale shared name), no `{{...}}` placeholder remains in any written file471(`grep -rn '{{' .devcontainer/`), and every selected tool's `post-create.sh` passes472[scripts/verify-tool-container.sh](scripts/verify-tool-container.sh) for the exact flags it was473rendered with (stronger than the blanket `{{` grep above: also checks the right blocks landed for474this tool's ssh/yolo combination and that the git-identity lines took the right shape).475476Runtime behavior — the clone actually succeeding, SSH keys actually registering, the staleness477hint actually appearing — is intentionally **not** part of this per-run check; it can only be478confirmed by actually attaching a container, same scope boundary this checklist already draws for479skill-sync and yolo-alias behavior. Cross-container isolation (two Tool Containers' Private480Checkouts genuinely independent of each other) is a one-time sanity check worth doing yourself the481first time you use more than one tool in a repo, not something to re-verify on every subsequent482`setup-devcontainer` run — see the README's "Running tools concurrently" section.483484## 7. Report next steps485486Tell the user, adapted to which tools were selected and which have SSH/yolo:4874881. Install Docker Desktop and the **Dev Containers** VS Code extension.4892. Copy `.devcontainer/.env.example` to `.devcontainer/.env` and fill in `GH_TOKEN`{{, and490 `DEVCONTAINER_HOST` (run `hostname`) if any tool's SSH layer is present}}.4913. For each selected tool: reopen the repo in that Tool Container492 (**Dev Containers: Reopen in Container**, pick the tool's name).4934. Run that tool's CLI and log in.4945. {{If more than one tool was selected: to use two at once, open a second495 VS Code window (File > New Window) on this same repo and reopen it in a496 different tool's container there — see the README's "Running tools497 concurrently" section.}}4986. {{If any tool has the SSH layer present: on attach, `post-attach.sh`499 prints a public key — paste it into github.com/settings/ssh as a Signing500 Key, then `touch ~/.ssh/.signing-key-registered`. Do this **once per501 SSH-enabled Tool Container** — each tool has its own key pair and its own502 registration marker now, so this doesn't carry over between tools.}}5037. {{For each tool with its YOLO alias present: a new shell in that tool's504 container has its alias available for fast, unattended iteration —505 `claude-yolo`, `codex-yolo`, `agy-yolo`, or `copilot-yolo` (matching the506 tool's actual CLI command, not its folder name). See the README's "YOLO507 aliases" section for what each one's permission tradeoff actually means508 before using it — Antigravity's in particular needs one manual setup step509 there before `agy-yolo` is meaningfully safe.}}510511Done when the user has been told every applicable item above, adapted to which tools, SSH layer,512and YOLO aliases are present.513514## Adding another Tool Container later515516For a repo that already has Tool Container(s) from a prior run of this skill and now wants an517additional tool. See [docs/adding-tool-later.md](docs/adding-tool-later.md).518519## Adding SSH to a tool later520521For a tool that already has a Tool Container and now needs agent-driven `git push` / signed522commits. See [docs/adding-ssh-later.md](docs/adding-ssh-later.md).523524## Migrating a Tool Container to Private Checkout525526For a tool whose `devcontainer.json` predates Private Checkout (step 2's detection: no527`onCreateCommand`) — moving it from the old shared bind-mounted workspace to its own isolated528clone, opt-in per tool, never automatic. See529[docs/migrating-private-checkout.md](docs/migrating-private-checkout.md).530531## Connecting a Local Checkout to a real GitHub repo532533For a repo generated as Local Checkout (step 1) that now has a real GitHub repository to push534to. Needs no skill-level regeneration — see535[docs/connecting-local-checkout.md](docs/connecting-local-checkout.md).