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
2020set -euo pipefail
2121
2222SRC=" $( 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
2334BK=" $HOME /.config/agents/backups/install-$( date +%Y%m%d-%H%M%S) "
2435DRY=0
2536WITH_DEPS=0
@@ -52,17 +63,23 @@ HAVE_JQ=1
5263command -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
5465if [ " $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"
5970fi
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.
6273back (){ [ " $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
84101fi
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
91108say " 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"
101118say " installed CLAUDE.md, statusline.sh"
102119
103120# --- conductor user settings ------------------------------------------------------------------
125142for 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/"
133150done
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.
136153for 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/"
138155done
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
140157say " installed skills ($( find " $SRC " /claude/skills -maxdepth 1 -mindepth 1 -type d | wc -l | tr -d ' ' ) )"
141158
142159# --- agent bin ----------------------------------------------------------------------------
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.
190207RETIRED=' []'
191- US=" $HOME /.claude /settings.json" ; back " $US "
208+ US=" $CDIR /settings.json" ; back " $US "
192209[ -f " $US " ] || { [ " $DRY " = 0 ] && echo ' {}' > " $US " ; }
193210if [ " $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
202219elif [ " $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 // {})
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 "
239256if [ " $HAVE_JQ " = 0 ]; then
240257 say " skipped MCP merge (no jq)"
241258elif [ -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 "
250267else
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)"
252269fi
253270
254271# --- shell lane ----------------------------------------------------------------------------
264281if [ " $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"
266283fi
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 ----------------------------------------------------------------------------------
270316say " "
0 commit comments