Skip to content

Commit 9553286

Browse files
garrytanclaude
andauthored
feat: community PRs — faster install, skill namespacing, uninstall, Codex fallback, Windows fix, Python patterns (v0.12.9.0) (garrytan#561)
* fix: sync package.json version with VERSION file (0.12.7.0) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * perf: shallow clone for faster install (garrytan#484) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat: Python/async/SSRF patterns in review checklist (garrytan#531) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat: namespace skill symlinks with gstack- prefix (garrytan#503) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat: add uninstall script (garrytan#323) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat: office-hours Claude subagent fallback when Codex unavailable (garrytan#464) Updates generateCodexSecondOpinion resolver to always offer second opinion and fall back to Claude subagent when Codex is unavailable or errors. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: findPort() race condition via net.createServer (garrytan#490) Replaces Bun.serve() port probing with net.createServer() for proper async bind/close semantics. Fixes Windows EADDRINUSE race condition. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * test: add tests for uninstall, setup prefix, and resolver fallback - Uninstall integration tests: syntax, flags, mock install layout, upgrade path - Setup prefix tests: gstack-* prefixing, --no-prefix, cleanup migration - Resolver tests: Claude subagent fallback in generated SKILL.md Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * chore: bump version and changelog (v0.12.9.0) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
1 parent 0ff62b4 commit 9553286

File tree

14 files changed

+821
-54
lines changed

14 files changed

+821
-54
lines changed

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,25 @@
11
# Changelog
22

3+
## [0.12.9.0] - 2026-03-27 — Community PRs: Faster Install, Skill Namespacing, Uninstall
4+
5+
Six community PRs landed in one batch. Install is faster, skills no longer collide with other tools, and you can cleanly uninstall gstack when needed.
6+
7+
### Added
8+
9+
- **Uninstall script.** `bin/gstack-uninstall` cleanly removes gstack from your system: stops browse daemons, removes all skill installs (Claude/Codex/Kiro), cleans up state. Supports `--force` (skip confirmation) and `--keep-state` (preserve config). (#323)
10+
- **Python security patterns in /review.** Shell injection (`subprocess.run(shell=True)`), SSRF via LLM-generated URLs, stored prompt injection, async/sync mixing, and column name safety checks now fire automatically on Python projects. (#531)
11+
- **Office-hours works without Codex.** The "second opinion" step now falls back to a Claude subagent when Codex CLI is unavailable, so every user gets the cross-model perspective. (#464)
12+
13+
### Changed
14+
15+
- **Faster install (~30s).** All clone commands now use `--single-branch --depth 1`. Full history available for contributors. (#484)
16+
- **Skills namespaced with `gstack-` prefix.** Skill symlinks are now `gstack-review`, `gstack-ship`, etc. instead of bare `review`, `ship`. Prevents collisions with other skill packs. Old symlinks are auto-cleaned on upgrade. Use `--no-prefix` to opt out. (#503)
17+
18+
### Fixed
19+
20+
- **Windows port race condition.** `findPort()` now uses `net.createServer()` instead of `Bun.serve()` for port probing, fixing an EADDRINUSE race on Windows where the polyfill's `stop()` is fire-and-forget. (#490)
21+
- **package.json version sync.** VERSION file and package.json now agree (was stuck at 0.12.5.0).
22+
323
## [0.12.8.1] - 2026-03-27 — zsh Glob Compatibility
424

525
Skill scripts now work correctly in zsh. Previously, bash code blocks in skill templates used raw glob patterns like `.github/workflows/*.yaml` and `ls ~/.gstack/projects/$SLUG/*-design-*.md` that would throw "no matches found" errors in zsh when no files matched. Fixed 38 instances across 13 templates and 2 resolvers using two approaches: `find`-based alternatives for complex patterns, and `setopt +o nomatch` guards for simple `ls` commands.

README.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,22 +46,27 @@ Fork it. Improve it. Make it yours. And if you want to hate on free open source
4646

4747
Open Claude Code and paste this. Claude does the rest.
4848

49-
> Install gstack: run **`git clone https://github.com/garrytan/gstack.git ~/.claude/skills/gstack && cd ~/.claude/skills/gstack && ./setup`** then add a "gstack" section to CLAUDE.md that says to use the /browse skill from gstack for all web browsing, never use mcp\_\_claude-in-chrome\_\_\* tools, and lists the available skills: /office-hours, /plan-ceo-review, /plan-eng-review, /plan-design-review, /design-consultation, /review, /ship, /land-and-deploy, /canary, /benchmark, /browse, /qa, /qa-only, /design-review, /setup-browser-cookies, /setup-deploy, /retro, /investigate, /document-release, /codex, /cso, /autoplan, /careful, /freeze, /guard, /unfreeze, /gstack-upgrade. Then ask the user if they also want to add gstack to the current project so teammates get it.
49+
> Install gstack: run **`git clone --single-branch --depth 1 https://github.com/garrytan/gstack.git ~/.claude/skills/gstack && cd ~/.claude/skills/gstack && ./setup`** then add a "gstack" section to CLAUDE.md that says to use the /browse skill from gstack for all web browsing, never use mcp\_\_claude-in-chrome\_\_\* tools, and lists the available skills: /office-hours, /plan-ceo-review, /plan-eng-review, /plan-design-review, /design-consultation, /review, /ship, /land-and-deploy, /canary, /benchmark, /browse, /qa, /qa-only, /design-review, /setup-browser-cookies, /setup-deploy, /retro, /investigate, /document-release, /codex, /cso, /autoplan, /careful, /freeze, /guard, /unfreeze, /gstack-upgrade. Then ask the user if they also want to add gstack to the current project so teammates get it.
5050
5151
### Step 2: Add to your repo so teammates get it (optional)
5252

5353
> Add gstack to this project: run **`cp -Rf ~/.claude/skills/gstack .claude/skills/gstack && rm -rf .claude/skills/gstack/.git && cd .claude/skills/gstack && ./setup`** then add a "gstack" section to this project's CLAUDE.md that says to use the /browse skill from gstack for all web browsing, never use mcp\_\_claude-in-chrome\_\_\* tools, lists the available skills: /office-hours, /plan-ceo-review, /plan-eng-review, /plan-design-review, /design-consultation, /review, /ship, /land-and-deploy, /canary, /benchmark, /browse, /qa, /qa-only, /design-review, /setup-browser-cookies, /setup-deploy, /retro, /investigate, /document-release, /codex, /cso, /careful, /freeze, /guard, /unfreeze, /gstack-upgrade, and tells Claude that if gstack skills aren't working, run `cd .claude/skills/gstack && ./setup` to build the binary and register skills.
5454
5555
Real files get committed to your repo (not a submodule), so `git clone` just works. Everything lives inside `.claude/`. Nothing touches your PATH or runs in the background.
5656

57+
> **Contributing or need full history?** The commands above use `--depth 1` for a fast install. If you plan to contribute or need full git history, do a full clone instead:
58+
> ```bash
59+
> git clone https://github.com/garrytan/gstack.git ~/.claude/skills/gstack
60+
> ```
61+
5762
### Codex, Gemini CLI, or Cursor
5863
5964
gstack works on any agent that supports the [SKILL.md standard](https://github.com/anthropics/claude-code). Skills live in `.agents/skills/` and are discovered automatically.
6065
6166
Install to one repo:
6267
6368
```bash
64-
git clone https://github.com/garrytan/gstack.git .agents/skills/gstack
69+
git clone --single-branch --depth 1 https://github.com/garrytan/gstack.git .agents/skills/gstack
6570
cd .agents/skills/gstack && ./setup --host codex
6671
```
6772
@@ -70,7 +75,7 @@ When setup runs from `.agents/skills/gstack`, it installs the generated Codex sk
7075
Install once for your user account:
7176

7277
```bash
73-
git clone https://github.com/garrytan/gstack.git ~/gstack
78+
git clone --single-branch --depth 1 https://github.com/garrytan/gstack.git ~/gstack
7479
cd ~/gstack && ./setup --host codex
7580
```
7681

@@ -81,7 +86,7 @@ discovery from the source repo checkout.
8186
Or let setup auto-detect which agents you have installed:
8287

8388
```bash
84-
git clone https://github.com/garrytan/gstack.git ~/gstack
89+
git clone --single-branch --depth 1 https://github.com/garrytan/gstack.git ~/gstack
8590
cd ~/gstack && ./setup --host auto
8691
```
8792

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.12.8.1
1+
0.12.9.0

bin/gstack-uninstall

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
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+
# ~/.kiro/skills/gstack* — Kiro skill install + per-skill symlinks
14+
# ~/.gstack/ — global state (config, analytics, sessions, projects,
15+
# repos, installation-id, browse error logs)
16+
# .claude/skills/gstack* — project-local skill install (--local installs)
17+
# .gstack/ — per-project browse state (in current git repo)
18+
# .gstack-worktrees/ — per-project test worktrees (in current git repo)
19+
# .agents/skills/gstack* — Codex/Gemini/Cursor sidecar (in current git repo)
20+
# Running browse daemons — stopped via SIGTERM before cleanup
21+
#
22+
# What is NOT REMOVED:
23+
# ~/Library/Caches/ms-playwright/ — Playwright Chromium (shared, may be used by other tools)
24+
# ~/.gstack-dev/ — developer eval artifacts (only present in gstack contributors)
25+
#
26+
# Env overrides (for testing):
27+
# GSTACK_DIR — override auto-detected gstack root
28+
# GSTACK_STATE_DIR — override ~/.gstack state directory
29+
#
30+
# NOTE: Uses set -uo pipefail (no -e) — uninstall must never abort partway.
31+
set -uo pipefail
32+
33+
if [ -z "${HOME:-}" ]; then
34+
echo "ERROR: \$HOME is not set" >&2
35+
exit 1
36+
fi
37+
38+
GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
39+
STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}"
40+
_GIT_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
41+
42+
# ─── Parse flags ─────────────────────────────────────────────
43+
FORCE=0
44+
KEEP_STATE=0
45+
while [ $# -gt 0 ]; do
46+
case "$1" in
47+
--force) FORCE=1; shift ;;
48+
--keep-state) KEEP_STATE=1; shift ;;
49+
-h|--help)
50+
sed -n '2,/^[^#]/{ /^#/s/^# \{0,1\}//p; }' "$0"
51+
exit 0
52+
;;
53+
*)
54+
echo "Unknown option: $1" >&2
55+
echo "Usage: gstack-uninstall [--force] [--keep-state]" >&2
56+
exit 1
57+
;;
58+
esac
59+
done
60+
61+
# ─── Confirmation ────────────────────────────────────────────
62+
if [ "$FORCE" -eq 0 ]; then
63+
echo "This will remove gstack from your system:"
64+
{ [ -d "$HOME/.claude/skills/gstack" ] || [ -L "$HOME/.claude/skills/gstack" ]; } && echo " ~/.claude/skills/gstack (+ per-skill symlinks)"
65+
[ -d "$HOME/.codex/skills" ] && echo " ~/.codex/skills/gstack*"
66+
[ -d "$HOME/.kiro/skills" ] && echo " ~/.kiro/skills/gstack*"
67+
[ "$KEEP_STATE" -eq 0 ] && [ -d "$STATE_DIR" ] && echo " $STATE_DIR"
68+
69+
if [ -n "$_GIT_ROOT" ]; then
70+
[ -d "$_GIT_ROOT/.claude/skills/gstack" ] && echo " $_GIT_ROOT/.claude/skills/gstack (project-local)"
71+
[ -d "$_GIT_ROOT/.gstack" ] && echo " $_GIT_ROOT/.gstack/ (browse state + reports)"
72+
[ -d "$_GIT_ROOT/.gstack-worktrees" ] && echo " $_GIT_ROOT/.gstack-worktrees/"
73+
[ -d "$_GIT_ROOT/.agents/skills" ] && echo " $_GIT_ROOT/.agents/skills/gstack*"
74+
fi
75+
76+
# Preview running daemons
77+
if [ -n "$_GIT_ROOT" ] && [ -f "$_GIT_ROOT/.gstack/browse.json" ]; then
78+
_PREVIEW_PID="$(awk -F'[:,]' '/"pid"/ { for(i=1;i<=NF;i++) if($i ~ /"pid"/) { gsub(/[^0-9]/, "", $(i+1)); print $(i+1); exit } }' "$_GIT_ROOT/.gstack/browse.json" 2>/dev/null || true)"
79+
[ -n "$_PREVIEW_PID" ] && kill -0 "$_PREVIEW_PID" 2>/dev/null && echo " browse daemon (PID $_PREVIEW_PID) will be stopped"
80+
fi
81+
82+
printf "\nContinue? [y/N] "
83+
read -r REPLY
84+
case "$REPLY" in
85+
y|Y|yes|YES) ;;
86+
*) echo "Aborted."; exit 0 ;;
87+
esac
88+
fi
89+
90+
REMOVED=()
91+
92+
# ─── Stop running browse daemons ─────────────────────────────
93+
# Browse servers write PID to {project}/.gstack/browse.json.
94+
# Stop any we can find before removing state directories.
95+
stop_browse_daemon() {
96+
local state_file="$1"
97+
if [ ! -f "$state_file" ]; then
98+
return
99+
fi
100+
local pid
101+
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)"
102+
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
103+
kill "$pid" 2>/dev/null || true
104+
# Wait up to 2s for graceful shutdown
105+
local waited=0
106+
while [ "$waited" -lt 4 ] && kill -0 "$pid" 2>/dev/null; do
107+
sleep 0.5
108+
waited=$(( waited + 1 ))
109+
done
110+
if kill -0 "$pid" 2>/dev/null; then
111+
kill -9 "$pid" 2>/dev/null || true
112+
fi
113+
REMOVED+=("browse daemon (PID $pid)")
114+
fi
115+
}
116+
117+
# Stop daemon in current project
118+
if [ -n "$_GIT_ROOT" ] && [ -f "$_GIT_ROOT/.gstack/browse.json" ]; then
119+
stop_browse_daemon "$_GIT_ROOT/.gstack/browse.json"
120+
fi
121+
122+
# Stop daemons tracked in global projects directory
123+
if [ -d "$STATE_DIR/projects" ]; then
124+
while IFS= read -r _BJ; do
125+
stop_browse_daemon "$_BJ"
126+
done < <(find "$STATE_DIR/projects" -name browse.json -path '*/.gstack/*' 2>/dev/null || true)
127+
fi
128+
129+
# ─── Remove global Claude skills ────────────────────────────
130+
CLAUDE_SKILLS="$HOME/.claude/skills"
131+
if [ -d "$CLAUDE_SKILLS/gstack" ] || [ -L "$CLAUDE_SKILLS/gstack" ]; then
132+
# Remove per-skill symlinks that point into gstack/
133+
for _LINK in "$CLAUDE_SKILLS"/*; do
134+
[ -L "$_LINK" ] || continue
135+
_NAME="$(basename "$_LINK")"
136+
[ "$_NAME" = "gstack" ] && continue
137+
_TARGET="$(readlink "$_LINK" 2>/dev/null || true)"
138+
case "$_TARGET" in
139+
gstack/*|*/gstack/*) rm -f "$_LINK"; REMOVED+=("claude/$_NAME") ;;
140+
esac
141+
done
142+
143+
rm -rf "$CLAUDE_SKILLS/gstack"
144+
REMOVED+=("~/.claude/skills/gstack")
145+
fi
146+
147+
# ─── Remove project-local Claude skills (--local installs) ──
148+
if [ -n "$_GIT_ROOT" ] && [ -d "$_GIT_ROOT/.claude/skills" ]; then
149+
for _LINK in "$_GIT_ROOT/.claude/skills"/*; do
150+
[ -L "$_LINK" ] || continue
151+
_TARGET="$(readlink "$_LINK" 2>/dev/null || true)"
152+
case "$_TARGET" in
153+
gstack/*|*/gstack/*) rm -f "$_LINK"; REMOVED+=("local claude/$(basename "$_LINK")") ;;
154+
esac
155+
done
156+
if [ -d "$_GIT_ROOT/.claude/skills/gstack" ] || [ -L "$_GIT_ROOT/.claude/skills/gstack" ]; then
157+
rm -rf "$_GIT_ROOT/.claude/skills/gstack"
158+
REMOVED+=("$_GIT_ROOT/.claude/skills/gstack")
159+
fi
160+
fi
161+
162+
# ─── Remove Codex skills ────────────────────────────────────
163+
CODEX_SKILLS="$HOME/.codex/skills"
164+
if [ -d "$CODEX_SKILLS" ]; then
165+
for _ITEM in "$CODEX_SKILLS"/gstack*; do
166+
[ -e "$_ITEM" ] || [ -L "$_ITEM" ] || continue
167+
rm -rf "$_ITEM"
168+
REMOVED+=("codex/$(basename "$_ITEM")")
169+
done
170+
fi
171+
172+
# ─── Remove Kiro skills ─────────────────────────────────────
173+
KIRO_SKILLS="$HOME/.kiro/skills"
174+
if [ -d "$KIRO_SKILLS" ]; then
175+
for _ITEM in "$KIRO_SKILLS"/gstack*; do
176+
[ -e "$_ITEM" ] || [ -L "$_ITEM" ] || continue
177+
rm -rf "$_ITEM"
178+
REMOVED+=("kiro/$(basename "$_ITEM")")
179+
done
180+
fi
181+
182+
# ─── Remove per-project .agents/ sidecar ─────────────────────
183+
if [ -n "$_GIT_ROOT" ] && [ -d "$_GIT_ROOT/.agents/skills" ]; then
184+
for _ITEM in "$_GIT_ROOT/.agents/skills"/gstack*; do
185+
[ -e "$_ITEM" ] || [ -L "$_ITEM" ] || continue
186+
rm -rf "$_ITEM"
187+
REMOVED+=("agents/$(basename "$_ITEM")")
188+
done
189+
190+
rmdir "$_GIT_ROOT/.agents/skills" 2>/dev/null || true
191+
rmdir "$_GIT_ROOT/.agents" 2>/dev/null || true
192+
fi
193+
194+
# ─── Remove per-project state ───────────────────────────────
195+
if [ -n "$_GIT_ROOT" ]; then
196+
if [ -d "$_GIT_ROOT/.gstack" ]; then
197+
rm -rf "$_GIT_ROOT/.gstack"
198+
REMOVED+=("$_GIT_ROOT/.gstack/")
199+
fi
200+
if [ -d "$_GIT_ROOT/.gstack-worktrees" ]; then
201+
rm -rf "$_GIT_ROOT/.gstack-worktrees"
202+
REMOVED+=("$_GIT_ROOT/.gstack-worktrees/")
203+
fi
204+
fi
205+
206+
# ─── Remove global state ────────────────────────────────────
207+
if [ "$KEEP_STATE" -eq 0 ] && [ -d "$STATE_DIR" ]; then
208+
rm -rf "$STATE_DIR"
209+
REMOVED+=("$STATE_DIR")
210+
fi
211+
212+
# ─── Clean up temp files ────────────────────────────────────
213+
for _TMP in /tmp/gstack-latest-version /tmp/gstack-sketch-*.html /tmp/gstack-sketch.png /tmp/gstack-sync-*; do
214+
if [ -e "$_TMP" ]; then
215+
rm -f "$_TMP"
216+
REMOVED+=("$(basename "$_TMP")")
217+
fi
218+
done
219+
220+
# ─── Summary ────────────────────────────────────────────────
221+
if [ ${#REMOVED[@]} -gt 0 ]; then
222+
echo "Removed: ${REMOVED[*]}"
223+
echo "gstack uninstalled."
224+
else
225+
echo "Nothing to remove — gstack is not installed."
226+
fi
227+
228+
exit 0

browse/src/server.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { emitActivity, subscribe, getActivityAfter, getActivityHistory, getSubsc
2626
// Bun.spawn used instead of child_process.spawn (compiled bun binaries
2727
// fail posix_spawn on all executables including /bin/bash)
2828
import * as fs from 'fs';
29+
import * as net from 'net';
2930
import * as path from 'path';
3031
import * as crypto from 'crypto';
3132

@@ -547,17 +548,28 @@ export { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS };
547548
const browserManager = new BrowserManager();
548549
let isShuttingDown = false;
549550

551+
// Test if a port is available by binding and immediately releasing.
552+
// Uses net.createServer instead of Bun.serve to avoid a race condition
553+
// in the Node.js polyfill where listen/close are async but the caller
554+
// expects synchronous bind semantics. See: #486
555+
function isPortAvailable(port: number, hostname: string = '127.0.0.1'): Promise<boolean> {
556+
return new Promise((resolve) => {
557+
const srv = net.createServer();
558+
srv.once('error', () => resolve(false));
559+
srv.listen(port, hostname, () => {
560+
srv.close(() => resolve(true));
561+
});
562+
});
563+
}
564+
550565
// Find port: explicit BROWSE_PORT, or random in 10000-60000
551566
async function findPort(): Promise<number> {
552567
// Explicit port override (for debugging)
553568
if (BROWSE_PORT) {
554-
try {
555-
const testServer = Bun.serve({ port: BROWSE_PORT, fetch: () => new Response('ok') });
556-
testServer.stop();
569+
if (await isPortAvailable(BROWSE_PORT)) {
557570
return BROWSE_PORT;
558-
} catch {
559-
throw new Error(`[browse] Port ${BROWSE_PORT} (from BROWSE_PORT env) is in use`);
560571
}
572+
throw new Error(`[browse] Port ${BROWSE_PORT} (from BROWSE_PORT env) is in use`);
561573
}
562574

563575
// Random port with retry
@@ -566,12 +578,8 @@ async function findPort(): Promise<number> {
566578
const MAX_RETRIES = 5;
567579
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
568580
const port = MIN_PORT + Math.floor(Math.random() * (MAX_PORT - MIN_PORT));
569-
try {
570-
const testServer = Bun.serve({ port, fetch: () => new Response('ok') });
571-
testServer.stop();
581+
if (await isPortAvailable(port)) {
572582
return port;
573-
} catch {
574-
continue;
575583
}
576584
}
577585
throw new Error(`[browse] No available port after ${MAX_RETRIES} attempts in range ${MIN_PORT}-${MAX_PORT}`);

0 commit comments

Comments
 (0)