* fix(#2992): deterministic latest-version check — package name is a constant, not LLM choice The /gsd-update workflow's check_latest_version step was prescribed in LLM-driven prose: "run `npm view get-shit-done-cc version`". The executing model could and did shortcut the prescription and invent npm queries against name-shaped guesses — `@get-shit-done/cli`, `get-shit-done-cli`, `gsd` — all of which 404 or, worse, return an unrelated typosquat (the 2016 `get-shit-done` timer package). Same architectural anti-pattern as #2969 (Hunk Verification Gate where the LLM filled `verified: yes` without checking). Implementation built TDD per #2992: get-shit-done/bin/check-latest-version.cjs - PACKAGE_NAME = 'get-shit-done-cc' as a module constant; not parameterised, not exposed for override. - checkLatestVersion({ spawn? }) returns { ok: bool, version?: string, reason: CHECK_REASON.X, detail? } via a frozen enum: OK / FAIL_NPM_FAILED / FAIL_INVALID_OUTPUT. - --json mode emits the structured record on stdout for the workflow to parse via jq. - Windows-aware: uses { shell: process.platform === 'win32' } since npm is npm.cmd on Windows (same lesson as #2962). - Stored under get-shit-done/bin/ (not top-level scripts/) because that path IS in the user's installed config dir; top-level scripts/ ships in the npm tarball but is not copied into ~/.claude/ at install time. tests/bug-2992-check-latest-version.test.cjs - 7 tests, all assertions on the typed CHECK_REASON enum + the structured record. Injectable spawn function so no real npm process is invoked. Covers OK, npm-non-zero, invalid-output, empty-output, pre-release semver, PACKAGE_NAME constant lock, enum-shape lock. get-shit-done/workflows/update.md - check_latest_version step rewritten to call the script via `node "${GSD_HOME}/get-shit-done/bin/check-latest-version.cjs" --json` and parse the structured response with jq. Explicit "Do NOT run `npm view` or `npm search` directly" guidance cites #2992 so future contributors understand why. Closes #2992 * fix(#2992): trailing slash on GSD_HOME default to satisfy bare-path lint The bug-2470 regression test scans update.md for bare `$HOME/.claude` references (no trailing slash). The PR added one in the new check_latest_version step. Fix: trailing slash on the default value (`${GSD_HOME:-$HOME/.claude/}`). Bash POSIX collapses the resulting double slash; the lint pattern's negative lookahead is now satisfied. * fix(#2992): emit GSD_DIR from get_installed_version, use it in check_latest_version Addresses CodeRabbit feedback: the previous `${GSD_HOME:-$HOME/.claude/}` fallback hardcoded the Claude runtime path, which silently breaks for non-Claude runtimes (gemini, codex, opencode, kilo). Fix: - get_installed_version now emits a 4th line with the resolved config dir ($LOCAL_DIR or $GLOBAL_DIR), captured by callers as GSD_DIR. - check_latest_version uses $GSD_DIR/get-shit-done/bin/check-latest-version.cjs. Empty GSD_DIR (UNKNOWN scope) skips the version check and falls through to fresh-install path. This keeps the package name deterministic (#2992) AND respects the detected runtime, instead of assuming Claude. * chore(#2992): add changeset fragment for PR #2993 * chore(#2992): add changeset fragment for PR #2993 * fix(#2992): consolidate LATEST_RESULT parsing inside the GSD_DIR guard CodeRabbit on PR #2993: the previous structure separated the GSD_DIR guard from the jq parsing, so when GSD_DIR was empty the parsing block ran against an unset LATEST_RESULT and produced misleading 'couldn't check for updates' diagnostics instead of clean 'no_install_detected'. Move all field assignments inside the conditional so the skip path seeds LATEST_OK=false, LATEST_VERSION='', LATEST_REASON='no_install_detected', and LATEST_STATUS=0 atomically. * fix(#2992): emit GSD_DIR in early-return; add code-block lang and spawnSync timeout (CR) CodeRabbit on PR #2993 caught three issues: 1. (Major) The early-return path in get_installed_version (PREFERRED_CONFIG_DIR fast path) only echoed 3 lines, but PR #2993 changed the contract to 4 (GSD_DIR is now line 4). Downstream check_latest_version misread valid installs as UNKNOWN. Added `echo "$PREFERRED_CONFIG_DIR"` before exit 0. 2. (Minor) Markdown MD040: fenced code block at line 310 was missing a language identifier. Added ```text. 3. (Quick win) spawnSync('npm view ...') had no timeout, so a hung network could block /gsd-update indefinitely. Added 15s timeout; on timeout spawnSync returns with signal !== null and the existing failure path emits FAIL_NPM_FAILED. * fix(#3008): kill cross-process race in install-minimal:307 mid-copy test Old shape compared listTmpStageDirs() snapshots before/after the mid-copy throw. Under scripts/run-tests.cjs --test-concurrency=4, tests/install-minimal-all-runtimes.test.cjs runs in a parallel subprocess and also creates gsd-minimal-skills-* dirs in shared os.tmpdir(). The parallel process's create/remove activity between this test's two snapshots caused deterministic failure when timing aligned -- presented as 'flaky' but is a real race. CI failure data (PR #2993 run 25238555786): expected (before): ['gsd-minimal-skills-km1O1O'] actual (after): [] Both processes behaved correctly in isolation. The test was wrong: it observed a shared filesystem state across processes. Fix: stub fs.mkdtempSync inside this test to record THIS call's stage dir path. After the throw, assert fs.existsSync(stagedDir) === false. Direct observation of the function's own behavior; no global tmpdir scan; no parallel-process interference. Closes #3008 * fix(#2992): distinguish timeout from npm failure; guard empty LATEST_RESULT (CR) CodeRabbit on PR #2993 (post-fix-up review) caught two improvements: 1. (Low value) check-latest-version.cjs:55-61 — when spawnSync times out, r.status is null and r.signal is set (e.g. 'SIGTERM'), but r.stderr is empty. Without the signal-first branch, both timeouts and genuine npm failures shaped as 'npm exited non-zero' in detail, making logs ambiguous. Added explicit signal-first branch: 'npm timed out (signal: SIGTERM)'. 2. (Quick win) update.md:284-315 — when node is missing or the script doesn't exist, LATEST_RESULT is empty. Piping empty to jq parses without error but leaves LATEST_OK / LATEST_REASON as empty strings, producing the user-visible diagnostic 'Couldn\'t check for updates (reason: , exit: N)' with a blank reason. Added an explicit guard that sets LATEST_REASON to 'script_not_found_or_node_unavailable' when LATEST_RESULT is empty, so operators see a meaningful failure message. Tests: bug-2992 grows by 2 cases (timeout signal detail + empty stderr fallback).
23 KiB
<required_reading> Read all files referenced by the invoking prompt's execution_context before starting. </required_reading>
Detect whether GSD is installed locally or globally by checking both locations and validating install integrity.First, derive PREFERRED_CONFIG_DIR and PREFERRED_RUNTIME from the invoking prompt's execution_context path:
- If the path contains
/get-shit-done/workflows/update.md, strip that suffix and store the remainder asPREFERRED_CONFIG_DIR - Path contains
/.codex/->codex - Path contains
/.gemini/->gemini - Path contains
/.config/kilo/or/.kilo/, orPREFERRED_CONFIG_DIRcontainskilo.json/kilo.jsonc->kilo - Path contains
/.config/opencode/or/.opencode/, orPREFERRED_CONFIG_DIRcontainsopencode.json/opencode.jsonc->opencode - Otherwise ->
claude
Use PREFERRED_CONFIG_DIR when available so custom --config-dir installs are checked before default locations.
Use PREFERRED_RUNTIME as the first runtime checked so /gsd-update targets the runtime that invoked it.
Kilo config precedence must match the installer: KILO_CONFIG_DIR -> dirname(KILO_CONFIG) -> XDG_CONFIG_HOME/kilo -> ~/.config/kilo.
expand_home() {
case "$1" in
"~/"*) printf '%s/%s\n' "$HOME" "${1#~/}" ;;
*) printf '%s\n' "$1" ;;
esac
}
# Runtime candidates: "<runtime>:<config-dir>" stored as an array.
# Using an array instead of a space-separated string ensures correct
# iteration in both bash and zsh (zsh does not word-split unquoted
# variables by default). Fixes #1173.
RUNTIME_DIRS=( "claude:.claude" "opencode:.config/opencode" "opencode:.opencode" "gemini:.gemini" "kilo:.config/kilo" "kilo:.kilo" "codex:.codex" )
ENV_RUNTIME_DIRS=()
# PREFERRED_CONFIG_DIR / PREFERRED_RUNTIME should be set from execution_context
# before running this block.
if [ -n "$PREFERRED_CONFIG_DIR" ]; then
PREFERRED_CONFIG_DIR="$(expand_home "$PREFERRED_CONFIG_DIR")"
if [ -z "$PREFERRED_RUNTIME" ]; then
if [ -f "$PREFERRED_CONFIG_DIR/kilo.json" ] || [ -f "$PREFERRED_CONFIG_DIR/kilo.jsonc" ]; then
PREFERRED_RUNTIME="kilo"
elif [ -f "$PREFERRED_CONFIG_DIR/opencode.json" ] || [ -f "$PREFERRED_CONFIG_DIR/opencode.jsonc" ]; then
PREFERRED_RUNTIME="opencode"
elif [ -f "$PREFERRED_CONFIG_DIR/config.toml" ]; then
PREFERRED_RUNTIME="codex"
fi
fi
fi
# If runtime is still unknown, infer from runtime env vars; fallback to claude.
if [ -z "$PREFERRED_RUNTIME" ]; then
if [ -n "$CODEX_HOME" ]; then
PREFERRED_RUNTIME="codex"
elif [ -n "$GEMINI_CONFIG_DIR" ]; then
PREFERRED_RUNTIME="gemini"
elif [ -n "$KILO_CONFIG_DIR" ]; then
PREFERRED_RUNTIME="kilo"
elif [ -n "$KILO_CONFIG" ]; then
PREFERRED_RUNTIME="kilo"
elif [ -n "$OPENCODE_CONFIG_DIR" ] || [ -n "$OPENCODE_CONFIG" ]; then
PREFERRED_RUNTIME="opencode"
elif [ -n "$CLAUDE_CONFIG_DIR" ]; then
PREFERRED_RUNTIME="claude"
else
PREFERRED_RUNTIME="claude"
fi
fi
# If execution_context already points at an installed config dir, trust it first.
# This covers custom --config-dir installs that do not live under the default
# runtime directories.
if [ -n "$PREFERRED_CONFIG_DIR" ] && { [ -f "$PREFERRED_CONFIG_DIR/get-shit-done/VERSION" ] || [ -f "$PREFERRED_CONFIG_DIR/get-shit-done/workflows/update.md" ]; }; then
INSTALL_SCOPE="GLOBAL"
# Normalize a path for comparison: on Windows with Git Bash, pwd returns
# POSIX-style /c/Users/... but PREFERRED_CONFIG_DIR may carry C:/Users/...
# Convert Windows drive-letter paths to POSIX form so the comparison works
# on both Windows (Git Bash) and POSIX systems.
normalize_path() {
local p="$1"
case "$p" in
[A-Za-z]:/*)
local drive rest
drive="${p%%:*}"
rest="${p#?:}"
p="/$(printf '%s' "$drive" | tr '[:upper:]' '[:lower:]')$rest"
;;
esac
printf '%s' "$p"
}
normalized_preferred="$(normalize_path "$PREFERRED_CONFIG_DIR")"
for dir in .claude .config/opencode .opencode .gemini .config/kilo .kilo .codex; do
resolved_local="$(cd "./$dir" 2>/dev/null && pwd)"
normalized_local="$(normalize_path "$resolved_local")"
if [ -n "$normalized_local" ] && [ "$normalized_local" = "$normalized_preferred" ]; then
INSTALL_SCOPE="LOCAL"
break
fi
done
if [ -f "$PREFERRED_CONFIG_DIR/get-shit-done/VERSION" ] && grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+' "$PREFERRED_CONFIG_DIR/get-shit-done/VERSION"; then
INSTALLED_VERSION="$(cat "$PREFERRED_CONFIG_DIR/get-shit-done/VERSION")"
else
INSTALLED_VERSION="0.0.0"
fi
echo "$INSTALLED_VERSION"
echo "$INSTALL_SCOPE"
echo "${PREFERRED_RUNTIME:-claude}"
# 4-line output contract (#2993 CR): early-return path must also emit
# GSD_DIR or downstream check_latest_version misreads the install as
# UNKNOWN. PREFERRED_CONFIG_DIR is the resolved config dir we just
# validated above (line 95-96); it is the right GSD_DIR value for
# this fast path.
echo "$PREFERRED_CONFIG_DIR"
exit 0
fi
# Absolute global candidates from env overrides (covers custom config dirs).
if [ -n "$CLAUDE_CONFIG_DIR" ]; then
ENV_RUNTIME_DIRS+=( "claude:$(expand_home "$CLAUDE_CONFIG_DIR")" )
fi
if [ -n "$GEMINI_CONFIG_DIR" ]; then
ENV_RUNTIME_DIRS+=( "gemini:$(expand_home "$GEMINI_CONFIG_DIR")" )
fi
if [ -n "$KILO_CONFIG_DIR" ]; then
ENV_RUNTIME_DIRS+=( "kilo:$(expand_home "$KILO_CONFIG_DIR")" )
elif [ -n "$KILO_CONFIG" ]; then
ENV_RUNTIME_DIRS+=( "kilo:$(dirname "$(expand_home "$KILO_CONFIG")")" )
elif [ -n "$XDG_CONFIG_HOME" ]; then
ENV_RUNTIME_DIRS+=( "kilo:$(expand_home "$XDG_CONFIG_HOME")/kilo" )
fi
if [ -n "$OPENCODE_CONFIG_DIR" ]; then
ENV_RUNTIME_DIRS+=( "opencode:$(expand_home "$OPENCODE_CONFIG_DIR")" )
elif [ -n "$OPENCODE_CONFIG" ]; then
ENV_RUNTIME_DIRS+=( "opencode:$(dirname "$(expand_home "$OPENCODE_CONFIG")")" )
elif [ -n "$XDG_CONFIG_HOME" ]; then
ENV_RUNTIME_DIRS+=( "opencode:$(expand_home "$XDG_CONFIG_HOME")/opencode" )
fi
if [ -n "$CODEX_HOME" ]; then
ENV_RUNTIME_DIRS+=( "codex:$(expand_home "$CODEX_HOME")" )
fi
# Reorder entries so preferred runtime is checked first.
ORDERED_RUNTIME_DIRS=()
for entry in "${RUNTIME_DIRS[@]}"; do
runtime="${entry%%:*}"
if [ "$runtime" = "$PREFERRED_RUNTIME" ]; then
ORDERED_RUNTIME_DIRS+=( "$entry" )
fi
done
ORDERED_ENV_RUNTIME_DIRS=()
for entry in "${ENV_RUNTIME_DIRS[@]}"; do
runtime="${entry%%:*}"
if [ "$runtime" = "$PREFERRED_RUNTIME" ]; then
ORDERED_ENV_RUNTIME_DIRS+=( "$entry" )
fi
done
for entry in "${ENV_RUNTIME_DIRS[@]}"; do
runtime="${entry%%:*}"
if [ "$runtime" != "$PREFERRED_RUNTIME" ]; then
ORDERED_ENV_RUNTIME_DIRS+=( "$entry" )
fi
done
for entry in "${RUNTIME_DIRS[@]}"; do
runtime="${entry%%:*}"
if [ "$runtime" != "$PREFERRED_RUNTIME" ]; then
ORDERED_RUNTIME_DIRS+=( "$entry" )
fi
done
# Check local first (takes priority only if valid and distinct from global)
LOCAL_VERSION_FILE="" LOCAL_MARKER_FILE="" LOCAL_DIR="" LOCAL_RUNTIME=""
for entry in "${ORDERED_RUNTIME_DIRS[@]}"; do
runtime="${entry%%:*}"
dir="${entry#*:}"
if [ -f "./$dir/get-shit-done/VERSION" ] || [ -f "./$dir/get-shit-done/workflows/update.md" ]; then
LOCAL_RUNTIME="$runtime"
LOCAL_VERSION_FILE="./$dir/get-shit-done/VERSION"
LOCAL_MARKER_FILE="./$dir/get-shit-done/workflows/update.md"
LOCAL_DIR="$(cd "./$dir" 2>/dev/null && pwd)"
break
fi
done
GLOBAL_VERSION_FILE="" GLOBAL_MARKER_FILE="" GLOBAL_DIR="" GLOBAL_RUNTIME=""
for entry in "${ORDERED_ENV_RUNTIME_DIRS[@]}"; do
runtime="${entry%%:*}"
dir="${entry#*:}"
if [ -f "$dir/get-shit-done/VERSION" ] || [ -f "$dir/get-shit-done/workflows/update.md" ]; then
GLOBAL_RUNTIME="$runtime"
GLOBAL_VERSION_FILE="$dir/get-shit-done/VERSION"
GLOBAL_MARKER_FILE="$dir/get-shit-done/workflows/update.md"
GLOBAL_DIR="$(cd "$dir" 2>/dev/null && pwd)"
break
fi
done
if [ -z "$GLOBAL_RUNTIME" ]; then
for entry in "${ORDERED_RUNTIME_DIRS[@]}"; do
runtime="${entry%%:*}"
dir="${entry#*:}"
if [ -f "$HOME/$dir/get-shit-done/VERSION" ] || [ -f "$HOME/$dir/get-shit-done/workflows/update.md" ]; then
GLOBAL_RUNTIME="$runtime"
GLOBAL_VERSION_FILE="$HOME/$dir/get-shit-done/VERSION"
GLOBAL_MARKER_FILE="$HOME/$dir/get-shit-done/workflows/update.md"
GLOBAL_DIR="$(cd "$HOME/$dir" 2>/dev/null && pwd)"
break
fi
done
fi
# Only treat as LOCAL if the resolved paths differ (prevents misdetection when CWD=$HOME)
IS_LOCAL=false
if [ -n "$LOCAL_VERSION_FILE" ] && [ -f "$LOCAL_VERSION_FILE" ] && [ -f "$LOCAL_MARKER_FILE" ] && grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+' "$LOCAL_VERSION_FILE"; then
if [ -z "$GLOBAL_DIR" ] || [ "$LOCAL_DIR" != "$GLOBAL_DIR" ]; then
IS_LOCAL=true
fi
fi
if [ "$IS_LOCAL" = true ]; then
INSTALLED_VERSION="$(cat "$LOCAL_VERSION_FILE")"
INSTALL_SCOPE="LOCAL"
TARGET_RUNTIME="$LOCAL_RUNTIME"
RESOLVED_GSD_DIR="$LOCAL_DIR"
elif [ -n "$GLOBAL_VERSION_FILE" ] && [ -f "$GLOBAL_VERSION_FILE" ] && [ -f "$GLOBAL_MARKER_FILE" ] && grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+' "$GLOBAL_VERSION_FILE"; then
INSTALLED_VERSION="$(cat "$GLOBAL_VERSION_FILE")"
INSTALL_SCOPE="GLOBAL"
TARGET_RUNTIME="$GLOBAL_RUNTIME"
RESOLVED_GSD_DIR="$GLOBAL_DIR"
elif [ -n "$LOCAL_RUNTIME" ] && [ -f "$LOCAL_MARKER_FILE" ]; then
# Runtime detected but VERSION missing/corrupt: treat as unknown version, keep runtime target
INSTALLED_VERSION="0.0.0"
INSTALL_SCOPE="LOCAL"
TARGET_RUNTIME="$LOCAL_RUNTIME"
RESOLVED_GSD_DIR="$LOCAL_DIR"
elif [ -n "$GLOBAL_RUNTIME" ] && [ -f "$GLOBAL_MARKER_FILE" ]; then
INSTALLED_VERSION="0.0.0"
INSTALL_SCOPE="GLOBAL"
TARGET_RUNTIME="$GLOBAL_RUNTIME"
RESOLVED_GSD_DIR="$GLOBAL_DIR"
else
INSTALLED_VERSION="0.0.0"
INSTALL_SCOPE="UNKNOWN"
TARGET_RUNTIME="claude"
RESOLVED_GSD_DIR=""
fi
echo "$INSTALLED_VERSION"
echo "$INSTALL_SCOPE"
echo "$TARGET_RUNTIME"
echo "$RESOLVED_GSD_DIR"
Parse output:
- Line 1 = installed version (
0.0.0means unknown version) - Line 2 = install scope (
LOCAL,GLOBAL, orUNKNOWN) - Line 3 = target runtime (
claude,opencode,gemini,kilo, orcodex) - Line 4 = resolved GSD config dir (e.g.
/Users/me/.claude,/Users/me/.gemini); empty if scope isUNKNOWN. Capture this asGSD_DIRand pass it to subsequent steps so they don't have to re-derive the runtime path. - If scope is
UNKNOWN, proceed to install step using--claude --globalfallback.
If multiple runtime installs are detected and the invoking runtime cannot be determined from execution_context, ask the user which runtime to update before running install.
If VERSION file missing:
## GSD Update
**Installed version:** Unknown
Your installation doesn't include version tracking.
Running fresh install...
Proceed to install step (treat as version 0.0.0 for comparison).
Check npm for latest version via the deterministic script. **Do NOT run `npm view` or `npm search` directly** — the package name must come from the script, not from a free choice at execution time. (#2992: LLM-driven prescriptions of npm package names produced wrong-package queries; moving the package name into a script constant closes that gap.)The GSD_DIR value emitted by get_installed_version (line 4) resolves to the runtime-specific config dir (~/.claude/, ~/.gemini/, ~/.codex/, etc.), so the script invocation works for every runtime — not just Claude. If GSD_DIR is empty (scope UNKNOWN), skip this step and go directly to install.
LATEST_RESULT is a JSON document with the documented shape { ok: bool, version: string, reason: string, detail?: string }. Parse via jq ONLY when the script actually ran. When GSD_DIR is empty (scope UNKNOWN), skip the check entirely and seed the parsed fields with their no-op values so downstream logic does not mistake an unset LATEST_RESULT for a failed network check (#2993 CR feedback):
if [ -z "$GSD_DIR" ]; then
# No install detected — fall through to install step; version-check is skipped.
LATEST_RESULT=""
LATEST_STATUS=0
LATEST_OK=false
LATEST_VERSION=""
LATEST_REASON="no_install_detected"
else
LATEST_RESULT="$(node "$GSD_DIR/get-shit-done/bin/check-latest-version.cjs" --json 2>/dev/null)"
LATEST_STATUS=$?
# #2993 CR: when node is missing or the script doesn't exist, LATEST_RESULT
# is empty and piping it to `jq` produces a parse error on stderr while
# leaving LATEST_OK / LATEST_REASON as empty strings. Fail the check with a
# meaningful reason instead of a blank diagnostic.
if [ -n "$LATEST_RESULT" ]; then
LATEST_OK="$(printf '%s' "$LATEST_RESULT" | jq -r '.ok // false')"
LATEST_VERSION="$(printf '%s' "$LATEST_RESULT" | jq -r '.version // empty')"
LATEST_REASON="$(printf '%s' "$LATEST_RESULT" | jq -r '.reason // empty')"
else
LATEST_OK=false
LATEST_VERSION=""
LATEST_REASON="script_not_found_or_node_unavailable"
fi
fi
If LATEST_OK is not true (or LATEST_STATUS is non-zero):
Couldn't check for updates (reason: {LATEST_REASON}, exit: {LATEST_STATUS}).
To update manually: `npx get-shit-done-cc --global`
Exit.
Compare installed vs latest:If installed == latest:
## GSD Update
**Installed:** X.Y.Z
**Latest:** X.Y.Z
You're already on the latest version.
Exit.
If installed > latest:
## GSD Update
**Installed:** X.Y.Z
**Latest:** A.B.C
You're ahead of the latest release — this looks like a dev install.
If you see a "⚠ dev install — re-run installer to sync hooks" warning in
your statusline, your hook files are older than your VERSION file. Fix it
by re-running the local installer from your dev branch:
node bin/install.js --global --claude
Running /gsd-update would install the npm release (A.B.C) and downgrade
your dev version — do NOT use it to resolve this warning.
Exit.
**If update available**, fetch and show what's new BEFORE updating:- Fetch changelog from GitHub raw URL
- Extract entries between installed and latest versions
- Display preview and ask for confirmation:
## GSD Update Available
**Installed:** 1.5.10
**Latest:** 1.5.15
### What's New
────────────────────────────────────────────────────────────
## [1.5.15] - 2026-01-20
### Added
- Feature X
## [1.5.14] - 2026-01-18
### Fixed
- Bug fix Y
────────────────────────────────────────────────────────────
⚠️ **Note:** The installer performs a clean install of GSD folders:
- `commands/gsd/` will be wiped and replaced
- `get-shit-done/` will be wiped and replaced
- `agents/gsd-*` files will be replaced
(Paths are relative to detected runtime install location:
global: `~/.claude/`, `~/.config/opencode/`, `~/.opencode/`, `~/.gemini/`, `~/.config/kilo/`, or `~/.codex/`
local: `./.claude/`, `./.config/opencode/`, `./.opencode/`, `./.gemini/`, `./.kilo/`, or `./.codex/`)
Your custom files in other locations are preserved:
- Custom commands not in `commands/gsd/` ✓
- Custom agents not prefixed with `gsd-` ✓
- Custom hooks ✓
- Your CLAUDE.md files ✓
If you've modified any GSD files directly, they'll be automatically backed up to `gsd-local-patches/` and can be reapplied with `/gsd-reapply-patches` after the update.
Text mode (workflow.text_mode: true in config or --text flag): Set TEXT_MODE=true if --text is present in $ARGUMENTS OR text_mode from init JSON is true. When TEXT_MODE is active, replace every AskUserQuestion call with a plain-text numbered list and ask the user to type their choice number. This is required for non-Claude runtimes (OpenAI Codex, Gemini CLI, etc.) where AskUserQuestion is not available.
Use AskUserQuestion:
- Question: "Proceed with update?"
- Options:
- "Yes, update now"
- "No, cancel"
If user cancels: Exit.
Before running the installer, detect and back up any user-added files inside GSD-managed directories. These are files that exist on disk but are NOT listed in `gsd-file-manifest.json` — i.e., files the user added themselves that the installer does not know about and will delete during the wipe.Do not use bash path-stripping (${filepath#$RUNTIME_DIR/}) or node -e require()
inline — those patterns fail when $RUNTIME_DIR is unset and the stripped
relative path may not match manifest key format, which causes CUSTOM_COUNT=0
even when custom files exist (bug #1997). Use gsd-sdk query detect-custom-files
when gsd-sdk is on PATH, or the bundled gsd-tools.cjs detect-custom-files
otherwise — both resolve paths reliably with Node.js path.relative().
First, resolve the config directory (RUNTIME_DIR) from the install scope
detected in get_installed_version:
# RUNTIME_DIR is the resolved config directory (e.g. ~/.config/opencode, ~/.gemini)
# It should already be set from get_installed_version as GLOBAL_DIR or LOCAL_DIR.
# Use the appropriate variable based on INSTALL_SCOPE.
if [ "$INSTALL_SCOPE" = "LOCAL" ]; then
RUNTIME_DIR="$LOCAL_DIR"
elif [ "$INSTALL_SCOPE" = "GLOBAL" ]; then
RUNTIME_DIR="$GLOBAL_DIR"
else
RUNTIME_DIR=""
fi
If RUNTIME_DIR is empty or does not exist, skip this step (no config dir to
inspect).
Otherwise run detect-custom-files (prefer SDK when available):
GSD_TOOLS="$RUNTIME_DIR/get-shit-done/bin/gsd-tools.cjs"
CUSTOM_JSON=''
if [ -n "$RUNTIME_DIR" ] && command -v gsd-sdk >/dev/null 2>&1; then
CUSTOM_JSON=$(gsd-sdk query detect-custom-files --config-dir "$RUNTIME_DIR" 2>/dev/null)
elif [ -f "$GSD_TOOLS" ] && [ -n "$RUNTIME_DIR" ]; then
CUSTOM_JSON=$(node "$GSD_TOOLS" detect-custom-files --config-dir "$RUNTIME_DIR" 2>/dev/null)
fi
if [ -z "$CUSTOM_JSON" ]; then
CUSTOM_JSON='{"custom_files":[],"custom_count":0}'
fi
CUSTOM_COUNT=$(echo "$CUSTOM_JSON" | node -e "process.stdin.resume();let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{console.log(JSON.parse(d).custom_count);}catch{console.log(0);}})" 2>/dev/null || echo "0")
If CUSTOM_COUNT > 0:
Back up each custom file to $RUNTIME_DIR/gsd-user-files-backup/ before the
installer wipes the directories:
BACKUP_DIR="$RUNTIME_DIR/gsd-user-files-backup"
mkdir -p "$BACKUP_DIR"
# Parse custom_files array from CUSTOM_JSON and copy each file
node - "$RUNTIME_DIR" "$BACKUP_DIR" "$CUSTOM_JSON" <<'JSEOF'
const [,, runtimeDir, backupDir, customJson] = process.argv;
const { custom_files } = JSON.parse(customJson);
const fs = require('fs');
const path = require('path');
for (const relPath of custom_files) {
const src = path.join(runtimeDir, relPath);
const dst = path.join(backupDir, relPath);
if (fs.existsSync(src)) {
fs.mkdirSync(path.dirname(dst), { recursive: true });
fs.copyFileSync(src, dst);
console.log(' Backed up: ' + relPath);
}
}
JSEOF
Then inform the user:
⚠️ Found N custom file(s) inside GSD-managed directories.
These have been backed up to gsd-user-files-backup/ before the update.
Restore them after the update if needed.
If CUSTOM_COUNT == 0: No user-added files detected. Continue to install.
Build runtime flag from step 1:
RUNTIME_FLAG="--$TARGET_RUNTIME"
If LOCAL install:
npx -y get-shit-done-cc@latest "$RUNTIME_FLAG" --local
If GLOBAL install:
npx -y get-shit-done-cc@latest "$RUNTIME_FLAG" --global
If UNKNOWN install:
npx -y get-shit-done-cc@latest --claude --global
Capture output. If install fails, show error and exit.
Clear the update cache so statusline indicator disappears:
expand_home() {
case "$1" in
"~/"*) printf '%s/%s\n' "$HOME" "${1#~/}" ;;
*) printf '%s\n' "$1" ;;
esac
}
# Clear update cache across preferred, env-derived, and default runtime directories
CACHE_DIRS=()
if [ -n "$PREFERRED_CONFIG_DIR" ]; then
CACHE_DIRS+=( "$(expand_home "$PREFERRED_CONFIG_DIR")" )
fi
if [ -n "$CLAUDE_CONFIG_DIR" ]; then
CACHE_DIRS+=( "$(expand_home "$CLAUDE_CONFIG_DIR")" )
fi
if [ -n "$GEMINI_CONFIG_DIR" ]; then
CACHE_DIRS+=( "$(expand_home "$GEMINI_CONFIG_DIR")" )
fi
if [ -n "$KILO_CONFIG_DIR" ]; then
CACHE_DIRS+=( "$(expand_home "$KILO_CONFIG_DIR")" )
elif [ -n "$KILO_CONFIG" ]; then
CACHE_DIRS+=( "$(dirname "$(expand_home "$KILO_CONFIG")")" )
elif [ -n "$XDG_CONFIG_HOME" ]; then
CACHE_DIRS+=( "$(expand_home "$XDG_CONFIG_HOME")/kilo" )
fi
if [ -n "$OPENCODE_CONFIG_DIR" ]; then
CACHE_DIRS+=( "$(expand_home "$OPENCODE_CONFIG_DIR")" )
elif [ -n "$OPENCODE_CONFIG" ]; then
CACHE_DIRS+=( "$(dirname "$(expand_home "$OPENCODE_CONFIG")")" )
elif [ -n "$XDG_CONFIG_HOME" ]; then
CACHE_DIRS+=( "$(expand_home "$XDG_CONFIG_HOME")/opencode" )
fi
if [ -n "$CODEX_HOME" ]; then
CACHE_DIRS+=( "$(expand_home "$CODEX_HOME")" )
fi
for dir in "${CACHE_DIRS[@]}"; do
if [ -n "$dir" ]; then
rm -f "$dir/cache/gsd-update-check.json"
fi
done
for dir in .claude .config/opencode .opencode .gemini .config/kilo .kilo .codex; do
rm -f "./$dir/cache/gsd-update-check.json"
rm -f "$HOME/$dir/cache/gsd-update-check.json"
done
# Clear the shared tool-agnostic cache written by gsd-check-update.js hook (#2784).
# The hook uses ~/.cache/gsd/gsd-update-check.json regardless of runtime; clear it
# so the statusline stops showing the stale "⬆ /gsd-update" indicator after update.
rm -f "$HOME/.cache/gsd/gsd-update-check.json"
The SessionStart hook (gsd-check-update.js) writes to the detected runtime's cache directory, so preferred/env-derived paths and default paths must all be cleared to prevent stale update indicators.
╔═══════════════════════════════════════════════════════════╗
║ GSD Updated: v1.5.10 → v1.5.15 ║
╚═══════════════════════════════════════════════════════════╝
⚠️ Restart your runtime to pick up the new commands.
[View full changelog](https://github.com/gsd-build/get-shit-done/blob/main/CHANGELOG.md)
Check for gsd-local-patches/backup-meta.json in the config directory.
If patches found:
Local patches were backed up before the update.
Run /gsd-update --reapply to merge your modifications into the new version.
If no patches: Continue normally.
<success_criteria>
- Installed version read correctly
- Latest version checked via npm
- Update skipped if already current
- Changelog fetched and displayed BEFORE update
- Clean install warning shown
- User confirmation obtained
- Update executed successfully
- Restart reminder shown </success_criteria>