Skip to content

Commit 23abf35

Browse files
authored
fix: validate completions output before writing; document two-mount requirement (#8)
Closes #7 ## Bug 1 — Fish (and bash/zsh) completions corrupted at build time `claude completions <shell>` runs at image build time when Claude Code is unauthenticated. The CLI exits 0 but writes an error message to stdout (e.g. `Not logged in · Please run /login`). The previous direct redirect saved that error verbatim into the completions file, causing fish to print parse errors on every shell start. **Fix:** capture output into a variable and validate the prefix before writing: | Shell | Valid prefix | |-------|-------------| | fish | `complete` | | bash | `_` or `#` | | zsh | `#compdef` | Invalid or empty output is skipped with a warning; no file is written. ## Bug 2 — Onboarding re-triggers when only `~/.claude` is mounted Claude Code stores preferences and onboarding state in `~/.claude.json` — a sibling of `~/.claude/`, not inside it. The previous README guidance mounted only the directory, so `~/.claude.json` was never persisted and the onboarding wizard re-ran on every container start. **Fix:** update `README.md`, `src/claude-code/README.md`, and the `mountHostConfig` logged snippet to document that two mounts are required and warn that `~/.claude.json` must exist on the host before the container starts (Docker creates it as a directory if absent). ## Test plan - [ ] CI passes (ShellCheck, shfmt, all scenario tests) - [ ] Build a container with fish installed — confirm no parse errors on shell start - [ ] Build with `mountHostConfig: true` — confirm logged snippet shows both mounts
1 parent eef1037 commit 23abf35

5 files changed

Lines changed: 183 additions & 39 deletions

File tree

README.md

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -104,24 +104,37 @@ browser window. Works in VS Code's integrated terminal with no extra configurati
104104
}
105105
```
106106

107-
**3. Mount host `~/.claude`.** Share your full host config — API keys, session tokens, preferences
108-
— directly into the container:
107+
**3. Mount host `~/.claude` and `~/.claude.json`.** Share your full host config — API keys,
108+
session tokens, preferences — directly into the container.
109+
110+
Claude Code splits its persistent state across two locations:
111+
112+
- `~/.claude/` — session transcripts, project memory, MCP server configuration
113+
- `~/.claude.json` — global settings, onboarding state, theme, account metadata
114+
115+
Both must be mounted. Mounting only the directory leaves `~/.claude.json` absent, causing the
116+
onboarding wizard to re-run and all preferences to reset on every container start.
109117

110118
```json
111119
{
112120
"mounts": [
113-
"source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind,consistency=cached,readonly"
121+
"source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind,consistency=cached,readonly",
122+
"source=${localEnv:HOME}/.claude.json,target=/home/vscode/.claude.json,type=bind,consistency=cached,readonly"
114123
]
115124
}
116125
```
117126

118127
> ⚠️ **Security:** This exposes your API keys inside the container. Only do this in trusted
119-
> environments. Adjust the `target` path to match your container user's home directory (`/root`,
128+
> environments. Adjust both `target` paths to match your container user's home directory (`/root`,
120129
> `/home/node`, etc.). The `readonly` flag prevents Claude Code from writing settings or session
121130
> data back to the host — if you need bidirectional sync, remove it and accept the risk.
122131
>
123-
> Set `mountHostConfig: true` in the feature options to get this snippet printed at build time with
124-
> your actual home directory path pre-filled.
132+
> **Note:** `~/.claude.json` must exist on the host before the container starts — Docker creates it
133+
> as a directory if absent. Run `claude` on the host at least once, or bootstrap it with:
134+
> `echo '{}' > ~/.claude.json`
135+
>
136+
> Set `mountHostConfig: true` in the feature options to get this two-mount snippet printed at build
137+
> time with your actual home directory path pre-filled.
125138
126139
## Tested Base Images
127140

src/claude-code/README.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,15 +68,23 @@ Claude Code requires authentication. Three options:
6868
}
6969
```
7070

71-
3. **Mount host config:** Use the `mountHostConfig` option to get the mount
72-
snippet, then add it to `mounts` in your `devcontainer.json`. Adjust the
73-
`target` path to match your container user's home directory.
71+
3. **Mount host config:** Use the `mountHostConfig` option to get the two-mount
72+
snippet, then add both entries to `mounts` in your `devcontainer.json`. Two
73+
mounts are required: `~/.claude/` holds session data and MCP config;
74+
`~/.claude.json` holds global settings and onboarding state. Mounting only
75+
the directory causes the onboarding wizard to re-run on every container
76+
start. Adjust both `target` paths to match your container user's home
77+
directory.
7478

