Skip to content

Commit dd6ad39

Browse files
Install where Claude Code actually looks, and for people who use bash
Two ways a stranger could install this cleanly and get nothing. Claude Code reads its user config from $CLAUDE_CONFIG_DIR when that is set and ~/.claude otherwise. The installer only ever knew the second one. Anyone with it pointed elsewhere — containers, VMs, anyone keeping separate profiles — got all 26 skills, both manifests and every hook written into a directory Claude Code never opens, with hook paths baked to match. It reported success and did nothing, which is the worst way for an installer to be wrong. install.sh, uninstall.sh and doctor now resolve the same variable, and .claude.json follows it into the config dir the way Claude Code puts it there. The shell lane was zsh-only, and the environment half of it did not need to be. The wrapper genuinely cannot travel — claude-parity.zsh uses whence and print and is not sourceable by bash — but the env snippet and the secrets line are plain POSIX exports, and they carry the 1h prompt cache, tool concurrency, streaming and task support. A default Debian, Ubuntu or Alpine box is every cloud VM and nearly every container, and all of them installed fine and then ran with none of it. bash gets the environment now, and install.sh says out loud that the wrapper is not coming. tests/install-matrix.sh runs the installer into eight throwaway HOMEs and asserts the resulting tree rather than reading the installer's own summary: default, CLAUDE_CONFIG_DIR, a space in the home path, no jq, a bash user, a repeat run for idempotency, and both uninstall paths. It runs on Linux in CI every push, which is the environment it exists for. Reverting the config-dir fix turns it red naming exactly what broke — I checked, because a matrix that cannot fail is a longer way of writing "probably fine". Backups keep working when the config dir sits outside $HOME: those files have no home-relative form, so they are stored under files_abs/ with their full path. verify.sh 23/23, gate-falsifiability.sh 24/24, install-matrix 8/8.
1 parent e1bd425 commit dd6ad39

6 files changed

Lines changed: 316 additions & 55 deletions

File tree

‎.github/workflows/verify.yml‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,14 @@ jobs:
7171
- name: Install jq
7272
run: sudo apt-get update -qq && sudo apt-get install -y -qq jq
7373

74+
# Runs install.sh into eight throwaway HOMEs and asserts the resulting tree each time:
75+
# a plain install, one with CLAUDE_CONFIG_DIR pointed elsewhere, one whose home path
76+
# contains a space, one with jq removed from PATH, a bash user, a second run for
77+
# idempotency, and the two uninstall paths. This runner is the environment the matrix
78+
# exists for — bash by default, no Homebrew, the shape of a cloud VM or a container.
79+
- name: Install matrix (config dir, bash, spaces, no jq, idempotency, uninstall)
80+
run: ./tests/install-matrix.sh
81+
7482
- name: install.sh, for real
7583
run: ./install.sh
7684

‎README.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,21 @@ A cloud session clones a repo into a sandbox with no access to your home directo
246246
committed `.claude/` directory is the only config it can read. That is what `overlay.sh` is
247247
for. Run it in any repo you dispatch cloud work to.
248248

249+
**`CLAUDE_CONFIG_DIR` is honoured.** Set it and the install goes there instead of `~/.claude`,
250+
including the hook paths written into `settings.json` and the `.claude.json` MCP entries. If
251+
you keep separate profiles, or run in a container that puts config somewhere else, set it
252+
before installing and everything follows.
253+
254+
**Shells.** The environment lane — the 1h prompt cache, tool concurrency, streaming, task
255+
support — is written to `.zshenv` and, for bash users, to `.bashrc`. The `claude` wrapper is
256+
zsh only: it is written in zsh and cannot be sourced by bash, so on a bash machine you get the
257+
environment without the wrapper, and `install.sh` says so when it runs.
258+
259+
`tests/install-matrix.sh` runs the installer into eight throwaway HOMEs and asserts the
260+
resulting tree: default, `CLAUDE_CONFIG_DIR`, a home path with a space, no `jq`, a bash user, a
261+
second run for idempotency, and both uninstall paths. It runs on Linux in CI every push. It
262+
never touches your real home directory, so it is safe to run on the machine you work on.
263+
249264
## Credentials
250265

