#!/usr/bin/env bash # ============================================================ # Claude Code — Update all components # Pulls latest config, updates submodules, refreshes symlinks, # and runs doctor to verify. # ============================================================ set -euo pipefail GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m' ok() { echo -e "${GREEN}✓${NC} $1"; } warn() { echo -e "${YELLOW}⚠${NC} $1"; } info() { echo -e "${BLUE}→${NC} $1"; } REPO="$(cd "$(dirname "$0")" && pwd)" VERSION=$(cat "$REPO/version.txt" 2>/dev/null || echo "unknown") # Load shared detection library # shellcheck source=lib/detect-plugins.sh disable=SC1091 source "$REPO/lib/detect-plugins.sh" # shellcheck source=lib/gstack-playwright.sh disable=SC1091 source "$REPO/lib/gstack-playwright.sh" # shellcheck source=lib/gstack-links.sh disable=SC1091 source "$REPO/lib/gstack-links.sh" echo "" echo "═══ claude-config update (v${VERSION}) ═══" echo "" # ── 0. Update Claude Code CLI ── echo "── Updating Claude Code CLI..." if command -v claude &>/dev/null; then CURRENT_VER=$(claude --version 2>/dev/null | head -1 || echo "unknown") info "Current: $CURRENT_VER" # Use the updater that matches the install channel: npm-managed installs # update via npm; native-installer installs self-update via `claude update` # (npm would EEXIST on the ~/.local/bin/claude symlink it does not own). if npm ls -g @anthropic-ai/claude-code &>/dev/null; then UPDATE_CMD=(npm install -g @anthropic-ai/claude-code@latest) else UPDATE_CMD=(claude update) fi if "${UPDATE_CMD[@]}" &>/dev/null; then NEW_VER=$(claude --version 2>/dev/null | head -1 || echo "unknown") if [ "$CURRENT_VER" = "$NEW_VER" ]; then ok "Claude Code already up to date ($NEW_VER)" else ok "Claude Code updated: $CURRENT_VER → $NEW_VER" fi else warn "Claude Code update failed — try manually: ${UPDATE_CMD[*]}" fi else warn "Claude Code not found — install first with: make install" fi echo "" # ── 1. Pull latest config ── echo "── Pulling latest config..." cd "$REPO" if git pull --rebase 2>/dev/null; then ok "Config repo updated" else warn "git pull failed — check for uncommitted changes" fi # ── 2. Update GStack submodule ── echo "" echo "── Updating GStack submodule..." warn "GStack tracks branch = main (no commit hash). Review upstream commits before updating." echo "" # TTY guard: in a non-interactive run (cron, CI, background shell) `read` # hits EOF and dies under set -e — the whole update aborted mid-script. # Default to the safe N and keep going; interactive behavior unchanged. if [ -t 0 ]; then printf " Proceed with GStack update? [y/N] " read -r _gstack_confirm else info "Non-interactive run — skipping GStack update (run in a terminal to be prompted)" _gstack_confirm="n" fi if [[ "$_gstack_confirm" =~ ^[Yy]$ ]]; then # Capture gstack state before the update so we can restore it after # ./setup runs (setup re-creates every symlink; without this, an # update would silently re-enable a tool the user had disabled). _gstack_state="unknown" if [ -x "$REPO/lib/toggle-external.sh" ]; then _gstack_state=$(bash "$REPO/lib/toggle-external.sh" status gstack 2>/dev/null || echo "unknown") fi if gstack_submodule_update_with_bump "$REPO"; then if [ -d "skills-external/gstack" ]; then if [ -x "skills-external/gstack/setup" ]; then if (cd skills-external/gstack && ./setup) 2>/dev/null; then ok "GStack updated" else warn "GStack ./setup failed — submodule updated but setup did not complete" fi else warn "GStack ./setup not found or not executable — skipping" ok "GStack submodule pointer updated" fi fi else warn "GStack submodule update failed — run: git submodule update --init" fi # Refresh the gstack shared helper tree (bin/, browse/dist/, ETHOS.md, # …) — see lib/gstack-links.sh. GSTACK_DIR="$REPO/skills-external/gstack" GSTACK_DST="$HOME/.claude/skills/gstack" if [ -d "$GSTACK_DIR" ]; then link_gstack_helpers "$GSTACK_DIR" "$GSTACK_DST" >/dev/null fi # Restore prior enabled/disabled state if [ "$_gstack_state" = "disabled" ] && [ -x "$REPO/lib/toggle-external.sh" ]; then bash "$REPO/lib/toggle-external.sh" disable gstack >/dev/null info "gstack was disabled before update — restored to disabled" fi else info "GStack update skipped" fi # ── 3. Update RTK (if pinned version available) ── echo "" echo "── Updating RTK..." # cargo lives in ~/.cargo/bin, which hand-managed profiles lose (BLK-016 # class) — source cargo env, as install-plugins.sh does, before concluding # cargo is absent. Without this the step silently never updated rtk. if ! command -v cargo &>/dev/null && [ -f "$HOME/.cargo/env" ]; then # shellcheck disable=SC1091 source "$HOME/.cargo/env" fi if command -v cargo &>/dev/null; then RTK_VERSION="" if [ -f "$REPO/plugins.lock.json" ] && command -v python3 &>/dev/null; then RTK_VERSION=$(python3 -c " import json with open('$REPO/plugins.lock.json') as f: d = json.load(f) print(d.get('rtk',{}).get('version','')) " 2>/dev/null || true) fi # Version-jump guard: a cargo build takes minutes — only pay it when the # target (pin, or the newest remote tag for "latest") differs from what is # installed. Same pin-honored/skip-on-match shape as the semgrep step. RTK_CUR=$(rtk --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true) [ -z "$RTK_CUR" ] && RTK_CUR=$("$HOME/.cargo/bin/rtk" --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true) if [ -n "$RTK_VERSION" ] && [ "$RTK_VERSION" != "latest" ]; then if [ "${RTK_VERSION#v}" = "$RTK_CUR" ]; then ok "rtk already at pinned $RTK_CUR" else info "Pinned version: $RTK_VERSION (installed: ${RTK_CUR:-none})" info "Compiling from source — this may take a few minutes..." if cargo install --git https://github.com/rtk-ai/rtk --tag "$RTK_VERSION" --force; then ok "RTK updated to $RTK_VERSION" else warn "RTK update failed" fi fi else # "latest" = newest release TAG, resolved by name and installed BY TAG. # (A bare `cargo install --git` builds the default-branch HEAD, whose # Cargo.toml version can trail the newest tag — the guard would then # never converge and recompile on every run.) RTK_TIP_TAG=$(git ls-remote --tags https://github.com/rtk-ai/rtk 2>/dev/null \ | sed -n 's|.*refs/tags/\(v\{0,1\}[0-9][0-9.]*\)$|\1|p' | sort -V | tail -1 || true) RTK_TIP="${RTK_TIP_TAG#v}" if [ -n "$RTK_TIP" ] && [ "$RTK_TIP" = "$RTK_CUR" ]; then ok "rtk already at latest tag ($RTK_CUR)" else info "No pin — latest tag: ${RTK_TIP_TAG:-unknown} (installed: ${RTK_CUR:-none})" info "Compiling from source — this may take a few minutes..." if [ -n "$RTK_TIP_TAG" ] && cargo install --git https://github.com/rtk-ai/rtk --tag "$RTK_TIP_TAG" --force; then ok "RTK updated to $RTK_TIP_TAG" elif [ -z "$RTK_TIP_TAG" ] && cargo install --git https://github.com/rtk-ai/rtk --force; then ok "RTK updated (latest HEAD — no tag resolvable)" else warn "RTK update failed" fi fi fi else warn "Cargo not available — skipping RTK" fi # ── 4. Update GSD v2 ── echo "" echo "── Updating GSD v2 (gsd-pi)..." if command -v gsd &>/dev/null; then GSD_VER="" if [ -f "$REPO/plugins.lock.json" ] && command -v python3 &>/dev/null; then GSD_VER=$(python3 -c " import json with open('$REPO/plugins.lock.json') as f: d = json.load(f) print(d.get('gsd',{}).get('version','')) " 2>/dev/null || true) fi if [ -n "$GSD_VER" ] && [ "$GSD_VER" != "latest" ]; then info "Pinned version: $GSD_VER" if npm install -g "gsd-pi@${GSD_VER}" 2>/dev/null; then ok "GSD v2 updated to $GSD_VER" else warn "GSD v2 update failed" fi else info "No pinned version — installing latest" if npm install -g gsd-pi 2>/dev/null; then ok "GSD v2 updated (latest)" else warn "GSD v2 update failed" fi fi else warn "GSD v2 not installed — skipping (run: npm install -g gsd-pi)" fi # ── 5. Update Context7 CLI ── echo "" echo "── Updating Context7 CLI..." if command -v ctx7 &>/dev/null; then CTX7_VER="" if [ -f "$REPO/plugins.lock.json" ] && command -v python3 &>/dev/null; then CTX7_VER=$(python3 -c " import json with open('$REPO/plugins.lock.json') as f: d = json.load(f) print(d.get('ctx7',{}).get('version','')) " 2>/dev/null || true) fi if [ -n "$CTX7_VER" ] && [ "$CTX7_VER" != "latest" ]; then info "Pinned version: $CTX7_VER" if npm install -g "ctx7@${CTX7_VER}" 2>/dev/null; then ok "ctx7 updated to $CTX7_VER" else warn "ctx7 update failed" fi else if npm install -g ctx7@latest 2>/dev/null; then ok "ctx7 updated (latest)" else warn "ctx7 update failed" fi fi else info "ctx7 not installed — skipping" fi # ── 6. Update Graphifyy ── echo "" echo "── Updating Graphifyy..." if command -v graphify &>/dev/null; then if pipx upgrade graphifyy 2>/dev/null; then ok "graphifyy updated" else warn "graphifyy update failed — try: pipx upgrade graphifyy" fi else info "graphifyy not installed — skipping" fi # ── 6.2. Update Semgrep (pin-honored — BLOCKING security gate) ── echo "" echo "── Updating Semgrep..." if command -v semgrep &>/dev/null; then SEMGREP_VER="" if [ -f "$REPO/plugins.lock.json" ] && command -v python3 &>/dev/null; then SEMGREP_VER=$(python3 -c " import json with open('$REPO/plugins.lock.json') as f: d = json.load(f) print(d.get('semgrep',{}).get('version','')) " 2>/dev/null || true) fi SEMGREP_CUR=$(semgrep --version 2>/dev/null | head -1) if [ -n "$SEMGREP_VER" ] && [ "$SEMGREP_VER" != "latest" ]; then if [ "$SEMGREP_CUR" = "$SEMGREP_VER" ]; then ok "semgrep already at pinned $SEMGREP_VER" else # Jump shown explicitly: semgrep is a BLOCKING gate — a version bump # can add rules that BLOCK unchanged code, so the jump must be a # visible, deliberate human decision (bump the pin, then update). info "semgrep ${SEMGREP_CUR:-?} → ${SEMGREP_VER} (pinned in plugins.lock.json)" if pipx install --force "semgrep==${SEMGREP_VER}" 2>/dev/null; then ok "semgrep updated to $SEMGREP_VER" else warn "semgrep update failed — try: pipx install --force semgrep==${SEMGREP_VER}" fi fi else info "No pinned version — upgrading to latest" if pipx upgrade semgrep 2>/dev/null; then ok "semgrep updated ($(semgrep --version 2>/dev/null | head -1))" else warn "semgrep update failed — try: pipx upgrade semgrep" fi fi else info "semgrep not installed — skipping (run: make plugin)" fi # ── 6.5. Update bun ── echo "" echo "── Updating bun..." if command -v bun &>/dev/null; then if bun upgrade >/dev/null 2>&1; then ok "bun $(bun --version 2>/dev/null || echo '?') (self-upgrade)" else warn "bun upgrade failed — try manually: bun upgrade" fi else info "bun not installed — skipping" fi # NOT updated here, deliberately (audit 2026-07-02): # - graphify Claude integration (`graphify claude install`): rewrites curated # CLAUDE.md / .claude/settings.json (BDR-028 guard territory) — re-run # MANUALLY only if a graphify upgrade changes its hook format. # - gsd: pinned in plugins.lock.json — Step 4 reinstalls the PIN, it does not # advance it. Bump the lock deliberately, then re-run. # ── 7. Update Emil Design Engineering skill ── echo "" echo "── Updating Emil Design Engineering..." EMIL_DIR="$REPO/skills-external/emil-design-eng" EMIL_URL="https://raw.githubusercontent.com/emilkowalski/skill/main/skills/emil-design-eng/SKILL.md" if [ -d "$EMIL_DIR" ]; then info "Fetching latest SKILL.md from emilkowalski/skill..." if curl -fsSL "$EMIL_URL" -o "$EMIL_DIR/SKILL.md.tmp" \ && mv "$EMIL_DIR/SKILL.md.tmp" "$EMIL_DIR/SKILL.md"; then ok "emil-design-eng updated" else warn "emil-design-eng update failed" fi else info "emil-design-eng not installed — skipping (run: make plugin)" fi # ── 7.1. Update Frontend Design (from anthropic-agent-skills plugin cache) ── echo "" echo "── Updating Frontend Design (Anthropic)..." FD_DIR="$REPO/skills-external/frontend-design" FD_PLUGIN_CACHE="$HOME/.claude/plugins/cache/anthropic-agent-skills/example-skills" FD_LATEST="$(find "$FD_PLUGIN_CACHE" -maxdepth 1 -type d 2>/dev/null | sort | tail -1)" if [ -d "$FD_DIR" ]; then if [ -n "$FD_LATEST" ] && [ -f "$FD_LATEST/skills/frontend-design/SKILL.md" ]; then cp "$FD_LATEST/skills/frontend-design/SKILL.md" "$FD_DIR/SKILL.md" [ -f "$FD_LATEST/skills/frontend-design/LICENSE.txt" ] && cp "$FD_LATEST/skills/frontend-design/LICENSE.txt" "$FD_DIR/LICENSE.txt" ok "frontend-design synced from plugin cache" else warn "frontend-design: anthropic-agent-skills cache not found — keeping current version" fi else info "frontend-design not installed — skipping (run: make plugin)" fi # ── 7.2. Update Design Motion Principles (from GitHub) ── echo "" echo "── Updating Design Motion Principles (kylezantos)..." DMP_DIR="$REPO/skills-external/design-motion-principles" if [ -d "$DMP_DIR" ]; then DMP_TMP="$(mktemp -d)" if git clone --depth 1 https://github.com/kylezantos/design-motion-principles.git "$DMP_TMP" 2>/dev/null; then cp -r "$DMP_TMP/skills/design-motion-principles/"* "$DMP_DIR/" ok "design-motion-principles synced from GitHub" else warn "design-motion-principles: GitHub fetch failed — keeping current version" fi rm -rf "$DMP_TMP" else info "design-motion-principles not installed — skipping" fi # ── 7.3. Update Agent Skills + Mengto scroll skills + superpowers # (pinned commit) — all three re-fetched at the SAME pinned commit # (never advances the pin) via the shared lib/vendor-skills.sh helper — # see install-plugins.sh Step 8e. echo "" echo "── Updating Agent Skills (addyosmani/agent-skills)..." # shellcheck source=lib/vendor-skills.sh disable=SC1091 source "$REPO/lib/vendor-skills.sh" vendor_pinned_skills agent-skills refresh echo "" echo "── Updating Mengto scroll skills (MengTo/Skills)..." vendor_pinned_skills mengto-skills refresh echo "" echo "── Updating superpowers skills (obra/superpowers)..." vendor_pinned_skills superpowers refresh # ── Impeccable (design detector + skill + subagents) ── # Global scope: the installer writes through the ~/.claude/{skills,agents} # symlinks straight into this repo (install-plugins.sh Step 8d explains why # staging + project scope was wrong). The pin can rot upstream, so a pinned # failure falls back to @latest rather than leaving the tool stale forever. # # One install attempt. $1 = "latest" or an exact version. On failure, IMP_FAIL # holds the reason. Same helper as Step 8d: with a copy already in place a # rotted pin exits 0 and says "Could not check for skill updates … left # unchanged", so the exit code cannot tell it from an up-to-date no-op. imp_install() { local pkg="impeccable" out rc=0 [ "$1" != "latest" ] && pkg="impeccable@$1" out=$(npx -y "$pkg" skills install -y --providers=claude --scope=global \ --no-hooks 2>&1) || rc=$? IMP_FAIL=$(printf '%s\n' "$out" \ | grep -E 'Download failed|Could not check for skill updates' \ | head -1 || true) if [ "$rc" -ne 0 ] && [ -z "$IMP_FAIL" ]; then IMP_FAIL="installer exited $rc" fi [ -z "$IMP_FAIL" ] } echo "" echo "── Updating impeccable..." IMP_SKILL_DIR="$HOME/.claude/skills/impeccable" IMP_PARKED="$REPO/skills-disabled/impeccable" if [ ! -f "$IMP_SKILL_DIR/SKILL.md" ] && [ ! -f "$IMP_PARKED/SKILL.md" ]; then info "impeccable not installed — skipping (run: make plugin)" else IMP_VER="" if [ -f "$REPO/plugins.lock.json" ] && command -v python3 &>/dev/null; then IMP_VER=$(python3 -c " import json with open('$REPO/plugins.lock.json') as f: d = json.load(f) print(d.get('impeccable',{}).get('version','latest')) " 2>/dev/null || true) fi IMP_NODE=$(node -v 2>/dev/null | sed 's/^v//' | cut -d. -f1) if [ -z "${IMP_NODE:-}" ] || [ "$IMP_NODE" -lt 24 ]; then info "impeccable update skipped — needs Node >= 24 (found ${IMP_NODE:-none}); existing copy kept" else IMP_WAS_PARKED=false [ -d "$IMP_PARKED" ] && IMP_WAS_PARKED=true # Pin honored (LRN-077 class: a silent rules update changes audit output # on unchanged code) — bump the pin deliberately, then update. IMP_PIN="${IMP_VER:-latest}" IMP_OK=false if imp_install "$IMP_PIN"; then IMP_OK=true elif [ "$IMP_PIN" != "latest" ]; then warn "impeccable@${IMP_PIN} did not install (${IMP_FAIL}) — that release's skill dist is gone upstream; trying @latest" if imp_install latest; then IMP_OK=true warn "refreshed from @latest, not the pin — bump \"impeccable\".version in plugins.lock.json" fi fi if [ "$IMP_OK" = true ] && [ -f "$IMP_SKILL_DIR/SKILL.md" ]; then IMP_SKILL_VER=$(sed -n 's/^version:[[:space:]]*//p' "$IMP_SKILL_DIR/SKILL.md" | head -1) ok "impeccable refreshed (CLI ${IMP_VER:-latest}, skill ${IMP_SKILL_VER:-?})" if [ "$IMP_WAS_PARKED" = true ]; then rm -rf "${IMP_PARKED:?}" mv "$IMP_SKILL_DIR" "$IMP_PARKED" info "impeccable was parked by a profile — refreshed copy returned to skills-disabled/" fi else warn "impeccable refresh failed (${IMP_FAIL:-no SKILL.md written}) — existing copy kept" fi fi fi # ── 7.4. Update the 21st.dev CLI + skill pack ── # The CLI is a global npm bin; the skills are its hash-verified output, staged # under a throwaway HOME because `21st skills install` refuses to write # through the ~/.claude/skills symlink (see install-plugins.sh Step 8.7). echo "" echo "── Updating 21st.dev CLI + skill pack..." if ! command -v 21st &>/dev/null; then info "21st CLI not installed — skipping (run: make plugin)" else TFD_VER="" if [ -f "$REPO/plugins.lock.json" ] && command -v python3 &>/dev/null; then TFD_VER=$(python3 -c " import json with open('$REPO/plugins.lock.json') as f: d = json.load(f) print(d.get('21st',{}).get('version','latest')) " 2>/dev/null || true) fi TFD_PKG="@21st-dev/cli@latest" [ -n "$TFD_VER" ] && [ "$TFD_VER" != "latest" ] && TFD_PKG="@21st-dev/cli@${TFD_VER}" if npm install -g "$TFD_PKG" 2>/dev/null; then ok "21st CLI updated (${TFD_VER:-latest})" else warn "21st CLI update failed — existing binary kept" fi TFD_STAGE=$(mktemp -d) if HOME="$TFD_STAGE" 21st skills install --global --agent claude >/dev/null 2>&1; then TFD_N=0 for _tfd in "$TFD_STAGE"/.claude/skills/*/; do [ -f "${_tfd}SKILL.md" ] || continue _tfd_name=$(basename "$_tfd") # Refresh the SOURCE only. A parked copy in skills-disabled/ is left # alone: re-enabling restores it, and the next update refreshes it. rm -rf "${REPO:?}/skills-external/${_tfd_name:?}" mv "$_tfd" "$REPO/skills-external/$_tfd_name" TFD_N=$((TFD_N + 1)) done if [ "$TFD_N" -gt 0 ]; then ok "21st skill pack refreshed ($TFD_N skills)" else warn "21st skills install produced no SKILL.md — existing pack kept" fi else warn "21st skill pack refresh failed — existing pack kept" fi rm -rf "$TFD_STAGE" fi # Effort pins (BDR-107, BDR-108): every refresh above rewrites SKILL.md and # drops the `effort:` line; the 21st pack refresh is the last step that rewrites # a SKILL.md, so the entry levels of lib/effort-pins.txt go back here. echo "" echo "── Re-applying effort pins on the vendored skills..." # shellcheck source=lib/effort-pins.sh disable=SC1091 source "$REPO/lib/effort-pins.sh" apply_effort_pins "$REPO" || warn "effort pins: map lines rejected — fix lib/effort-pins.txt" # ── 7.5. Update external skills (npx skills) ── echo "" echo "── Updating external skills (npx skills)..." if command -v npx &>/dev/null; then NPX_SKILLS=( "alchaincyf/darwin-skill" ) for _src in "${NPX_SKILLS[@]}"; do _name="${_src##*/}" if [ ! -d "$HOME/.agents/skills/$_name" ]; then info "$_name not installed — skipping (run: make plugin)" continue fi # `skills add` is idempotent and pulls latest from the source repo, # which is the closest thing to an update operation the CLI exposes. # Run from $HOME: the CLI resolves .agents/skills/ relative to the CWD, so # running from the repo would write into $REPO/.agents/skills (gitignored) # instead of $HOME/.agents/skills where link.sh expects it. if (cd "$HOME" && npx -y skills add "$_src" 2>/dev/null); then ok "$_name refreshed from $_src" else warn "$_name refresh failed — run manually: (cd \"\$HOME\" && npx -y skills add $_src)" fi done else info "npx not available — skipping external skills" fi # ── 8. Update marketplace plugins ── echo "" echo "── Updating marketplace plugins..." if command -v claude &>/dev/null; then _plugins=$(claude plugin list 2>/dev/null \ | grep -oP '(?<=❯ )\S+' || true) if [ -n "$_plugins" ]; then while IFS= read -r _p; do _name="${_p%%@*}" info "Updating $_name..." # Pass the full "name@marketplace" spec — the CLI rejects # the bare name when several marketplaces are registered. if claude plugin update "$_p" 2>/dev/null; then ok "$_name updated" else warn "$_name update failed" fi done <<< "$_plugins" else info "No marketplace plugins installed — skipping" fi else warn "Claude Code not found — skipping plugin update" fi # ── 9. Update shellcheck ── echo "" echo "── Updating shellcheck..." if command -v shellcheck &>/dev/null; then # Detect OS for package manager update if [[ "$OSTYPE" == "darwin"* ]]; then if brew upgrade shellcheck 2>/dev/null; then ok "shellcheck updated" else ok "shellcheck already up to date" fi elif command -v apt-get &>/dev/null; then if sudo apt-get install -y --only-upgrade shellcheck 2>/dev/null; then ok "shellcheck updated" else ok "shellcheck already up to date" fi elif command -v dnf &>/dev/null; then if sudo dnf upgrade -y shellcheck 2>/dev/null; then ok "shellcheck updated" else ok "shellcheck already up to date" fi elif command -v pacman &>/dev/null; then if sudo pacman -S --noconfirm shellcheck 2>/dev/null; then ok "shellcheck updated" else ok "shellcheck already up to date" fi else info "shellcheck installed via binary — update manually" fi else info "shellcheck not installed — skipping (run: make plugin)" fi # ── 10. Refresh symlinks ── echo "" echo "── Refreshing symlinks..." bash "$REPO/link.sh" # ── 11. Run doctor ── echo "" bash "$REPO/doctor.sh"