Files
msd-core/get-shit-done/workflows/update.md
Tom Boucher 334a64168e chore(npm): rebrand packages to @opengsd scope (#127)
* chore(npm): rebrand packages to @opengsd scope

Rename:
- get-shit-done-redux → @opengsd/get-shit-done-redux
- @gsd-redux/sdk → @opengsd/gsd-sdk

Add publishConfig.access=public for first-time scoped publish.
CLI binary names (get-shit-done-redux, gsd-sdk, gsd-tools) unchanged.

Sweeps install commands, npx invocations, CI publish/version-check
workflows, tests, docs, READMEs (all translations), and the
PACKAGE_NAME constant in check-latest-version.

Bumps qs 6.15.1 → 6.15.2 to clear a moderate advisory surfaced by
the audit-clean test (GHSA-q8mj-m7cp-5q26).

Closes #126

* chore: pin 2.0.0 release + remove canary workflow

- Bump both packages 1.50.0-canary.0 → 2.0.0 for first @opengsd publish
- Remove .github/workflows/canary.yml and canary dist-tag handling in
  release.yml / release-sdk.yml
- Drop canary section from VERSIONING.md

Refs #126

* chore: address review findings + harden tarball-smoke timeout

- .changeset/opengsd-org-rename.md: match project's custom
  parse.cjs frontmatter (type: Changed / pr: 127); the scoped
  @changesets/cli keys were silently rejected.
- CONTEXT.md: drop two canary-stream policy lines and a dangling
  DEFECT.CANARY-VERSION-LEAK.cross-ref now that canary.yml is gone.
- tests/release-tarball-smoke.install.test.cjs: pass
  timeout: 600_000 for npm pack + global install; the 3-minute
  runNpm default was timing out on slower Docker hosts (cartographer).

Refs #126

* fix(sdk): add missing type/runtime devDependencies for build

prepublishOnly invokes tsc which couldn't resolve @types/node,
@types/ws, or synckit. They had been hoisted from root but were
not declared in sdk/'s own package.json — first publish from a
clean SDK tree failed.

Refs #126

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ci): use npm pack stdout instead of glob to find tarball

`npm pack --silent` for a scoped package (@opengsd/get-shit-done-redux)
produces `opengsd-get-shit-done-redux-*.tgz`, not `get-shit-done-redux-*.tgz`.
Capture the filename from stdout instead of a hardcoded glob so the step
works regardless of package name format.

Fixes smoke (ubuntu-latest, 22, false) CI failure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* ci: treat workflow-file changes as test-skip eligible

`.github/workflows/install-smoke.yml` (and other workflow files)
were in neither `test.yml` paths nor `test-skip.yml` paths-ignore,
so neither workflow ran on a workflow-only commit — leaving the
required test-skip check perpetually missing.

Refs #126

* chore: reset version to 1.0.0 for first @opengsd publish

Nothing has been published yet under the @opengsd scope, so the
inaugural release uses 1.0.0 rather than 2.0.0. The "major bump"
in the changeset reflects the breaking install-command change for
users migrating from the prior unscoped `get-shit-done-redux`, not
a numeric continuation from a 1.x line under the new identity.

Refs #126

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 16:22:41 -04:00

25 KiB

Check for GSD updates via npm, display changelog for versions between installed and latest, obtain user confirmation, and execute clean installation with cache clearing.

