Skip to content

Commit dc5fa46

Browse files
committed
feat: add uninstall script
Closes #273. Adds bin/gstack-uninstall to cleanly remove gstack from a system: - Stops running browse daemons (SIGTERM with graceful fallback) - Removes Claude skills (~/.claude/skills/gstack + per-skill symlinks) - Removes Codex skills (~/.codex/skills/gstack*) - Removes per-project .agents/ sidecar and .gstack/ state - Removes global ~/.gstack/ state directory - Cleans up /tmp session files Supports --force (skip confirmation), --keep-state (preserve ~/.gstack/ data), and GSTACK_STATE_DIR env override for testing. Uses set -uo pipefail (no -e) so uninstall never aborts partway. Handles broken symlinks. Follows existing bin/ script conventions.
1 parent dbd98af commit dc5fa46

1 file changed

Lines changed: 179 additions & 0 deletions

File tree

bin/gstack-uninstall

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
#!/usr/bin/env bash
2+
# gstack-uninstall — remove gstack skills, state, and browse daemons
3+
#
4+
# Usage:
5+
# gstack-uninstall — interactive uninstall (prompts before removing)
6+
# gstack-uninstall --force — remove everything without prompting
7+
# gstack-uninstall --keep-state — remove skills but keep ~/.gstack/ data
8+
#
9+
# What gets removed:
10+
# ~/.claude/skills/gstack — global Claude skill install (git clone or vendored)
11+
# ~/.claude/skills/{skill} — per-skill symlinks created by setup
12+
# ~/.codex/skills/gstack* — Codex skill install + per-skill symlinks
13+
# ~/.gstack/ — global state (config, analytics, sessions, projects)
14+
# .gstack/ — per-project browse state (in current git repo)
15+
# .agents/skills/gstack* — Codex/Gemini/Cursor sidecar (in current git repo)
16+
# Running browse daemons — stopped via SIGTERM before cleanup
17+
#
18+
# What is NOT removed:
19+
# ~/Library/Caches/ms-playwright/ — Playwright Chromium (shared, may be used by other tools)
20+
# ~/.gstack-dev/ — developer eval artifacts (only present in gstack contributors)
21+
#
22+
# Env overrides (for testing):
23+
# GSTACK_DIR — override auto-detected gstack root
24+
# GSTACK_STATE_DIR — override ~/.gstack state directory
25+
#
26+
# NOTE: Uses set -uo pipefail (no -e) — uninstall must never abort partway.
27+
set -uo pipefail
28+
29+
GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
30+
STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}"
31+
_GIT_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
32+
33+
# ─── Parse flags ─────────────────────────────────────────────
34+
FORCE=0
35+
KEEP_STATE=0
36+
while [ $# -gt 0 ]; do
37+
case "$1" in
38+
--force) FORCE=1; shift ;;
39+
--keep-state) KEEP_STATE=1; shift ;;
40+
-h|--help)
41+
sed -n '2,/^[^#]/{ /^#/s/^# \{0,1\}//p; }' "$0"
42+
exit 0
43+
;;
44+
*)
45+
echo "Unknown option: $1" >&2
46+
echo "Usage: gstack-uninstall [--force] [--keep-state]" >&2
47+
exit 1
48+
;;
49+
esac
50+
done
51+
52+
# ─── Confirmation ────────────────────────────────────────────
53+
if [ "$FORCE" -eq 0 ]; then
54+
echo "This will remove gstack from your system:"
55+
{ [ -d "$HOME/.claude/skills/gstack" ] || [ -L "$HOME/.claude/skills/gstack" ]; } && echo " ~/.claude/skills/gstack"
56+
[ -d "$HOME/.codex/skills" ] && echo " ~/.codex/skills/gstack*"
57+
[ "$KEEP_STATE" -eq 0 ] && [ -d "$STATE_DIR" ] && echo " $STATE_DIR"
58+
59+
if [ -n "$_GIT_ROOT" ]; then
60+
[ -d "$_GIT_ROOT/.gstack" ] && echo " $_GIT_ROOT/.gstack/"
61+
[ -d "$_GIT_ROOT/.agents/skills" ] && echo " $_GIT_ROOT/.agents/skills/gstack*"
62+
fi
63+
64+
printf "\nContinue? [y/N] "
65+
read -r REPLY
66+
case "$REPLY" in
67+
y|Y|yes|YES) ;;
68+
*) echo "Aborted."; exit 0 ;;
69+
esac
70+
fi
71+
72+
removed=()
73+
74+
# ─── Stop running browse daemons ─────────────────────────────
75+
# Browse servers write PID to {project}/.gstack/browse.json.
76+
# Stop any we can find before removing state directories.
77+
stop_browse_daemon() {
78+
local state_file="$1"
79+
if [ ! -f "$state_file" ]; then
80+
return
81+
fi
82+
local pid
83+
pid="$(awk -F'[:,]' '/"pid"/ { for(i=1;i<=NF;i++) if($i ~ /"pid"/) { gsub(/[^0-9]/, "", $(i+1)); print $(i+1); exit } }' "$state_file" 2>/dev/null || true)"
84+
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
85+
kill "$pid" 2>/dev/null || true
86+
# Wait up to 2s for graceful shutdown
87+
local waited=0
88+
while [ "$waited" -lt 4 ] && kill -0 "$pid" 2>/dev/null; do
89+
sleep 0.5
90+
waited=$(( waited + 1 ))
91+
done
92+
if kill -0 "$pid" 2>/dev/null; then
93+
kill -9 "$pid" 2>/dev/null || true
94+
fi
95+
removed+=("browse daemon (PID $pid)")
96+
fi
97+
}
98+
99+
# Stop daemon in current project
100+
if [ -n "$_GIT_ROOT" ] && [ -f "$_GIT_ROOT/.gstack/browse.json" ]; then
101+
stop_browse_daemon "$_GIT_ROOT/.gstack/browse.json"
102+
fi
103+
104+
# Stop daemons tracked in global projects directory
105+
if [ -d "$STATE_DIR/projects" ]; then
106+
while IFS= read -r browse_json; do
107+
stop_browse_daemon "$browse_json"
108+
done < <(find "$STATE_DIR/projects" -name browse.json -path '*/.gstack/*' 2>/dev/null || true)
109+
fi
110+
111+
# ─── Remove Claude skills ───────────────────────────────────
112+
CLAUDE_SKILLS="$HOME/.claude/skills"
113+
if [ -d "$CLAUDE_SKILLS/gstack" ] || [ -L "$CLAUDE_SKILLS/gstack" ]; then
114+
# Remove per-skill symlinks that point into gstack/
115+
for link in "$CLAUDE_SKILLS"/*/; do
116+
name="$(basename "$link")"
117+
[ "$name" = "gstack" ] && continue
118+
if [ -L "${link%/}" ]; then
119+
target="$(readlink "${link%/}" 2>/dev/null || true)"
120+
case "$target" in
121+
gstack/*|*/gstack/*) rm -f "${link%/}"; removed+=("claude/$name") ;;
122+
esac
123+
fi
124+
done
125+
126+
# Remove the gstack directory/symlink itself
127+
rm -rf "$CLAUDE_SKILLS/gstack"
128+
removed+=("~/.claude/skills/gstack")
129+
fi
130+
131+
# ─── Remove Codex skills ────────────────────────────────────
132+
CODEX_SKILLS="$HOME/.codex/skills"
133+
if [ -d "$CODEX_SKILLS" ]; then
134+
for item in "$CODEX_SKILLS"/gstack*; do
135+
[ -e "$item" ] || [ -L "$item" ] || continue
136+
rm -rf "$item"
137+
removed+=("codex/$(basename "$item")")
138+
done
139+
fi
140+
141+
# ─── Remove per-project .agents/ sidecar ─────────────────────
142+
if [ -n "$_GIT_ROOT" ] && [ -d "$_GIT_ROOT/.agents/skills" ]; then
143+
for item in "$_GIT_ROOT/.agents/skills"/gstack*; do
144+
[ -e "$item" ] || [ -L "$item" ] || continue
145+
rm -rf "$item"
146+
removed+=("agents/$(basename "$item")")
147+
done
148+
149+
rmdir "$_GIT_ROOT/.agents/skills" 2>/dev/null || true
150+
rmdir "$_GIT_ROOT/.agents" 2>/dev/null || true
151+
fi
152+
153+
# ─── Remove per-project .gstack/ state ───────────────────────
154+
if [ -n "$_GIT_ROOT" ] && [ -d "$_GIT_ROOT/.gstack" ]; then
155+
rm -rf "$_GIT_ROOT/.gstack"
156+
removed+=("$_GIT_ROOT/.gstack/")
157+
fi
158+
159+
# ─── Remove global state ────────────────────────────────────
160+
if [ "$KEEP_STATE" -eq 0 ] && [ -d "$STATE_DIR" ]; then
161+
rm -rf "$STATE_DIR"
162+
removed+=("$STATE_DIR")
163+
fi
164+
165+
# ─── Clean up temp files ────────────────────────────────────
166+
for tmp_file in /tmp/gstack-latest-version /tmp/gstack-sketch-*.html /tmp/gstack-sketch.png; do
167+
if [ -e "$tmp_file" ]; then
168+
rm -f "$tmp_file"
169+
removed+=("$(basename "$tmp_file")")
170+
fi
171+
done
172+
173+
# ─── Summary ────────────────────────────────────────────────
174+
if [ ${#removed[@]} -gt 0 ]; then
175+
echo "Removed: ${removed[*]}"
176+
echo "gstack uninstalled."
177+
else
178+
echo "Nothing to remove — gstack is not installed."
179+
fi

0 commit comments

Comments
 (0)