fix(devcontainer): full plugin/config parity by dropping CLAUDE_CONFIG_DIR + syncing settings.json

Two changes that together give the container the same plugins and configs
as the host for all three AI CLIs (login stays per-container):

1. Drop CLAUDE_CONFIG_DIR from containerEnv. The named-volume mount target
   `/home/node/.claude` already matches Claude's default `~/.claude`, so
   the env var added no behavior — but setting it changed which file
   Claude reads `hasCompletedOnboarding` from. With it set, Claude reads
   `$CLAUDE_CONFIG_DIR/.claude.json` (the small identity-only file that
   does NOT carry `hasCompletedOnboarding`); without it, Claude reads
   `$HOME/.claude.json` (the big onboarding-state file that does). The
   wizard fires every container-create when set, skips when unset.

2. Sync `settings.json` from host (Claude) + symlink `memories/` and
   `skills/` from host (Codex). Theme + `enabledPlugins` +
   `extraKnownMarketplaces` live in `settings.json` — without syncing
   it, the theme picker fires and host-installed plugins stay disabled
   even though their files are symlinked in. Codex's `memories/` and
   `skills/` are the symmetric Codex user-installed surface, now shared
   the same way Claude's plugins/skills/agents/memory/commands are.

Cursor stays as-is — `cli-config.json` conflates auth+settings (already
synced), and there's no separate plugin surface to mirror.

Login details remain per-container by design (acceptable to re-login on
rebuild). Everything else — plugins, skills, agents, memory, MCP user-
scope config, project trust, theme, plugin enablement — now matches
host on every container-create.
This commit is contained in:
Gergo Magyar 2026-05-28 16:26:16 +01:00
parent a6df9ca182
commit 26470d5580
3 changed files with 49 additions and 20 deletions

View file