7579
## Notes
7680

7781
- Node.js >= 18 is required. If not present, this feature installs the current
7882
LTS release automatically.
79-
- Shell completions are installed for bash, zsh, and fish if those directories
80-
exist in the container.
83+
- Shell completions for bash, zsh, and fish are attempted at build time.
84+
Because `claude completions` requires authentication, completions are only
85+
installed if credentials are available during the build (e.g., via
86+
`ANTHROPIC_API_KEY`). To install completions after logging in, run
87+
`claude completions bash > /usr/share/bash-completion/completions/claude`
88+
(adjust path and shell name as needed).
8189
- The `enableMcpServers` option creates a starter config with secure permissions
8290
(`chmod 600`) owned by the container user.

src/claude-code/install.sh

Lines changed: 82 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,10 @@ setup_completions() {
525525

526526
log_info "Installing shell completions..."
527527

528+
# Escape character for portable ANSI stripping (works with GNU sed and busybox sed).
529+
local esc
530+
esc=$(printf '\033')
531+
528532
# Bash completions
529533
local bash_comp_dir=""
530534
if [[ -d /usr/share/bash-completion/completions ]]; then
@@ -533,17 +537,56 @@ setup_completions() {
533537
bash_comp_dir="/etc/bash_completion.d"
534538
fi
535539
if [[ -n "${bash_comp_dir}" ]]; then
536-
timeout 30 claude completions bash </dev/null >"${bash_comp_dir}/claude" 2>/dev/null || {
537-
log_warn "Failed to install bash completions."
538-
}
540+
local bash_comp_raw=""
541+
local bash_comp_output=""
542+
bash_comp_raw=$(timeout 30 claude completions bash </dev/null 2>/dev/null) || true
543+
# Strip \r (CRLF), ANSI codes, and known Node.js warning lines, then keep
544+
# everything from the first non-blank line onwards. This is more robust than
545+
# anchoring on a specific first character, since the completion format varies
546+
# across Claude Code versions and some wrap the script in an `if` block.
547+
bash_comp_output=$(printf '%s' "${bash_comp_raw}" |
548+
tr -d '\r' |
549+
sed "s/${esc}\[[0-9;]*[a-zA-Z]//g" |
550+
sed '/^(node:[0-9]/d; /^Use .* --trace-warnings/d' |
551+
sed -n '/[^ ]/,$p')
552+
local bash_comp_first=""
553+
bash_comp_first=$(printf '%s' "${bash_comp_output}" | head -n1)
554+
if [[ "${bash_comp_first}" == _* ]] || [[ "${bash_comp_first}" == "#"* ]] ||
555+
[[ "${bash_comp_first}" == "if "* ]] || [[ "${bash_comp_first}" == "function "* ]]; then
556+
printf '%s\n' "${bash_comp_output}" >"${bash_comp_dir}/claude"
557+
elif printf '%s' "${bash_comp_output}" | grep -qi -e 'not logged in' -e '/login'; then
558+
log_debug "Skipping bash completions: authentication required (expected during build)."
559+
elif [[ -n "${bash_comp_raw}" ]]; then
560+
log_warn "Skipping bash completions: output does not look like a valid completion script."
561+
else
562+
log_debug "Skipping bash completions: no output from claude completions bash."
563+
fi
539564
fi
540565

541566
# Zsh completions — only if zsh is installed
542567
if command -v zsh >/dev/null 2>&1; then
543568
mkdir -p /usr/share/zsh/site-functions 2>/dev/null || true
544-
timeout 30 claude completions zsh </dev/null >/usr/share/zsh/site-functions/_claude 2>/dev/null || {
545-
log_warn "Failed to install zsh completions."
546-
}
569+
local zsh_comp_raw=""
570+
local zsh_comp_output=""
571+
zsh_comp_raw=$(timeout 30 claude completions zsh </dev/null 2>/dev/null) || true
572+
# Strip \r, ANSI codes, and Node.js warning preamble; keep from first non-blank line.
573+
zsh_comp_output=$(printf '%s' "${zsh_comp_raw}" |
574+
tr -d '\r' |
575+
sed "s/${esc}\[[0-9;]*[a-zA-Z]//g" |
576+
sed '/^(node:[0-9]/d; /^Use .* --trace-warnings/d' |
577+
sed -n '/[^ ]/,$p')
578+
local zsh_comp_first=""
579+
zsh_comp_first=$(printf '%s' "${zsh_comp_output}" | head -n1)
580+
if [[ "${zsh_comp_first}" == _* ]] || [[ "${zsh_comp_first}" == "#"* ]] ||
581+
[[ "${zsh_comp_first}" == "if "* ]] || [[ "${zsh_comp_first}" == "function "* ]]; then
582+
printf '%s\n' "${zsh_comp_output}" >/usr/share/zsh/site-functions/_claude
583+
elif printf '%s' "${zsh_comp_output}" | grep -qi -e 'not logged in' -e '/login'; then
584+
log_debug "Skipping zsh completions: authentication required (expected during build)."
585+
elif [[ -n "${zsh_comp_raw}" ]]; then
586+
log_warn "Skipping zsh completions: output does not look like a valid completion script."
587+
else
588+
log_debug "Skipping zsh completions: no output from claude completions zsh."
589+
fi
547590
fi
548591

549592
# Fish completions
@@ -555,12 +598,29 @@ setup_completions() {
555598
fi
556599
done
557600
if [[ -n "${fish_comp_dir}" ]]; then
558-
timeout 30 claude completions fish </dev/null >"${fish_comp_dir}/claude.fish" 2>/dev/null || {
559-
log_warn "Failed to install fish completions."
560-
}
601+
local fish_comp_raw=""
602+
local fish_comp_output=""
603+
fish_comp_raw=$(timeout 30 claude completions fish </dev/null 2>/dev/null) || true
604+
# Strip \r, ANSI codes, and Node.js warning preamble; keep from first non-blank line.
605+
fish_comp_output=$(printf '%s' "${fish_comp_raw}" |
606+
tr -d '\r' |
607+
sed "s/${esc}\[[0-9;]*[a-zA-Z]//g" |
608+
sed '/^(node:[0-9]/d; /^Use .* --trace-warnings/d' |
609+
sed -n '/[^ ]/,$p')
610+
local fish_comp_first=""
611+
fish_comp_first=$(printf '%s' "${fish_comp_output}" | head -n1)
612+
if [[ "${fish_comp_first}" == "complete"* ]] || [[ "${fish_comp_first}" == "#"* ]]; then
613+
printf '%s\n' "${fish_comp_output}" >"${fish_comp_dir}/claude.fish"
614+
elif printf '%s' "${fish_comp_output}" | grep -qi -e 'not logged in' -e '/login'; then
615+
log_debug "Skipping fish completions: authentication required (expected during build)."
616+
elif [[ -n "${fish_comp_raw}" ]]; then
617+
log_warn "Skipping fish completions: output does not look like a valid completion script."
618+
else
619+
log_debug "Skipping fish completions: no output from claude completions fish."
620+
fi
561621
fi
562622

563-
log_info "Shell completions installed."
623+
log_info "Shell completions setup complete."
564624
}
565625

566626
setup_completions
@@ -608,13 +668,22 @@ setup_mount_docs() {
608668
log_info "============================================================"
609669
log_info "HOST CONFIG MOUNTING"
610670
log_info "============================================================"
611-
log_info "To mount your host Claude config, add this to your"
612-
log_info "devcontainer.json:"
671+
log_info "Claude Code stores state in two separate locations:"
672+
log_info " ~/.claude/ session data, MCP config, project memory"
673+
log_info " ~/.claude.json global settings, onboarding state, theme"
674+
log_info ""
675+
log_info "Both must be mounted to persist preferences across rebuilds."
676+
log_info "Add both entries to your devcontainer.json:"
613677
log_info ""
614678
log_info ' "mounts": ['
615-
log_info " \"source=\${localEnv:HOME}/.claude,target=${REMOTE_USER_HOME}/.claude,type=bind,consistency=cached,readonly\""
679+
log_info " \"source=\${localEnv:HOME}/.claude,target=${REMOTE_USER_HOME}/.claude,type=bind,consistency=cached,readonly\","
680+
log_info " \"source=\${localEnv:HOME}/.claude.json,target=${REMOTE_USER_HOME}/.claude.json,type=bind,consistency=cached,readonly\""
616681
log_info ' ]'
617682
log_info ""
683+
log_info "NOTE: ~/.claude.json must exist on the host before the container"
684+
log_info "starts — Docker creates it as a directory if absent. Run 'claude'"
685+
log_info "on the host at least once, or: echo '{}' > ~/.claude.json"
686+
log_info ""
618687
log_info "NOTE: This exposes your API keys inside the container."
619688
log_info "See README for security considerations."
620689
log_info "============================================================"

test/claude-code/default_options.sh

Lines changed: 49 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,56 @@ source "${SCRIPT_DIR}/test.sh"
77
echo "=== Scenario: default_options ==="
88
core_assertions
99

10-
echo "--- Completions ---"
11-
# At least one completion directory should have the claude file
12-
FOUND_COMPLETIONS=false
13-
for path in \
14-
/usr/share/bash-completion/completions/claude \
15-
/etc/bash_completion.d/claude \
16-
/usr/share/zsh/site-functions/_claude \
17-
/usr/share/fish/vendor_completions.d/claude.fish \
18-
/usr/share/fish/completions/claude.fish; do
19-
if [[ -f "${path}" ]]; then
20-
FOUND_COMPLETIONS=true
21-
pass "Completion file found: ${path}"
10+
echo "--- Completions: bash ---"
11+
if [[ -d /usr/share/bash-completion/completions ]]; then
12+
if [[ -f /usr/share/bash-completion/completions/claude ]]; then
13+
check_completion_file_contents /usr/share/bash-completion/completions/claude "_" "#" "if"
14+
else
15+
pass "Bash completion not written — install skipped (no valid output from completions command)"
2216
fi
23-
done
24-
if [[ "${FOUND_COMPLETIONS}" == "false" ]]; then
25-
fail "No shell completion files found"
17+
elif [[ -d /etc/bash_completion.d ]]; then
18+
if [[ -f /etc/bash_completion.d/claude ]]; then
19+
check_completion_file_contents /etc/bash_completion.d/claude "_" "#" "if"
20+
else
21+
pass "Bash completion not written — install skipped (no valid output from completions command)"
22+
fi
23+
else
24+
pass "Bash completion directory absent — skipping bash completion check"
25+
fi
26+
27+
echo "--- Completions: zsh ---"
28+
if command -v zsh >/dev/null 2>&1; then
29+
if [[ -f /usr/share/zsh/site-functions/_claude ]]; then
30+
check_completion_file_contents /usr/share/zsh/site-functions/_claude "#compdef" "#"
31+
else
32+
pass "Zsh completion not written — install skipped (no valid output from completions command)"
33+
fi
34+
else
35+
pass "zsh not installed — skipping zsh completion check"
36+
fi
37+
38+
echo "--- Completions: fish ---"
39+
# Attempt to install fish so the fish completion path is exercised.
40+
# This is a best-effort step: non-apt images (Alpine, Arch, etc.) will silently skip.
41+
apt-get install -y --no-install-recommends fish >/dev/null 2>&1 || true
42+
if command -v fish >/dev/null 2>&1; then
43+
mkdir -p /usr/share/fish/vendor_completions.d
44+
# Re-run setup_completions in a subshell so that only the function is sourced,
45+
# not the full install script (which would re-install claude).
46+
# The install script is persisted to a stable path at the end of installation.
47+
(
48+
export SHELL_COMPLETIONS="true"
49+
# shellcheck source=/dev/null
50+
source /usr/local/share/devcontainer-features/claude-code/install.sh 2>/dev/null || true
51+
setup_completions
52+
) || true
53+
if [[ -f /usr/share/fish/vendor_completions.d/claude.fish ]]; then
54+
check_completion_file_contents /usr/share/fish/vendor_completions.d/claude.fish "complete"
55+
else
56+
pass "Fish completion not written — install skipped (no valid output from completions command)"
57+
fi
58+
else
59+
pass "fish not available — skipping fish completion check"
2660
fi
2761

2862
echo "--- MCP config should be absent ---"

test/claude-code/test.sh

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,26 @@ check_file_valid_json() {
142142
fi
143143
}
144144

145+
check_completion_file_contents() {
146+
local file="$1"
147+
shift
148+
local prefixes=("$@")
149+
if [[ ! -f "${file}" ]]; then
150+
fail "Completion file missing: ${file}"
151+
return
152+
fi
153+
local first_line
154+
first_line=$(head -n1 "${file}")
155+
local prefix
156+
for prefix in "${prefixes[@]}"; do
157+
if [[ "${first_line}" == "${prefix}"* ]]; then
158+
pass "Completion file content valid (prefix '${prefix}'): ${file}"
159+
return
160+
fi
161+
done
162+
fail "Completion file has unexpected first line ('${first_line}'): ${file}"
163+
}
164+
145165
check_path_clean() {
146166
local cache_dir="$1"
147167
if [[ ! -d "${cache_dir}" ]]; then

0 commit comments

Comments
 (0)