Skills › gstack › gstack-upgrade › v1.1.3.0.sh
v1.1.3.0.sh
137 lines · gstack-upgrade/migrations/v1.1.3.0.sh at 3f79d81
1#!/usr/bin/env bash
2# Migration: v1.1.3.0 — Remove stale /checkpoint skill installs
3#
4# Claude Code ships /checkpoint as a native alias for /rewind, which was
5# shadowing the gstack checkpoint skill. The skill has been split into
6# /context-save + /context-restore. This migration removes the old on-disk
7# install so Claude Code's native /checkpoint is no longer shadowed.
8#
9# Ownership guard: the script only removes the install IF it owns it —
10# i.e., the directory or its SKILL.md is a symlink resolving inside
11# ~/.claude/skills/gstack/. A user's own /checkpoint skill (regular file,
12# or symlink pointing elsewhere) is preserved.
13#
14# Three supported install shapes to handle:
15# 1. ~/.claude/skills/checkpoint is a directory symlink into gstack.
16# 2. ~/.claude/skills/checkpoint is a regular directory whose ONLY file
17# is a SKILL.md symlink into gstack (gstack's prefix-install shape).
18# 3. Anything else → leave alone, print notice.
19#
20# Idempotent: missing paths are no-ops.
21set -euo pipefail
23# Guard: refuse to run if HOME is unset or empty. With `set -u`, unset HOME
24# errors out, but HOME="" (possible under sudo-without-H, systemd units, some
25# CI runners) survives and produces dangerous absolute paths like
26# "/.claude/skills/...". Abort cleanly.
27if [ -z "${HOME:-}" ]; then
28 echo " [v1.1.3.0] HOME is unset or empty — skipping migration." >&2
29 exit 0
30fi
32SKILLS_DIR="${HOME}/.claude/skills"
33OLD_TOPLEVEL="${SKILLS_DIR}/checkpoint"
34OLD_NAMESPACED="${SKILLS_DIR}/gstack/checkpoint"
35GSTACK_ROOT_REAL=""
37# Helper: canonical-path a target (symlink-safe). Prints the resolved path, or
38# empty on failure (broken symlink, ENOENT, ELOOP). Both realpath AND the python3
39# fallback are tried — a single tool failure shouldn't defeat the ownership
40# check. Returns empty string if both fail.
41resolve_real() {
42 local target="$1"
43 local out=""
44 if command -v realpath >/dev/null 2>&1; then
45 out=$(realpath "$target" 2>/dev/null || true)
46 fi
47 if [ -z "$out" ] && command -v python3 >/dev/null 2>&1; then
48 out=$(python3 -c 'import os,sys;print(os.path.realpath(sys.argv[1]))' "$target" 2>/dev/null || true)
49 fi
50 printf '%s' "$out"
51}
53# Resolve the canonical path of the gstack skills root. If gstack isn't
54# installed here, there's nothing to migrate.
55if [ -d "${SKILLS_DIR}/gstack" ]; then
56 GSTACK_ROOT_REAL=$(resolve_real "${SKILLS_DIR}/gstack")
57fi
59# Helper: does $1 (canonical path) live inside $2 (canonical path)?
60path_inside() {
61 local inner="$1"
62 local outer="$2"
63 [ -n "$inner" ] && [ -n "$outer" ] || return 1
64 case "$inner" in
65 "$outer"|"$outer"/*) return 0;;
66 *) return 1;;
67 esac
68}
70removed_any=0
72# --- Shape 1: top-level ~/.claude/skills/checkpoint
73if [ -L "$OLD_TOPLEVEL" ]; then
74 # Directory symlink (or file symlink). Canonicalize and check ownership.
75 target_real=$(resolve_real "$OLD_TOPLEVEL")
76 if [ -n "$GSTACK_ROOT_REAL" ] && path_inside "$target_real" "$GSTACK_ROOT_REAL"; then
77 rm -- "$OLD_TOPLEVEL"
78 echo " [v1.1.3.0] Removed stale /checkpoint symlink (was shadowing Claude Code's /rewind alias)."
79 removed_any=1
80 else
81 echo " [v1.1.3.0] Leaving $OLD_TOPLEVEL alone — symlink target is outside gstack (or unresolvable)."
82 fi
83elif [ -d "$OLD_TOPLEVEL" ]; then
84 # Regular directory. Only remove if it contains exactly one file named
85 # SKILL.md that's a symlink into gstack (gstack's prefix-install shape).
86 # Use find to count real files, ignoring .DS_Store (macOS sidecars).
87 file_count=$(find "$OLD_TOPLEVEL" -maxdepth 1 -type f -not -name '.DS_Store' -not -name '._*' 2>/dev/null | wc -l | tr -d ' ')
88 symlink_count=$(find "$OLD_TOPLEVEL" -maxdepth 1 -type l 2>/dev/null | wc -l | tr -d ' ')
89 if [ "$file_count" = "0" ] && [ "$symlink_count" = "1" ] && [ -L "$OLD_TOPLEVEL/SKILL.md" ]; then
90 target_real=$(resolve_real "$OLD_TOPLEVEL/SKILL.md")
91 if [ -n "$GSTACK_ROOT_REAL" ] && path_inside "$target_real" "$GSTACK_ROOT_REAL"; then
92 # Strip macOS sidecars first (not user content), then remove the dir.
93 find "$OLD_TOPLEVEL" -maxdepth 1 \( -name '.DS_Store' -o -name '._*' \) -type f -delete 2>/dev/null || true
94 rm -r -- "$OLD_TOPLEVEL"
95 echo " [v1.1.3.0] Removed stale /checkpoint install directory (gstack prefix-mode)."
96 removed_any=1
97 else
98 echo " [v1.1.3.0] Leaving $OLD_TOPLEVEL alone — SKILL.md symlink target is outside gstack."
99 fi
100 else
101 echo " [v1.1.3.0] Leaving $OLD_TOPLEVEL alone — not a gstack-owned install (has custom content)."
102 fi
103fi
104# Missing → no-op (idempotency).
106# --- Shape 2: ~/.claude/skills/gstack/checkpoint/
107# Ownership guard applies here too: only remove if this path resolves inside the
108# gstack skills root. If a user replaced the directory with a symlink pointing
109# elsewhere (e.g., at their own fork), respect it.
110if [ -L "$OLD_NAMESPACED" ]; then
111 target_real=$(resolve_real "$OLD_NAMESPACED")
112 if [ -n "$GSTACK_ROOT_REAL" ] && path_inside "$target_real" "$GSTACK_ROOT_REAL"; then
113 rm -- "$OLD_NAMESPACED"
114 echo " [v1.1.3.0] Removed stale ~/.claude/skills/gstack/checkpoint symlink."
115 removed_any=1
116 else
117 echo " [v1.1.3.0] Leaving $OLD_NAMESPACED alone — symlink target is outside gstack."
118 fi
119elif [ -d "$OLD_NAMESPACED" ]; then
120 # Regular directory. This is the gstack-prefix install location. Check that
121 # it resolves to a path inside the gstack root (it should, unless someone
122 # hand-edited the tree).
123 target_real=$(resolve_real "$OLD_NAMESPACED")
124 if [ -n "$GSTACK_ROOT_REAL" ] && path_inside "$target_real" "$GSTACK_ROOT_REAL"; then
125 rm -rf -- "$OLD_NAMESPACED"
126 echo " [v1.1.3.0] Removed stale ~/.claude/skills/gstack/checkpoint/ (replaced by context-save + context-restore)."
127 removed_any=1
128 else
129 echo " [v1.1.3.0] Leaving $OLD_NAMESPACED alone — resolves outside gstack."
130 fi
131fi
133if [ "$removed_any" = "1" ]; then
134 echo " [v1.1.3.0] /checkpoint is now Claude Code's native /rewind alias. Use /context-save to save state and /context-restore to resume."
135fi
137exit 0