@ -94,11 +94,12 @@ The three AI CLIs use a **hybrid topology** so you get host plugins/skills/memor
- **Symlinks shared subdirs from the read-only host stage** into the container's config volume, so installing a plugin on the host shows up in the container after a rebuild. The shared list:
- **Claude**: `plugins/`, `skills/`, `agents/`, `memory/`, `commands/` — your user-installed surface
- **Codex**: `config.toml` — your user prefs
- **Cursor**: nothing shared (cli-config.json conflates auth + settings, no shareable subdirs)
- **Codex**: `config.toml`, `memories/`, `skills/` — your prefs + user-installed surface (symmetric with Claude)
- **Cursor**: nothing shared via symlink (Cursor's `cli-config.json` conflates auth + settings; no separate plugin surface)
- **Syncs these from host into the container's config volume** (not symlinks — container can refresh/rewrite freely, host stays untouched). Sync is "always overwrite if host has the file, otherwise leave container alone", so logging in on host populates the container on next rebuild, and logging in only inside the container keeps that login (host has no source to overwrite from):
- `.credentials.json` (Claude), `auth.json` (Codex), `cli-config.json` (Cursor) — credentials
- **Two Claude state files**: `$HOME/.claude.json` (carries `hasCompletedOnboarding`, `userID`, `oauthAccount`, per-project trust state, MCP user-scope config) **and** `$CLAUDE_CONFIG_DIR/.claude.json` (carries migration tracking, the same `userID`, per-project trust). Both must agree on `userID` or Claude Code re-onboards; the sync covers both. Stub fallback `{"hasCompletedOnboarding":true,"installMethod":"global"}` written to `$HOME/.claude.json` only if the host had neither file.
- **Two Claude state files**: `$HOME/.claude.json` (carries `hasCompletedOnboarding`, MCP user-scope config, project trust, `tipsHistory`) **and** `~/.claude/.claude.json` (carries `userID`, `oauthAccount`, migration tracking). Both files get synced. We deliberately leave `CLAUDE_CONFIG_DIR` unset (Claude's default `~/.claude` matches the named-volume mount target) so Claude reads onboarding state from `$HOME/.claude.json` — which is where `hasCompletedOnboarding` lives. With `CLAUDE_CONFIG_DIR` set, Claude would instead read the small identity-only file and re-onboard every container. Stub fallback `{"hasCompletedOnboarding":true,"installMethod":"global"}` written to `$HOME/.claude.json` only if the host had neither file.
- **`settings.json`** (Claude) — theme, `enabledPlugins`, and `extraKnownMarketplaces`. Without this synced, theme picker fires on every fresh volume and host-installed plugins stay disabled even though their files are symlinked in. We pin Claude Code via `CLAUDE_CODE_VERSION`, so version drift between host (floating) and container (pinned) is bounded — Claude tolerates unknown keys, and we re-sync on every container-create anyway.
**Why read-only stage + named volume instead of a single host bind mount:**

View file

@ -105,8 +105,23 @@
// would silently break `cursor-agent login`. Users who need API key auth
// should `export` the var in their container shell or carry it via their
// VS Code dotfiles repo (see .devcontainer/README.md).
// CLAUDE_CONFIG_DIR is intentionally NOT set. The Claude default is
// `$HOME/.claude` (= `/home/node/.claude`), which is exactly where the
// claude-config named volume mounts — setting the env var explicitly
// changes which file Claude reads `hasCompletedOnboarding` from: with
// the var set, Claude reads `$CLAUDE_CONFIG_DIR/.claude.json` (the
// small identity file); without it, Claude reads `$HOME/.claude.json`
// (the big onboarding-state file that actually carries
// `hasCompletedOnboarding`, MCP user-scope config, and per-project
// trust). Leaving the var unset matches host behavior and lets
// post-create.sh's host-sync of `$HOME/.claude.json` skip the wizard
// on every container-create.
//
// CODEX_HOME is kept (even though it matches the Codex default) as a
// canary: if we ever move the Codex named volume target, the env var
// makes the dependency explicit instead of silently following the
// default.
"containerEnv": {
"CLAUDE_CONFIG_DIR": "/home/node/.claude",
"CODEX_HOME": "/home/node/.codex",
"DISABLE_AUTOUPDATER": "1",
"HISTFILE": "/commandhistory/.zsh_history"

View file

@ -92,37 +92,50 @@ sync_from_host() {
fi
}
# Claude Code — share plugins, skills, agents, memory, commands (the
# user-installed surface). Skip ide/projects/settings.json per above.
# Claude Code — share the user-installed surface (plugins/skills/agents/
# memory/commands) read-only from host. Skip ide/projects per above.
link_readonly_share /host/.claude /home/node/.claude \
plugins skills agents memory commands
# `~/.claude.json` at $HOME is one state file. There's a SECOND `.claude.json`
# inside CLAUDE_CONFIG_DIR (`/home/node/.claude/.claude.json`) carrying
# migration tracking, userID, and per-project trust state. If the userIDs
# between the two don't match (e.g., the in-config one is left over from
# a prior test session), Claude Code re-onboards. Sync BOTH from host.
# State files. Claude splits state across two files: $HOME/.claude.json
# (hasCompletedOnboarding, MCP user-scope config, project trust,
# tipsHistory) and $CLAUDE_CONFIG_DIR/.claude.json (userID, oauthAccount,
# migration tracking). With CLAUDE_CONFIG_DIR unset (see devcontainer.json
# rationale), Claude reads onboarding state from $HOME/.claude.json —
# which carries `hasCompletedOnboarding` and skips the wizard.
sync_from_host /host/.claude.json /home/node/.claude.json 644
sync_from_host /host/.claude/.claude.json /home/node/.claude/.claude.json 644
# If neither host file existed, write a minimal stub at $HOME so the
# wizard is still bypassed for first-time hosts.
# settings.json carries the active theme, enabled plugins, and known
# marketplaces — without this, theme picker fires on every fresh volume
# and host-installed plugins stay disabled even though their files are
# symlinked in by link_readonly_share above. We pin Claude Code version
# in devcontainer.json, so schema drift between host (floating) and
# container (pinned) is bounded — Claude tolerates unknown keys, and
# we re-sync on every container-create.
sync_from_host /host/.claude/settings.json /home/node/.claude/settings.json 644
# Stub fallback at $HOME/.claude.json for first-time hosts that never
# ran Claude Code (host file is empty or missing — `sync_from_host`
# leaves the container's path missing too).
if [ ! -f /home/node/.claude.json ]; then
echo '{"hasCompletedOnboarding":true,"installMethod":"global"}' \
> /home/node/.claude.json
chmod 644 /home/node/.claude.json
fi
# Credentials. Container managed its own refresh until next rebuild.
# Credentials. Container manages its own refresh until next rebuild.
sync_from_host \
/host/.claude/.credentials.json /home/node/.claude/.credentials.json
# Codex — share config.toml; copy auth.json on container create. Hosts
# using OS keyring storage (`cli_auth_credentials_store = "keyring"`,
# default on macOS) have no auth.json on disk — the copy silently
# no-ops and `codex login --device-auth` inside the container is the
# path.
link_readonly_share /host/.codex /home/node/.codex config.toml
# Codex — share config.toml + memories/ + skills/ (the user-installed
# surface, symmetric with Claude's plugin/skill/agent sharing). Sync
# auth.json on container-create. Hosts using OS keyring storage
# (`cli_auth_credentials_store = "keyring"`, default on macOS) have no
# auth.json on disk — the copy silently no-ops and
# `codex login --device-auth` inside the container is the path.
link_readonly_share /host/.codex /home/node/.codex \
config.toml memories skills
sync_from_host \
/host/.codex/auth.json /home/node/.codex/auth.json