251266
`install.sh` copies `secrets.env.example` to `~/.config/agents/secrets.env` with mode 600 when

‎bin/doctor‎

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22
# Health-check Claude Code setup — catches silent drift (vanished hooks/agents, missing secrets).
33
set -uo pipefail
44

5+
# Claude Code reads its user config from $CLAUDE_CONFIG_DIR when set, ~/.claude otherwise.
6+
# Mirrors install.sh: anything else and this operates on a directory the install never used.
7+
CDIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
8+
if [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then CJSON="$CDIR/.claude.json"; else CJSON="$HOME/.claude.json"; fi
9+
510
# --drift: compare the INSTALLED state (~/.claude, ~/.config/agents/bin) against the vstack
611
# repo checkout it came from.
712
#
@@ -73,22 +78,22 @@ if [ "${1:-}" = "--drift" ]; then
7378
for f in "$REPO"/claude/hooks/*.sh; do
7479
[ -e "$f" ] || continue
7580
b=$(basename "$f")
76-
check_item "claude/hooks/$b" "$f" "$HOME/.claude/hooks/$b"
81+
check_item "claude/hooks/$b" "$f" "$CDIR/hooks/$b"
7782
done
7883
for f in "$REPO"/claude/agents/*.md; do
7984
[ -e "$f" ] || continue
8085
b=$(basename "$f")
81-
check_item "claude/agents/$b" "$f" "$HOME/.claude/agents/$b"
86+
check_item "claude/agents/$b" "$f" "$CDIR/agents/$b"
8287
done
8388
for f in "$REPO"/claude/commands/*.md; do
8489
[ -e "$f" ] || continue
8590
b=$(basename "$f")
86-
check_item "claude/commands/$b" "$f" "$HOME/.claude/commands/$b"
91+
check_item "claude/commands/$b" "$f" "$CDIR/commands/$b"
8792
done
8893
for d in "$REPO"/claude/skills/*/; do
8994
[ -d "$d" ] || continue
9095
s=$(basename "$d")
91-
check_item "claude/skills/$s" "${d%/}" "$HOME/.claude/skills/$s"
96+
check_item "claude/skills/$s" "${d%/}" "$CDIR/skills/$s"
9297
done
9398
for f in "$REPO"/bin/*; do
9499
[ -e "$f" ] || continue
@@ -99,8 +104,8 @@ if [ "${1:-}" = "--drift" ]; then
99104
# has been running a while", and it was the one file --drift never looked at. Hand-edit it,
100105
# get "no drift", then lose the edit on the next update. Same for the statusline and the
101106
# shell lane.
102-
check_item "claude/CLAUDE.md" "$REPO/claude/CLAUDE.md" "$HOME/.claude/CLAUDE.md"
103-
check_item "claude/statusline.sh" "$REPO/claude/statusline.sh" "$HOME/.claude/statusline.sh"
107+
check_item "claude/CLAUDE.md" "$REPO/claude/CLAUDE.md" "$CDIR/CLAUDE.md"
108+
check_item "claude/statusline.sh" "$REPO/claude/statusline.sh" "$CDIR/statusline.sh"
104109
[ -f "$REPO/conductor/settings.managed.toml" ] && \
105110
check_item "conductor/settings.managed.toml" "$REPO/conductor/settings.managed.toml" "$HOME/.conductor/settings.managed.toml"
106111
[ -f "$REPO/shell/claude-parity.zsh" ] && \
@@ -121,7 +126,7 @@ if [ "${1:-}" = "--drift" ]; then
121126
# and have no business in a public repo. Anything outside both sets is unclassified — not
122127
# necessarily wrong, but nobody has decided what it is, which is exactly the state the dead
123128
# skillOverrides and sandbox blocks sat in.
124-
if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.claude/settings.json" ]; then
129+
if command -v jq >/dev/null 2>&1 && [ -f "$CDIR/settings.json" ]; then
125130
# Claude Code's own settings belong here even though vstack never writes them. Leaving one
126131
# out pushes the reader toward the wrong remedy: doctor offers "ship it" or "retire it", and
127132
# retiring a key Claude Code owns means install.sh deletes a working feature. `sandbox` is
@@ -131,7 +136,7 @@ if [ "${1:-}" = "--drift" ]; then
131136
extra=$(jq -r --slurpfile repo "$REPO/claude/settings.json" --argjson us "$USER_SCOPE" '
132137
($repo[0] | keys) as $shipped
133138
| keys - $shipped - $us
134-
| .[]' "$HOME/.claude/settings.json" 2>/dev/null)
139+
| .[]' "$CDIR/settings.json" 2>/dev/null)
135140
if [ -n "$extra" ]; then
136141
echo "unclassified settings.json key(s) not shipped by the repo and not user-scope:"
137142
printf ' %s\n' $extra
@@ -153,22 +158,22 @@ if [ "${1:-}" = "--drift" ]; then
153158
# pdf, docx, pptx, xlsx) link in from elsewhere and are expected.
154159
RUNTIME_TOP='projects sessions session-env shell-snapshots tasks todos plans telemetry plugins cache chrome daemon downloads paste-cache jobs ide statsig backups scheduled-tasks history.jsonl stats-cache.json mcp-needs-auth-cache.json settings.json settings.local.json .credentials.json .last-update-result.json .last-cleanup .cc-writes CLAUDE.md statusline.sh hooks agents commands skills'
155160
stale=""
156-
for p in "$HOME"/.claude/* "$HOME"/.claude/.[!.]*; do
161+
for p in "$CDIR"/* "$CDIR"/.[!.]*; do
157162
[ -e "$p" ] || continue
158163
b=$(basename "$p")
159164
case " $RUNTIME_TOP " in *" $b "*) continue ;; esac
160165
stale="$stale $b"
161166
done
162167
for kind in hooks:'*.sh' agents:'*.md' commands:'*.md'; do
163168
d=${kind%%:*}; pat=${kind#*:}
164-
for p in "$HOME"/.claude/"$d"/$pat; do
169+
for p in "$CDIR"/"$d"/$pat; do
165170
[ -e "$p" ] || continue
166171
[ -L "$p" ] && continue
167172
b=$(basename "$p")
168173
[ -e "$REPO/claude/$d/$b" ] || stale="$stale $d/$b"
169174
done
170175
done
171-
for p in "$HOME"/.claude/skills/*/; do
176+
for p in "$CDIR"/skills/*/; do
172177
[ -d "$p" ] || continue
173178
s=$(basename "$p")
174179
[ -L "${p%/}" ] && continue
@@ -259,17 +264,17 @@ fi
259264
echo "── hooks ──"
260265
# inject-orchestrator + inject-tokenmaxxing were merged into inject-session-context (2026-08-10)
261266
for f in $HOOKS; do
262-
[ -x "$HOME/.claude/hooks/$f.sh" ] && ok "$f.sh" || bad "$f.sh" "missing/not exec"; done
267+
[ -x "$CDIR/hooks/$f.sh" ] && ok "$f.sh" || bad "$f.sh" "missing/not exec"; done
263268
[ -x "$HOME/.config/agents/bin/claude-bg.sh" ] && ok "claude-bg.sh (headless)" || bad "claude-bg.sh" "missing"
264-
n=$(ls "$HOME/.claude/agents"/*.md 2>/dev/null | wc -l | tr -d ' ')
269+
n=$(ls "$CDIR/agents"/*.md 2>/dev/null | wc -l | tr -d ' ')
265270
[ "$n" -ge "$WANT_AGENTS" ] && ok "subagents ($n)" || bad "subagents" "$n<$WANT_AGENTS"
266271
echo "── secrets ──"
267272
[ -f "$HOME/.config/agents/secrets.env" ] && [ "$(get_perms "$HOME/.config/agents/secrets.env")" = "600" ] && ok "secrets.env (600)" || bad "secrets.env" "missing/perms"
268273
grep -q 'secrets.env' "$HOME/.zshenv" 2>/dev/null && ok "sourced in .zshenv" || bad "zshenv source" "not sourced"
269274
for w in cloudflare-mcp; do [ -x "$HOME/.config/agents/bin/$w" ] && ok "wrapper $w" || bad "wrapper $w" "missing"; done
270275
echo "── deploy ──"; [ -x "$HOME/.config/agents/bin/deploy-auto.sh" ] && ok "deploy-auto.sh" || bad "deploy-auto.sh" "missing"
271276
echo "── memory ──"; [ -d "$HOME/.claude-mem" ] && ok "claude-mem store" || bad "claude-mem" "missing"
272-
S="$HOME/.claude/settings.json"
277+
S="$CDIR/settings.json"
273278
echo "── parity ──"
274279
[ -z "${ANTHROPIC_API_KEY:-}" ] && ok "no ANTHROPIC_API_KEY in env" || bad "ANTHROPIC_API_KEY" "set — billing API credits, not the Max sub"
275280
AS=$(claude auth status 2>/dev/null)
@@ -290,7 +295,7 @@ jq -e '.model=="claude-opus-5[1m]" and .effortLevel=="high" and .fastMode==true'
290295
jq -e '.skillListingMaxDescChars and .skillListingBudgetFraction and (.skillOverrides|length>0)' "$S" >/dev/null 2>&1 \
291296
&& ok "context caps (skill listing)" || bad "context caps" "drifted"
292297
# claude-mem auto-updates and will silently revert this async flag.
293-
CM=$(ls -d "$HOME"/.claude/plugins/cache/thedotmack/claude-mem/*/hooks/hooks.json 2>/dev/null | tail -1)
298+
CM=$(ls -d "$CDIR"/plugins/cache/thedotmack/claude-mem/*/hooks/hooks.json 2>/dev/null | tail -1)
294299
if [ -n "$CM" ]; then
295300
jq -e '[.hooks.UserPromptSubmit[].hooks[].async] | all' "$CM" >/dev/null 2>&1 \
296301
&& ok "claude-mem UserPromptSubmit async" || bad "claude-mem UserPromptSubmit" "sync again (plugin updated) — re-apply"

‎install.sh‎

Lines changed: 73 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@
55
# timestamped backup dir first, and it never touches secrets you already have.
66
#
77
# Lanes it materialises:
8-
# ~/.claude/ hooks, agents, commands, skills, settings
8+
# $CLAUDE_CONFIG_DIR, or ~/.claude hooks, agents, commands, skills, settings
99
# ~/.config/agents/bin/ CLI wrappers (deploy, headless runner, MCP shims, doctor)
1010
# ~/.config/agents/shell/ zsh parity wrapper + env snippet, wired into .zshrc/.zshenv
11-
# ~/.claude.json MCP server entries (merged, never clobbered)
11+
# .claude.json MCP server entries (merged, never clobbered)
1212
#
1313
# Usage:
1414
# ./install.sh install the config
@@ -20,6 +20,17 @@
2020
set -euo pipefail
2121

2222
SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
23+
24+
# Claude Code reads its user config from $CLAUDE_CONFIG_DIR when that is set, and from
25+
# ~/.claude otherwise. vstack hardcoded ~/.claude, so anyone running with CLAUDE_CONFIG_DIR
26+
# pointed elsewhere — VMs, containers, and anyone keeping separate profiles — got a complete,
27+
# clean-looking install into a directory Claude Code never reads. It failed silently and
28+
# looked like success, which is the worst way for an installer to be wrong.
29+
#
30+
# .claude.json follows the same rule: it sits inside the config dir when one is named, and
31+
# beside it at ~/.claude.json when it is not.
32+
CDIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
33+
if [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then CJSON="$CDIR/.claude.json"; else CJSON="$HOME/.claude.json"; fi
2334
BK="$HOME/.config/agents/backups/install-$(date +%Y%m%d-%H%M%S)"
2435
DRY=0
2536
WITH_DEPS=0
@@ -52,17 +63,23 @@ HAVE_JQ=1
5263
command -v jq >/dev/null || { HAVE_JQ=0; echo "warn: jq not found — settings and MCP merge will be skipped (brew install jq / apt install jq)" >&2; }
5364

5465
if [ "$DRY" = 0 ]; then
55-
mkdir -p "$BK" "$HOME/.claude/hooks" "$HOME/.claude/agents" "$HOME/.claude/commands" \
56-
"$HOME/.claude/skills" \
66+
mkdir -p "$BK" "$CDIR/hooks" "$CDIR/agents" "$CDIR/commands" \
67+
"$CDIR/skills" \
5768
"$HOME/.config/agents/bin" "$HOME/.config/agents/shell"
5869
chmod 700 "$HOME/.config/agents/backups"
5970
fi
6071
# Backups preserve the real path under files/ — the old flat `tr / _` names were a lossy
6172
# encoding that misparsed any future filename containing an underscore on restore.
6273
back(){ [ "$DRY" = 1 ] && return 0; [ -f "$1" ] || return 0
63-
rel="${1#$HOME/}"
64-
mkdir -p "$BK/files/$(dirname "$rel")"
65-
cp "$1" "$BK/files/$rel"
74+
# Paths under $HOME are stored HOME-relative so uninstall can map them back. A config dir
75+
# moved outside $HOME by CLAUDE_CONFIG_DIR has no such relative form, so it is stored under
76+
# files_abs/ with its full path and restored to exactly where it came from.
77+
case "$1" in
78+
"$HOME"/*) rel="${1#$HOME/}"; dest="$BK/files/$rel" ;;
79+
*) dest="$BK/files_abs${1}" ;;
80+
esac
81+
mkdir -p "$(dirname "$dest")"
82+
cp "$1" "$dest"
6683
return 0
6784
}
6885

@@ -84,20 +101,20 @@ if [ "$DRY" = 0 ] && [ -f "$SRC/.claude/verify.sh" ]; then
84101
fi
85102

86103
# --- hooks / agents / commands ------------------------------------------------------------
87-
for f in "$SRC"/claude/hooks/*.sh; do back "$HOME/.claude/hooks/$(basename "$f")"; run cp "$f" "$HOME/.claude/hooks/"; done
88-
for f in "$SRC"/claude/agents/*.md; do back "$HOME/.claude/agents/$(basename "$f")"; run cp "$f" "$HOME/.claude/agents/"; done
89-
for f in "$SRC"/claude/commands/*.md; do back "$HOME/.claude/commands/$(basename "$f")"; run cp "$f" "$HOME/.claude/commands/"; done
90-
[ "$DRY" = 0 ] && chmod 755 "$HOME"/.claude/hooks/*.sh
104+
for f in "$SRC"/claude/hooks/*.sh; do back "$CDIR/hooks/$(basename "$f")"; run cp "$f" "$CDIR/hooks/"; done
105+
for f in "$SRC"/claude/agents/*.md; do back "$CDIR/agents/$(basename "$f")"; run cp "$f" "$CDIR/agents/"; done
106+
for f in "$SRC"/claude/commands/*.md; do back "$CDIR/commands/$(basename "$f")"; run cp "$f" "$CDIR/commands/"; done
107+
[ "$DRY" = 0 ] && chmod 755 "$CDIR"/hooks/*.sh
91108
say "installed hooks, agents, commands"
92109

93110
# --- global directives + statusline ---------------------------------------------------------
94111
# CLAUDE.md is the standing instruction file every session reads. It is backed up first: it is
95112
# the file most likely to have been hand-edited on a machine that has been running a while.
96-
back "$HOME/.claude/CLAUDE.md"
97-
run cp "$SRC/claude/CLAUDE.md" "$HOME/.claude/CLAUDE.md"
98-
back "$HOME/.claude/statusline.sh"
99-
run cp "$SRC/claude/statusline.sh" "$HOME/.claude/statusline.sh"
100-
[ "$DRY" = 0 ] && chmod 755 "$HOME/.claude/statusline.sh"
113+
back "$CDIR/CLAUDE.md"
114+
run cp "$SRC/claude/CLAUDE.md" "$CDIR/CLAUDE.md"
115+
back "$CDIR/statusline.sh"
116+
run cp "$SRC/claude/statusline.sh" "$CDIR/statusline.sh"
117+
[ "$DRY" = 0 ] && chmod 755 "$CDIR/statusline.sh"
101118
say "installed CLAUDE.md, statusline.sh"
102119

103120
# --- conductor user settings ------------------------------------------------------------------
@@ -125,18 +142,18 @@ fi
125142
for d in "$SRC"/claude/skills/*/; do
126143
s=$(basename "$d")
127144
[ "$DRY" = 1 ] && { say "would: install skill $s"; continue; }
128-
[ -d "$HOME/.claude/skills/$s" ] && cp -R "$HOME/.claude/skills/$s" "$BK/skills_$s"
129-
rm -rf "${HOME:?}/.claude/skills/$s"
145+
[ -d "$CDIR/skills/$s" ] && cp -R "$CDIR/skills/$s" "$BK/skills_$s"
146+
rm -rf "${CDIR:?}/skills/$s"
130147
# NB: strip the trailing slash. BSD/macOS `cp -R src/ dest/` copies src CONTENTS into dest,
131148
# not src itself, which would scatter SKILL.md and references/ across the skills root.
132-
cp -R "${d%/}" "$HOME/.claude/skills/"
149+
cp -R "${d%/}" "$CDIR/skills/"
133150
done
134151
# The licence and the attribution travel with the skills. Shipping LICENSE.pstack alone left
135152
# the installed tree claiming one origin for skills that actually come from four.
136153
for meta in LICENSE.pstack ATTRIBUTION.md; do
137-
[ -f "$SRC/claude/skills/$meta" ] && run cp "$SRC/claude/skills/$meta" "$HOME/.claude/skills/"
154+
[ -f "$SRC/claude/skills/$meta" ] && run cp "$SRC/claude/skills/$meta" "$CDIR/skills/"
138155
done
139-
[ "$DRY" = 0 ] && find "$HOME/.claude/skills" -name "*.sh" -exec chmod 755 {} + 2>/dev/null
156+
[ "$DRY" = 0 ] && find "$CDIR/skills" -name "*.sh" -exec chmod 755 {} + 2>/dev/null
140157
say "installed skills ($(find "$SRC"/claude/skills -maxdepth 1 -mindepth 1 -type d | wc -l | tr -d ' '))"
141158

142159
# --- agent bin ----------------------------------------------------------------------------
@@ -188,7 +205,7 @@ fi
188205
# git show "${s}:claude/settings.json" | jq -r 'keys[]'; done | sort -u
189206
# that the key appears there and not in the current file. Check 21 enforces exactly that.
190207
RETIRED='[]'
191-
US="$HOME/.claude/settings.json"; back "$US"
208+
US="$CDIR/settings.json"; back "$US"
192209
[ -f "$US" ] || { [ "$DRY" = 0 ] && echo '{}' > "$US"; }
193210
if [ "$DRY" = 0 ] && [ "$HAVE_JQ" = 0 ]; then
194211
# No jq: never hand-merge JSON. Write the portable settings only when there is nothing
@@ -201,7 +218,7 @@ if [ "$DRY" = 0 ] && [ "$HAVE_JQ" = 0 ]; then
201218
fi
202219
elif [ "$DRY" = 0 ]; then
203220
tmp=$(mktemp)
204-
jq -s --arg h "$HOME/.claude/hooks" --argjson retired "$RETIRED" '
221+
jq -s --arg h "$CDIR/hooks" --argjson retired "$RETIRED" '
205222
((.[1] | del(.hooks)) as $portable
206223
| (.[0] * $portable)
207224
| .skillOverrides = ($portable.skillOverrides // {})
@@ -235,7 +252,7 @@ fi
235252
# --- MCP servers ---------------------------------------------------------------------------
236253
# Merged into the GLOBAL mcpServers map. Ours win on key collision; anything else you have
237254
# configured is preserved. Project-scoped servers stay yours to add (see mcp/README).
238-
CJ="$HOME/.claude.json"
255+
CJ="$CJSON"
239256
if [ "$HAVE_JQ" = 0 ]; then
240257
say "skipped MCP merge (no jq)"
241258
elif [ -f "$CJ" ] && [ "$DRY" = 0 ]; then
@@ -246,9 +263,9 @@ elif [ -f "$CJ" ] && [ "$DRY" = 0 ]; then
246263
"$CJ" "$tmp.servers" > "$tmp"
247264
jq -e . "$tmp" >/dev/null && cat "$tmp" > "$CJ"
248265
rm -f "$tmp" "$tmp.servers"
249-
say "merged MCP servers into ~/.claude.json"
266+
say "merged MCP servers into $CJSON"
250267
else
251-
say "skipped MCP merge (no ~/.claude.json yet — run claude once, then re-run this)"
268+
say "skipped MCP merge (no $CJSON yet — run claude once, then re-run this)"
252269
fi
253270

254271
# --- shell lane ----------------------------------------------------------------------------
@@ -264,7 +281,36 @@ fi
264281
if [ "$DRY" = 0 ] && ! grep -q 'agents/secrets.env' "$HOME/.zshenv" 2>/dev/null; then
265282
printf '\n[ -f "$HOME/.config/agents/secrets.env" ] && set -a && . "$HOME/.config/agents/secrets.env" && set +a\n' >> "$HOME/.zshenv"
266283
fi
267-
say "installed shell lane (.zshrc, .zshenv)"
284+
285+
# bash gets the same environment. The wrapper does not travel — claude-parity.zsh is written
286+
# in zsh (whence -p, print -r, local -a) and cannot be sourced by bash — but the env snippet
287+
# and the secrets line are plain POSIX exports, and they are the part that actually changes
288+
# behaviour: the 1h prompt cache, tool concurrency, streaming, task support.
289+
#
290+
# Only zsh users got any of it, which meant a default Debian, Ubuntu or Alpine box — every
291+
# cloud VM and nearly every container — installed cleanly and then ran with none of it. Both
292+
# rc files are written when both shells are present, because a machine can have both.
293+
SHELL_LANES=".zshrc, .zshenv"
294+
if [ "$DRY" = 0 ]; then
295+
for rc in .bashrc .profile; do
296+
# .profile only when there is no .bashrc: writing both double-exports on login shells.
297+
[ "$rc" = .profile ] && [ -f "$HOME/.bashrc" ] && continue
298+
[ "$rc" = .bashrc ] || [ -f "$HOME/$rc" ] || [ -n "${BASH_VERSION:-}" ] || continue
299+
back "$HOME/$rc"
300+
if ! grep -q '>>> claude-parity env >>>' "$HOME/$rc" 2>/dev/null; then
301+
cat "$SRC/shell/zshenv.snippet" >> "$HOME/$rc"
302+
SHELL_LANES="$SHELL_LANES, $rc"
303+
fi
304+
if ! grep -q 'agents/secrets.env' "$HOME/$rc" 2>/dev/null; then
305+
printf '\n[ -f "$HOME/.config/agents/secrets.env" ] && set -a && . "$HOME/.config/agents/secrets.env" && set +a\n' >> "$HOME/$rc"
306+
fi
307+
done
308+
case "${SHELL:-}" in
309+
*zsh) ;;
310+
*) say "note the claude wrapper is zsh-only; \$SHELL is ${SHELL:-unset}, so you get the env lane without it" ;;
311+
esac
312+
fi
313+
say "installed shell lane ($SHELL_LANES)"
268314

269315
# --- verify ----------------------------------------------------------------------------------
270316
say ""

0 commit comments

Comments
 (0)