diff --git a/bin/gstack-uninstall b/bin/gstack-uninstall index c379c2265..f5199f293 100755 --- a/bin/gstack-uninstall +++ b/bin/gstack-uninstall @@ -12,12 +12,14 @@ # ~/.codex/skills/gstack* — Codex skill install + per-skill symlinks # ~/.factory/skills/gstack* — Factory Droid skill install + per-skill symlinks # ~/.kiro/skills/gstack* — Kiro skill install + per-skill symlinks +# ~/.cursor/skills/gstack* — Cursor skill install + per-skill symlinks # ~/.gstack/ — global state (config, analytics, sessions, projects, # repos, installation-id, browse error logs) # .claude/skills/gstack* — project-local skill install (--local installs) # .gstack/ — per-project browse state (in current git repo) # .gstack-worktrees/ — per-project test worktrees (in current git repo) -# .agents/skills/gstack* — Codex/Gemini/Cursor sidecar (in current git repo) +# .agents/skills/gstack* — Codex/Gemini sidecar (in current git repo) +# .cursor/skills/gstack* — project-local Cursor skills (in current git repo) # Running browse daemons — stopped via SIGTERM before cleanup # # What is NOT REMOVED: @@ -66,6 +68,7 @@ if [ "$FORCE" -eq 0 ]; then [ -d "$HOME/.codex/skills" ] && echo " ~/.codex/skills/gstack*" [ -d "$HOME/.factory/skills" ] && echo " ~/.factory/skills/gstack*" [ -d "$HOME/.kiro/skills" ] && echo " ~/.kiro/skills/gstack*" + [ -d "$HOME/.cursor/skills" ] && echo " ~/.cursor/skills/gstack*" [ "$KEEP_STATE" -eq 0 ] && [ -d "$STATE_DIR" ] && echo " $STATE_DIR" if [ -n "$_GIT_ROOT" ]; then @@ -73,6 +76,7 @@ if [ "$FORCE" -eq 0 ]; then [ -d "$_GIT_ROOT/.gstack" ] && echo " $_GIT_ROOT/.gstack/ (browse state + reports)" [ -d "$_GIT_ROOT/.gstack-worktrees" ] && echo " $_GIT_ROOT/.gstack-worktrees/" [ -d "$_GIT_ROOT/.agents/skills" ] && echo " $_GIT_ROOT/.agents/skills/gstack*" + [ -d "$_GIT_ROOT/.cursor/skills" ] && echo " $_GIT_ROOT/.cursor/skills/gstack*" fi # Preview running daemons @@ -247,6 +251,16 @@ if [ -d "$KIRO_SKILLS" ]; then done fi +# ─── Remove Cursor skills ─────────────────────────────────── +CURSOR_SKILLS="$HOME/.cursor/skills" +if [ -d "$CURSOR_SKILLS" ]; then + for _ITEM in "$CURSOR_SKILLS"/gstack*; do + [ -e "$_ITEM" ] || [ -L "$_ITEM" ] || continue + rm -rf "$_ITEM" + REMOVED+=("cursor/$(basename "$_ITEM")") + done +fi + # ─── Remove per-project .agents/ sidecar ───────────────────── if [ -n "$_GIT_ROOT" ] && [ -d "$_GIT_ROOT/.agents/skills" ]; then for _ITEM in "$_GIT_ROOT/.agents/skills"/gstack*; do @@ -271,6 +285,18 @@ if [ -n "$_GIT_ROOT" ] && [ -d "$_GIT_ROOT/.factory/skills" ]; then rmdir "$_GIT_ROOT/.factory" 2>/dev/null || true fi +# ─── Remove per-project .cursor/skills/gstack* ────────────── +# Never rmdir .cursor itself — Cursor IDE stores rules and other user config there. +if [ -n "$_GIT_ROOT" ] && [ -d "$_GIT_ROOT/.cursor/skills" ]; then + for _ITEM in "$_GIT_ROOT/.cursor/skills"/gstack*; do + [ -e "$_ITEM" ] || [ -L "$_ITEM" ] || continue + rm -rf "$_ITEM" + REMOVED+=("cursor/$(basename "$_ITEM")") + done + + rmdir "$_GIT_ROOT/.cursor/skills" 2>/dev/null || true +fi + # ─── Remove per-project state ─────────────────────────────── if [ -n "$_GIT_ROOT" ]; then if [ -d "$_GIT_ROOT/.gstack" ]; then diff --git a/setup b/setup index b4cbed373..f0c1a1cd9 100755 --- a/setup +++ b/setup @@ -58,6 +58,8 @@ FACTORY_SKILLS="$HOME/.factory/skills" FACTORY_GSTACK="$FACTORY_SKILLS/gstack" OPENCODE_SKILLS="$HOME/.config/opencode/skills" OPENCODE_GSTACK="$OPENCODE_SKILLS/gstack" +CURSOR_SKILLS="$HOME/.cursor/skills" +CURSOR_GSTACK="$CURSOR_SKILLS/gstack" IS_WINDOWS=0 case "$(uname -s)" in @@ -239,14 +241,19 @@ INSTALL_CODEX=0 INSTALL_KIRO=0 INSTALL_FACTORY=0 INSTALL_OPENCODE=0 +INSTALL_CURSOR=0 if [ "$HOST" = "auto" ]; then command -v claude >/dev/null 2>&1 && INSTALL_CLAUDE=1 command -v codex >/dev/null 2>&1 && INSTALL_CODEX=1 command -v kiro-cli >/dev/null 2>&1 && INSTALL_KIRO=1 command -v droid >/dev/null 2>&1 && INSTALL_FACTORY=1 command -v opencode >/dev/null 2>&1 && INSTALL_OPENCODE=1 + # Cursor's `cursor` CLI shim isn't always on PATH; ~/.cursor is the + # reliable footprint of an installed Cursor IDE. + command -v cursor >/dev/null 2>&1 && INSTALL_CURSOR=1 + [ -d "$HOME/.cursor" ] && INSTALL_CURSOR=1 # If none found, default to claude - if [ "$INSTALL_CLAUDE" -eq 0 ] && [ "$INSTALL_CODEX" -eq 0 ] && [ "$INSTALL_KIRO" -eq 0 ] && [ "$INSTALL_FACTORY" -eq 0 ] && [ "$INSTALL_OPENCODE" -eq 0 ]; then + if [ "$INSTALL_CLAUDE" -eq 0 ] && [ "$INSTALL_CODEX" -eq 0 ] && [ "$INSTALL_KIRO" -eq 0 ] && [ "$INSTALL_FACTORY" -eq 0 ] && [ "$INSTALL_OPENCODE" -eq 0 ] && [ "$INSTALL_CURSOR" -eq 0 ]; then INSTALL_CLAUDE=1 fi elif [ "$HOST" = "claude" ]; then @@ -259,6 +266,8 @@ elif [ "$HOST" = "factory" ]; then INSTALL_FACTORY=1 elif [ "$HOST" = "opencode" ]; then INSTALL_OPENCODE=1 +elif [ "$HOST" = "cursor" ]; then + INSTALL_CURSOR=1 fi migrate_direct_codex_install() { @@ -593,6 +602,16 @@ if [ "$INSTALL_OPENCODE" -eq 1 ] && [ "$NEEDS_BUILD" -eq 0 ]; then ) fi +# 1e. Generate .cursor/ Cursor skill docs +if [ "$INSTALL_CURSOR" -eq 1 ] && [ "$NEEDS_BUILD" -eq 0 ]; then + log "Generating .cursor/ skill docs..." + ( + cd "$SOURCE_GSTACK_DIR" + bun_cmd install --frozen-lockfile 2>/dev/null || bun_cmd install + bun_cmd run gen:skill-docs --host cursor + ) +fi + # 2. Ensure Playwright's Chromium is available # Detect Ubuntu 26.04: Playwright does not yet ship a native chromium build for # ubuntu26.04-x64. Override the platform to ubuntu24.04-x64 so the installer @@ -1234,6 +1253,143 @@ link_opencode_skill_dirs() { fi } +# ─── Helper: create a minimal ~/.cursor/skills/gstack runtime root ────────── +# Cursor scans ~/.cursor/skills. Same shape as the Codex/Factory/OpenCode +# runtime roots: root SKILL.md from the generated tree + runtime assets only. +# Contributed by @szsunyuan (PR #2547), re-derived onto the current installers. +create_cursor_runtime_root() { + local gstack_dir="$1" + local cursor_gstack="$2" + local cursor_dir="$gstack_dir/.cursor/skills" + local generated_root="$cursor_dir/gstack" + + if [ -L "$cursor_gstack" ]; then + rm -f "$cursor_gstack" + elif [ -d "$cursor_gstack" ] && [ "$cursor_gstack" != "$gstack_dir" ] && [ "$cursor_gstack" != "$generated_root" ]; then + rm -rf "$cursor_gstack" + fi + + mkdir -p "$cursor_gstack" "$cursor_gstack/browse" "$cursor_gstack/gstack-upgrade" "$cursor_gstack/review" + + if [ -f "$cursor_dir/gstack/SKILL.md" ]; then + _link_or_copy "$cursor_dir/gstack/SKILL.md" "$cursor_gstack/SKILL.md" + fi + # bin scripts import shared modules via ../lib — bin and lib travel together. + if [ -d "$gstack_dir/bin" ]; then + _link_or_copy "$gstack_dir/bin" "$cursor_gstack/bin" + fi + if [ -d "$gstack_dir/lib" ]; then + _link_or_copy "$gstack_dir/lib" "$cursor_gstack/lib" + fi + if [ -d "$gstack_dir/browse/dist" ]; then + _link_or_copy "$gstack_dir/browse/dist" "$cursor_gstack/browse/dist" + fi + if [ -d "$gstack_dir/browse/bin" ]; then + _link_or_copy "$gstack_dir/browse/bin" "$cursor_gstack/browse/bin" + fi + if [ -f "$cursor_dir/gstack-upgrade/SKILL.md" ]; then + _link_or_copy "$cursor_dir/gstack-upgrade/SKILL.md" "$cursor_gstack/gstack-upgrade/SKILL.md" + fi + # Review runtime assets — the cursor host config ships the lean pair. + for f in checklist.md TODOS-format.md; do + if [ -f "$gstack_dir/review/$f" ]; then + _link_or_copy "$gstack_dir/review/$f" "$cursor_gstack/review/$f" + fi + done + if [ -f "$gstack_dir/ETHOS.md" ]; then + _link_or_copy "$gstack_dir/ETHOS.md" "$cursor_gstack/ETHOS.md" + fi + # supabase/config.sh — required by gstack-telemetry-sync to resolve GSTACK_SUPABASE_URL + if [ -f "$gstack_dir/supabase/config.sh" ]; then + mkdir -p "$cursor_gstack/supabase" + _link_or_copy "$gstack_dir/supabase/config.sh" "$cursor_gstack/supabase/config.sh" + fi +} + +# Plant runtime assets into the repo-local generated skill dir so in-repo +# GSTACK_ROOT (preamble prefers $_ROOT/.cursor/skills/gstack) has bin/. +# NEVER wipe this directory — it holds the generated SKILL.md files. +create_cursor_sidecar() { + local repo_root="$1" + local cursor_gstack="$repo_root/.cursor/skills/gstack" + local cursor_dir="$repo_root/.cursor/skills" + + mkdir -p "$cursor_gstack" "$cursor_gstack/browse" "$cursor_gstack/gstack-upgrade" "$cursor_gstack/review" + + if [ -d "$repo_root/bin" ]; then + if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$cursor_gstack/bin" ] || [ ! -e "$cursor_gstack/bin" ]; then + _link_or_copy "$repo_root/bin" "$cursor_gstack/bin" + fi + fi + if [ -d "$repo_root/lib" ]; then + if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$cursor_gstack/lib" ] || [ ! -e "$cursor_gstack/lib" ]; then + _link_or_copy "$repo_root/lib" "$cursor_gstack/lib" + fi + fi + if [ -d "$repo_root/browse/dist" ]; then + if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$cursor_gstack/browse/dist" ] || [ ! -e "$cursor_gstack/browse/dist" ]; then + _link_or_copy "$repo_root/browse/dist" "$cursor_gstack/browse/dist" + fi + fi + if [ -d "$repo_root/browse/bin" ]; then + if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$cursor_gstack/browse/bin" ] || [ ! -e "$cursor_gstack/browse/bin" ]; then + _link_or_copy "$repo_root/browse/bin" "$cursor_gstack/browse/bin" + fi + fi + if [ -f "$cursor_dir/gstack-upgrade/SKILL.md" ]; then + if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$cursor_gstack/gstack-upgrade/SKILL.md" ] || [ ! -e "$cursor_gstack/gstack-upgrade/SKILL.md" ]; then + _link_or_copy "$cursor_dir/gstack-upgrade/SKILL.md" "$cursor_gstack/gstack-upgrade/SKILL.md" + fi + fi + for f in checklist.md TODOS-format.md; do + if [ -f "$repo_root/review/$f" ]; then + if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$cursor_gstack/review/$f" ] || [ ! -e "$cursor_gstack/review/$f" ]; then + _link_or_copy "$repo_root/review/$f" "$cursor_gstack/review/$f" + fi + fi + done + if [ -f "$repo_root/ETHOS.md" ]; then + if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$cursor_gstack/ETHOS.md" ] || [ ! -e "$cursor_gstack/ETHOS.md" ]; then + _link_or_copy "$repo_root/ETHOS.md" "$cursor_gstack/ETHOS.md" + fi + fi +} + +link_cursor_skill_dirs() { + local gstack_dir="$1" + local skills_dir="$2" + local cursor_dir="$gstack_dir/.cursor/skills" + local linked=() + + if [ ! -d "$cursor_dir" ]; then + echo " Generating .cursor/ skill docs..." + ( cd "$gstack_dir" && bun_cmd run gen:skill-docs --host cursor ) + fi + + if [ ! -d "$cursor_dir" ]; then + echo " warning: .cursor/skills/ generation failed — run 'bun run gen:skill-docs --host cursor' manually" >&2 + return 1 + fi + + for skill_dir in "$cursor_dir"/gstack*/; do + if [ -f "$skill_dir/SKILL.md" ]; then + skill_name="$(basename "$skill_dir")" + [ "$skill_name" = "gstack" ] && continue + target="$skills_dir/$skill_name" + # #2444: IS_WINDOWS bypass — real-dir copies never match -L, so re-runs + # skipped the refresh. Only replace a symlink or a missing path + # otherwise; never an unowned real Cursor skill dir (#2142). + if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$target" ] || [ ! -e "$target" ]; then + _link_or_copy "$skill_dir" "$target" + linked+=("$skill_name") + fi + fi + done + if [ ${#linked[@]} -gt 0 ]; then + echo " linked skills: ${linked[*]}" + fi +} + # 4. Install for Claude (default) SKILLS_BASENAME="$(basename "$INSTALL_SKILLS_DIR")" SKILLS_PARENT_BASENAME="$(basename "$(dirname "$INSTALL_SKILLS_DIR")")" @@ -1468,6 +1624,19 @@ if [ "$INSTALL_OPENCODE" -eq 1 ]; then echo " opencode skills: $OPENCODE_SKILLS" fi +# 6d. Install for Cursor +if [ "$INSTALL_CURSOR" -eq 1 ]; then + mkdir -p "$CURSOR_SKILLS" + create_cursor_runtime_root "$SOURCE_GSTACK_DIR" "$CURSOR_GSTACK" + # Link before sidecar. Sidecar mkdir -p creates .cursor/skills/gstack, which + # would make link_cursor_skill_dirs' "[ ! -d generated ]" gen fallback a no-op. + link_cursor_skill_dirs "$SOURCE_GSTACK_DIR" "$CURSOR_SKILLS" + create_cursor_sidecar "$SOURCE_GSTACK_DIR" + echo "gstack ready (cursor)." + echo " browse: $BROWSE_BIN" + echo " cursor skills: $CURSOR_SKILLS" +fi + # 7. Create .agents/ sidecar symlinks for the real Codex skill target. # The root Codex skill ends up pointing at $SOURCE_GSTACK_DIR/.agents/skills/gstack, # so the runtime assets must live there for both global and repo-local installs. diff --git a/test/gen-skill-docs.test.ts b/test/gen-skill-docs.test.ts index e75571e6f..ba94d3a69 100644 --- a/test/gen-skill-docs.test.ts +++ b/test/gen-skill-docs.test.ts @@ -2490,6 +2490,86 @@ describe('setup script validation', () => { expect(setupContent).toContain('OPENCODE_GSTACK="$OPENCODE_SKILLS/gstack"'); }); + // --host cursor full install slice (#1358, PR #2547 by @szsunyuan re-derived) + test('auto mode detects Cursor via binary or ~/.cursor directory', () => { + expect(setupContent).toContain('command -v cursor'); + expect(setupContent).toContain('[ -d "$HOME/.cursor" ] && INSTALL_CURSOR=1'); + }); + + test('setup supports --host cursor with install section and Cursor skill path vars', () => { + expect(setupContent).toContain('INSTALL_CURSOR='); + expect(setupContent).toContain('CURSOR_SKILLS="$HOME/.cursor/skills"'); + expect(setupContent).toContain('CURSOR_GSTACK="$CURSOR_SKILLS/gstack"'); + expect(setupContent).toContain('create_cursor_runtime_root'); + expect(setupContent).toContain('create_cursor_sidecar'); + expect(setupContent).toContain('link_cursor_skill_dirs'); + expect(setupContent).toContain('gstack ready (cursor).'); + }); + + test('create_cursor_runtime_root exposes only Cursor runtime assets', () => { + const fnStart = setupContent.indexOf('create_cursor_runtime_root()'); + const fnEnd = setupContent.indexOf('create_cursor_sidecar()', fnStart); + const fnBody = setupContent.slice(fnStart, fnEnd); + expect(fnBody).toContain('gstack/SKILL.md'); + expect(fnBody).toContain('browse/dist'); + expect(fnBody).toContain('browse/bin'); + expect(fnBody).toContain('gstack-upgrade/SKILL.md'); + expect(fnBody).toContain('checklist.md'); + expect(fnBody).toContain('TODOS-format.md'); + // bin scripts import ../lib — the two must travel together. + expect(fnBody).toContain('$cursor_gstack/lib'); + expect(fnBody).not.toContain('design-checklist.md'); + expect(fnBody).not.toContain('greptile-triage.md'); + expect(fnBody).not.toContain('review/specialists'); + expect(fnBody).not.toContain('qa/templates'); + expect(fnBody).not.toContain('_link_or_copy "$gstack_dir" "$cursor_gstack"'); + }); + + test('create_cursor_sidecar plants runtime assets without wiping generated SKILL.md', () => { + const fnStart = setupContent.indexOf('create_cursor_sidecar()'); + const fnEnd = setupContent.indexOf('link_cursor_skill_dirs()', fnStart); + const fnBody = setupContent.slice(fnStart, fnEnd); + expect(fnBody).toContain('.cursor/skills/gstack'); + expect(fnBody).toContain('bin'); + expect(fnBody).toContain('browse/dist'); + expect(fnBody).toContain('browse/bin'); + expect(fnBody).toContain('ETHOS.md'); + expect(fnBody).not.toContain('rm -rf'); + }); + + test('link_cursor_skill_dirs skips the gstack runtime root directory', () => { + const fnStart = setupContent.indexOf('link_cursor_skill_dirs()'); + const fnEnd = setupContent.indexOf('}', setupContent.indexOf('linked[@]', fnStart)); + const fnBody = setupContent.slice(fnStart, fnEnd); + expect(fnBody).toContain('[ "$skill_name" = "gstack" ] && continue'); + // #2444-aware guard: Windows bypass, else only replace symlink-or-missing. + expect(fnBody).toContain('[ "$IS_WINDOWS" -eq 1 ] || [ -L "$target" ] || [ ! -e "$target" ]'); + }); + + // #2142 deleted existing ~/.cursor/skills/ dirs with `rm -rf "$target"` + // before relinking. That can wipe unowned Cursor skills. Only replace a + // symlink or a missing path; never the whole skills directory. + test('link_cursor_skill_dirs does not delete unowned Cursor skill directories', () => { + const fnStart = setupContent.indexOf('link_cursor_skill_dirs()'); + const fnEnd = setupContent.indexOf('}', setupContent.indexOf('linked[@]', fnStart)); + const fnBody = setupContent.slice(fnStart, fnEnd); + expect(fnBody).not.toContain('rm -rf "$target"'); + expect(fnBody).not.toContain('rm -rf "$skills_dir"'); + expect(setupContent).not.toContain('rm -rf "$CURSOR_SKILLS"'); + }); + + test('Cursor install links generated skills before planting the sidecar', () => { + const cursorInstall = setupContent.slice( + setupContent.indexOf('# 6d. Install for Cursor'), + setupContent.indexOf('# 7. Create .agents/ sidecar'), + ); + const linkCall = cursorInstall.indexOf('link_cursor_skill_dirs "$SOURCE_GSTACK_DIR"'); + const sidecarCall = cursorInstall.indexOf('create_cursor_sidecar "$SOURCE_GSTACK_DIR"'); + expect(linkCall).toBeGreaterThan(-1); + expect(sidecarCall).toBeGreaterThan(-1); + expect(linkCall).toBeLessThan(sidecarCall); + }); + test('setup installs OpenCode skills into a nested gstack runtime root', () => { expect(setupContent).toContain('create_opencode_runtime_root'); expect(setupContent).toContain('.opencode/skills'); diff --git a/test/setup-windows-rerun-refresh.test.ts b/test/setup-windows-rerun-refresh.test.ts index 24703a463..57e87e458 100644 --- a/test/setup-windows-rerun-refresh.test.ts +++ b/test/setup-windows-rerun-refresh.test.ts @@ -34,16 +34,23 @@ function extractFn(name: string): string { const WINDOWS_BYPASS = '[ "$IS_WINDOWS" -eq 1 ] || [ -L '; describe('setup: Windows re-run refresh — static guard sites (#2444)', () => { - test('all five install guards carry the IS_WINDOWS bypass', () => { - const sites = SETUP_SRC.split(WINDOWS_BYPASS).length - 1; - expect(sites).toBe(5); + test('no install guard is missing the IS_WINDOWS bypass', () => { + // Every `[ -L ...] || [ ! -e ...]` refresh guard in setup must carry the + // bypass — a bare guard is a Windows re-run no-op waiting to happen. + const bareGuards = SETUP_SRC + .split('\n') + .filter((l) => /\[ -L "\$[A-Za-z_/${}.]+" \] \|\| \[ ! -e /.test(l) && !l.includes('IS_WINDOWS')); + expect(bareGuards).toEqual([]); + expect(SETUP_SRC.split(WINDOWS_BYPASS).length - 1).toBeGreaterThanOrEqual(5); }); test.each([ 'link_codex_skill_dirs', 'link_factory_skill_dirs', 'link_opencode_skill_dirs', + 'link_cursor_skill_dirs', 'create_agents_sidecar', + 'create_cursor_sidecar', ])('%s bypasses the symlink-or-missing guard on Windows', (fn) => { expect(extractFn(fn)).toContain(WINDOWS_BYPASS); }); diff --git a/test/uninstall.test.ts b/test/uninstall.test.ts index a7208e877..214e1323b 100644 --- a/test/uninstall.test.ts +++ b/test/uninstall.test.ts @@ -161,5 +161,38 @@ describe('gstack-uninstall', () => { // Non-gstack should survive expect(fs.existsSync(path.join(mockHome, '.claude', 'skills', 'other-tool'))).toBe(true); }); + + test('--force removes Cursor gstack skills and leaves other Cursor skills', () => { + fs.mkdirSync(path.join(mockHome, '.cursor', 'skills', 'gstack'), { recursive: true }); + fs.writeFileSync(path.join(mockHome, '.cursor', 'skills', 'gstack', 'SKILL.md'), 'test'); + fs.mkdirSync(path.join(mockHome, '.cursor', 'skills', 'gstack-review'), { recursive: true }); + fs.writeFileSync(path.join(mockHome, '.cursor', 'skills', 'gstack-review', 'SKILL.md'), 'test'); + fs.mkdirSync(path.join(mockHome, '.cursor', 'skills', 'frontend-design'), { recursive: true }); + fs.writeFileSync(path.join(mockHome, '.cursor', 'skills', 'frontend-design', 'SKILL.md'), 'keep'); + + fs.mkdirSync(path.join(mockGitRoot, '.cursor', 'skills', 'gstack-ship'), { recursive: true }); + fs.writeFileSync(path.join(mockGitRoot, '.cursor', 'skills', 'gstack-ship', 'SKILL.md'), 'test'); + fs.mkdirSync(path.join(mockGitRoot, '.cursor', 'rules'), { recursive: true }); + fs.writeFileSync(path.join(mockGitRoot, '.cursor', 'rules', 'keep.md'), 'keep'); + + const result = spawnSync('bash', [UNINSTALL, '--force'], { + stdio: 'pipe', + env: { + ...process.env, + HOME: mockHome, + GSTACK_DIR: path.join(mockHome, '.claude', 'skills', 'gstack'), + GSTACK_STATE_DIR: path.join(mockHome, '.gstack'), + }, + cwd: mockGitRoot, + }); + + expect(result.status).toBe(0); + + expect(fs.existsSync(path.join(mockHome, '.cursor', 'skills', 'gstack'))).toBe(false); + expect(fs.existsSync(path.join(mockHome, '.cursor', 'skills', 'gstack-review'))).toBe(false); + expect(fs.existsSync(path.join(mockHome, '.cursor', 'skills', 'frontend-design'))).toBe(true); + expect(fs.existsSync(path.join(mockGitRoot, '.cursor', 'skills', 'gstack-ship'))).toBe(false); + expect(fs.existsSync(path.join(mockGitRoot, '.cursor', 'rules', 'keep.md'))).toBe(true); + }); }); });