diff --git a/README.md b/README.md index 3ee1b40..46396b0 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,8 @@ localdev ``` That launches the container with your current directory mounted read-write as the -workspace at `/`. Your global agent configs (`~/.claude`, -`~/.opencode`, `~/.pi`, `~/.omp`, `~/.kit`, `~/.goprojdex`), SSH keys, and forwarded SSH agent are mounted +workspace at `/`. Your global agent configs (`~/.claude` and +`~/.claude.json`, `~/.opencode`, `~/.pi`, `~/.omp`, `~/.kit`, `~/.goprojdex`), SSH keys, and forwarded SSH agent are mounted automatically. Three launchers are available: - **`localdev`** - lightweight container, isolated networking (default) @@ -22,6 +22,29 @@ automatically. Three launchers are available: Run `localdev -h` for full usage. +### Claude Code state: shared or isolated + +Claude Code keeps its state in **two** places, and both are mounted, because both are needed for a login to survive a container restart: + +| Path | Holds | +|------|-------| +| `~/.claude/` | credentials, history, settings, plugin cache and registry | +| `~/.claude.json` | account linkage, onboarding state, per-project state | + +The second is a *file* at the root of your home directory, not inside the first. Mounting only the directory leaves `.claude.json` absent on every start, so Claude Code sees no linked account and prompts for login each launch — even though the credential is sitting in the mounted directory unused. + +By default both come from your own `$HOME`, so the container shares state with the host. That is convenient: your plugins and your login are immediately available inside. + +It has a cost, though. Host and container run different Claude Code builds against one mutable registry, and they can reset each other's state — a container session emptying the host's `installed_plugins.json`, disabling its plugins, has been observed. If that matters to you, point `LOCALDEV_CLAUDE_STATE` at a directory used only by containers: + +```bash +export LOCALDEV_CLAUDE_STATE="$HOME/.localdev-claude" +``` + +The launcher then mounts `$LOCALDEV_CLAUDE_STATE/.claude` and `$LOCALDEV_CLAUDE_STATE/.claude.json`, creating them on first use. The container gets its own credential and plugin state and cannot damage the host's. The cost is one extra login, and that plugins must be provisioned separately in that directory — for private plugin marketplaces the container may not be able to reach them at all, in which case copy the relevant `plugins/marketplaces/` and `plugins/cache/` subtrees across from your host. + +The startup banner says which mode is in effect — `(shared with host)` or `(isolated from host)`. + ### Mounting extra folders Often you need folders from outside the workspace inside the container - reference diff --git a/localdev b/localdev index 1f00c5c..f1d335f 100755 --- a/localdev +++ b/localdev @@ -46,24 +46,10 @@ if [[ -d "$HOME/dotfiles" ]]; then fi fi -# Handle .claude directory mount (native location for Claude Code global config) -mkdir -p "$HOME/.claude" 2>/dev/null || true -CLAUDE_MOUNT=() -if [[ -d "$HOME/.claude" ]]; then - if [[ -r "$HOME/.claude" ]]; then - # Plugins (~/.claude/plugins) are intentionally shared via this bind, NOT isolated in a - # named volume. The entrypoint symlinks HOST_HOME -> /home/developer so the host's absolute - # plugin install paths resolve inside the container. Do not re-add a separate plugins volume. - CLAUDE_MOUNT=("-v" "$HOME/.claude:/home/developer/.claude:rw") - echo "Mounting (read-write): $HOME/.claude -> /home/developer/.claude" >&2 - MOUNTS_CONTENT+="$HOME/.claude -> /home/developer/.claude (read-write)\n" - else - echo "Warning: $HOME/.claude exists but is not readable, skipping mount" >&2 - ((MOUNT_ERRORS++)) - fi -else - echo "Info: $HOME/.claude could not be created, skipping mount" >&2 -fi +# Claude Code state: mounts both ~/.claude and ~/.claude.json (both are required for +# login to persist). Honours LOCALDEV_CLAUDE_STATE to isolate container state from the +# host's -- see configure_claude_state in localdev-mounts.sh. +configure_claude_state # Handle .opencode directory mount (native location for opencode global config) mkdir -p "$HOME/.opencode" 2>/dev/null || true diff --git a/localdev-mounts.sh b/localdev-mounts.sh index e9028e3..29d42a8 100644 --- a/localdev-mounts.sh +++ b/localdev-mounts.sh @@ -563,3 +563,64 @@ collect_mounts() { _process_env_mounts _process_file_mounts "/${dirname}" } + +# configure_claude_state mounts the host's Claude Code state into the container. +# +# Claude Code keeps its state in two places, and BOTH must be mounted or login does not +# survive a container restart: +# +# ~/.claude/ directory: credentials (.credentials.json), history, settings, +# plugin cache and registry +# ~/.claude.json FILE at HOME root: account linkage, onboarding and project state +# +# Mounting only the directory -- which is what this used to do -- leaves .claude.json +# absent on every start, so Claude Code sees no linked account and prompts for login even +# though .credentials.json is sitting in the mounted directory unused. +# +# LOCALDEV_CLAUDE_STATE selects which host directory supplies that pair (default $HOME): +# +# shared (default) The host's own state. Convenient -- host plugins and login are +# immediately available -- but host and container run different +# Claude Code builds against one mutable registry and can reset each +# other's state. A container session has been observed emptying the +# host's installed_plugins.json. +# +# isolated Point LOCALDEV_CLAUDE_STATE at a directory used only by containers +# (e.g. ~/.localdev-claude). The container gets its own credential and +# plugin state, and cannot damage the host's. Costs one extra login. +# +# Sets: CLAUDE_MOUNT. Appends to: MOUNTS_CONTENT +configure_claude_state() { + local state="${LOCALDEV_CLAUDE_STATE:-$HOME}" + local dir="$state/.claude" + local json="$state/.claude.json" + CLAUDE_MOUNT=() + + mkdir -p "$dir" 2>/dev/null || true + if [[ ! -d "$dir" || ! -r "$dir" ]]; then + echo "Warning: $dir is missing or unreadable; Claude Code state not mounted" >&2 + ((MOUNT_ERRORS++)) + return 0 + fi + + # podman creates a DIRECTORY when bind-mounting a path that does not exist, which would + # make Claude Code fail on a directory where it expects a JSON file. Seed it instead. + if [[ ! -e "$json" ]]; then + printf '{}\n' > "$json" 2>/dev/null || true + fi + + CLAUDE_MOUNT=("-v" "$dir:/home/developer/.claude:rw") + MOUNTS_CONTENT+="$dir -> /home/developer/.claude (read-write)\n" + if [[ -f "$json" && -r "$json" ]]; then + CLAUDE_MOUNT+=("-v" "$json:/home/developer/.claude.json:rw") + MOUNTS_CONTENT+="$json -> /home/developer/.claude.json (read-write)\n" + else + echo "Warning: $json unusable; login will not persist across container restarts" >&2 + fi + + if [[ "$state" == "$HOME" ]]; then + echo "Mounting (read-write): $dir + .claude.json (shared with host)" >&2 + else + echo "Mounting (read-write): $dir + .claude.json (isolated from host)" >&2 + fi +} diff --git a/localdevnet b/localdevnet index 40679c5..6c335ad 100755 --- a/localdevnet +++ b/localdevnet @@ -46,21 +46,10 @@ if [[ -d "$HOME/dotfiles" ]]; then fi fi -# Handle .claude directory mount (native location for Claude Code global config) -mkdir -p "$HOME/.claude" 2>/dev/null || true -CLAUDE_MOUNT=() -if [[ -d "$HOME/.claude" ]]; then - if [[ -r "$HOME/.claude" ]]; then - CLAUDE_MOUNT=("-v" "$HOME/.claude:/home/developer/.claude:rw") - echo "Mounting (read-write): $HOME/.claude -> /home/developer/.claude" >&2 - MOUNTS_CONTENT+="$HOME/.claude -> /home/developer/.claude (read-write)\n" - else - echo "Warning: $HOME/.claude exists but is not readable, skipping mount" >&2 - ((MOUNT_ERRORS++)) - fi -else - echo "Info: $HOME/.claude could not be created, skipping mount" >&2 -fi +# Claude Code state: mounts both ~/.claude and ~/.claude.json (both are required for +# login to persist). Honours LOCALDEV_CLAUDE_STATE to isolate container state from the +# host's -- see configure_claude_state in localdev-mounts.sh. +configure_claude_state # Handle .opencode directory mount (native location for opencode global config) mkdir -p "$HOME/.opencode" 2>/dev/null || true diff --git a/localfull b/localfull index 922aa7f..36d7d4d 100755 --- a/localfull +++ b/localfull @@ -46,21 +46,10 @@ if [[ -d "$HOME/dotfiles" ]]; then fi fi -# Handle .claude directory mount (native location for Claude Code global config) -mkdir -p "$HOME/.claude" 2>/dev/null || true -CLAUDE_MOUNT=() -if [[ -d "$HOME/.claude" ]]; then - if [[ -r "$HOME/.claude" ]]; then - CLAUDE_MOUNT=("-v" "$HOME/.claude:/home/developer/.claude:rw") - echo "Mounting (read-write): $HOME/.claude -> /home/developer/.claude" >&2 - MOUNTS_CONTENT+="$HOME/.claude -> /home/developer/.claude (read-write)\n" - else - echo "Warning: $HOME/.claude exists but is not readable, skipping mount" >&2 - ((MOUNT_ERRORS++)) - fi -else - echo "Info: $HOME/.claude could not be created, skipping mount" >&2 -fi +# Claude Code state: mounts both ~/.claude and ~/.claude.json (both are required for +# login to persist). Honours LOCALDEV_CLAUDE_STATE to isolate container state from the +# host's -- see configure_claude_state in localdev-mounts.sh. +configure_claude_state # Handle .opencode directory mount (native location for opencode global config) mkdir -p "$HOME/.opencode" 2>/dev/null || true