<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 as PREFERRED_CONFIG_DIR
  • Path contains /.codex/ -> codex
  • Path contains /.gemini/antigravity/ -> antigravity
  • Path contains /.gemini/ -> gemini
  • Path contains /.config/kilo/ or /.kilo/, or PREFERRED_CONFIG_DIR contains kilo.json / kilo.jsonc -> kilo
  • Path contains /.config/opencode/ or /.opencode/, or PREFERRED_CONFIG_DIR contains opencode.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" "antigravity:.gemini/antigravity" "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 "$ANTIGRAVITY_CONFIG_DIR" ]; then
    PREFERRED_RUNTIME="antigravity"
  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/antigravity .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 "$ANTIGRAVITY_CONFIG_DIR" ]; then
  ENV_RUNTIME_DIRS+=( "antigravity:$(expand_home "$ANTIGRAVITY_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.0 means unknown version)
  • Line 2 = install scope (LOCAL, GLOBAL, or UNKNOWN)
  • Line 3 = target runtime (claude, opencode, gemini, kilo, or codex)
  • Line 4 = resolved GSD config dir (e.g. /Users/me/.claude, /Users/me/.gemini); empty if scope is UNKNOWN. Capture this as GSD_DIR and 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 --global fallback.

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 -y --package=@opengsd/get-shit-done-redux@latest -- get-shit-done-redux --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:
  1. Fetch changelog from GitHub raw URL and save to a temp file, e.g. /tmp/gsd-changelog-$$.md.
  2. Extract entries between installed and latest versions using the deterministic range helper (fix for #3496 — do NOT use ad-hoc grep/awk extraction which silently skips intermediate versions):
CHANGELOG_TMP="/tmp/gsd-changelog-$$.md"
curl -fsSL "https://raw.githubusercontent.com/open-gsd/get-shit-done-redux/main/CHANGELOG.md" -o "$CHANGELOG_TMP" 2>/dev/null \
  || wget -qO "$CHANGELOG_TMP" "https://raw.githubusercontent.com/open-gsd/get-shit-done-redux/main/CHANGELOG.md" 2>/dev/null

EXTRACT_JSON=$(node "$GSD_DIR/get-shit-done/scripts/changeset/cli.cjs" extract \
  --from "$INSTALLED_VERSION" \
  --to "$LATEST_VERSION" \
  --changelog "$CHANGELOG_TMP" \
  --json 2>/dev/null)
EXTRACT_EXIT=$?
rm -f "$CHANGELOG_TMP"

if [ "$EXTRACT_EXIT" -eq 2 ]; then
  # Exit 2 = no releases in range (e.g. versions are equal or changelog is sparse)
  CHANGELOG_PREVIEW="No changelog updates between v${INSTALLED_VERSION} and v${LATEST_VERSION}."
elif [ "$EXTRACT_EXIT" -ne 0 ] || [ -z "$EXTRACT_JSON" ]; then
  CHANGELOG_PREVIEW="(Could not extract changelog — update will still proceed)"
else
  # Re-run without --json to get the human-readable markdown for display
  CHANGELOG_PREVIEW=$(node "$GSD_DIR/get-shit-done/scripts/changeset/cli.cjs" extract \
    --from "$INSTALLED_VERSION" \
    --to "$LATEST_VERSION" \
    --changelog "$CHANGELOG_TMP" 2>/dev/null || echo "(changelog unavailable)")
fi
  1. Display preview and ask for confirmation, using $CHANGELOG_PREVIEW from the extract step above:
## GSD Update Available

**Installed:** {INSTALLED_VERSION}
**Latest:** {LATEST_VERSION}

### What's New
────────────────────────────────────────────────────────────

{CHANGELOG_PREVIEW}

────────────────────────────────────────────────────────────

⚠️  **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:update --reapply` 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 [ -f "$GSD_TOOLS" ] && [ -n "$RUNTIME_DIR" ]; then
  CUSTOM_JSON=$(node "$GSD_TOOLS" detect-custom-files --config-dir "$RUNTIME_DIR" 2>/dev/null)
elif [ -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)
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)) continue;

  try {
    fs.mkdirSync(path.dirname(dst), { recursive: true });
    fs.copyFileSync(src, dst);
    console.log('  Backed up: ' + relPath);
  } catch (err) {
    const code = err && err.code ? String(err.code) : 'ERROR';
    console.log('  Skipped (non-fatal): ' + relPath + ' [' + code + ']');
  }
}
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.

Run the update using the install type detected in step 1:

Build runtime flag from step 1:

RUNTIME_FLAG="--$TARGET_RUNTIME"

If LOCAL install:

npx -y --package=@opengsd/get-shit-done-redux@latest -- get-shit-done-redux "$RUNTIME_FLAG" --local

If GLOBAL install:

npx -y --package=@opengsd/get-shit-done-redux@latest -- get-shit-done-redux "$RUNTIME_FLAG" --global

If UNKNOWN install:

npx -y --package=@opengsd/get-shit-done-redux@latest -- get-shit-done-redux --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/antigravity .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.

Format completion message (changelog was already shown in confirmation step):
╔═══════════════════════════════════════════════════════════╗
║  GSD Updated: v1.5.10 → v1.5.15                           ║
╚═══════════════════════════════════════════════════════════╝

⚠️  Restart your runtime to pick up the new commands.

[View full changelog](https://github.com/open-gsd/get-shit-done-redux/blob/main/CHANGELOG.md)
After update completes, check if the installer detected and backed up any locally modified files:

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>