Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ localdev
```

That launches the container with your current directory mounted read-write as the
workspace at `/<directory-name>`. Your global agent configs (`~/.claude`,
`~/.opencode`, `~/.pi`, `~/.omp`, `~/.kit`, `~/.goprojdex`), SSH keys, and forwarded SSH agent are mounted
workspace at `/<directory-name>`. 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)
Expand All @@ -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
Expand Down
22 changes: 4 additions & 18 deletions localdev
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions localdev-mounts.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
19 changes: 4 additions & 15 deletions localdevnet
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 4 additions & 15 deletions localfull
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down