diff --git a/bin/gstack-uninstall b/bin/gstack-uninstall index 181b3813a..9f83386d7 100755 --- a/bin/gstack-uninstall +++ b/bin/gstack-uninstall @@ -140,6 +140,9 @@ CLAUDE_SKILLS="$HOME/.claude/skills" # prefixed variants, and the alias dirs. Built BEFORE the install root is # removed. A real directory in ~/.claude/skills is only deletable when its # name is in this inventory AND its SKILL.md carries the generated banner. +# The seed names below are the alias dirs setup's _install_alias_skill_md +# creates (setup: link_claude_root_skill_alias + the connect-chrome call +# sites) — keep in sync with setup if an alias is added or renamed there. _INVENTORY=" _gstack-command connect-chrome gstack-connect-chrome " if [ -d "$GSTACK_DIR" ]; then for _SRC in "$GSTACK_DIR"/*/; do @@ -172,13 +175,21 @@ if [ -d "$CLAUDE_SKILLS/gstack" ] || [ -L "$CLAUDE_SKILLS/gstack" ]; then esac elif [ -d "$_ENTRY" ] && { [ -f "$_ENTRY/SKILL.md" ] || [ -L "$_ENTRY/SKILL.md" ]; }; then if [ -L "$_ENTRY/SKILL.md" ]; then - # Shape 2: provenance readable from the symlink target itself - # (mirrors setup's cleanup_old_claude_symlinks semantics). + # Shape 2: provenance readable from the symlink target itself. + # Gate 1: the name must be in gstack's skill inventory (parity with + # shape 3). Gate 2: the target must contain "gstack" as an ANCHORED + # path segment (gstack/*|*/gstack/*, same pattern as shape 1) — a + # bare *gstack* substring match would wipe a user's own skill whose + # SKILL.md merely lives under e.g. ~/tools/gstack-fork/. _TARGET="$(readlink "$_ENTRY/SKILL.md" 2>/dev/null || true)" - case "$_TARGET" in - *gstack*) rm -rf "$_ENTRY"; REMOVED+=("claude/$_NAME") ;; - *) _SKIPPED_DIRS+=("$_ENTRY") ;; - esac + if _in_skill_inventory "$_NAME"; then + case "$_TARGET" in + gstack/*|*/gstack/*) rm -rf "$_ENTRY"; REMOVED+=("claude/$_NAME") ;; + *) _SKIPPED_DIRS+=("$_ENTRY") ;; + esac + else + _SKIPPED_DIRS+=("$_ENTRY") + fi elif _in_skill_inventory "$_NAME" && grep -q '\n# x\n'; fs.mkdirSync(path.join(mockHome, '.cursor', 'skills', 'gstack'), { recursive: true }); - fs.writeFileSync(path.join(mockHome, '.cursor', 'skills', 'gstack', 'SKILL.md'), 'test'); + fs.writeFileSync(path.join(mockHome, '.cursor', 'skills', 'gstack', 'SKILL.md'), banner); fs.mkdirSync(path.join(mockHome, '.cursor', 'skills', 'gstack-review'), { recursive: true }); - fs.writeFileSync(path.join(mockHome, '.cursor', 'skills', 'gstack-review', 'SKILL.md'), 'test'); + fs.writeFileSync(path.join(mockHome, '.cursor', 'skills', 'gstack-review', 'SKILL.md'), banner); 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.writeFileSync(path.join(mockGitRoot, '.cursor', 'skills', 'gstack-ship', 'SKILL.md'), banner); fs.mkdirSync(path.join(mockGitRoot, '.cursor', 'rules'), { recursive: true }); fs.writeFileSync(path.join(mockGitRoot, '.cursor', 'rules', 'keep.md'), 'keep'); @@ -194,5 +197,37 @@ describe('gstack-uninstall', () => { expect(fs.existsSync(path.join(mockGitRoot, '.cursor', 'skills', 'gstack-ship'))).toBe(false); expect(fs.existsSync(path.join(mockGitRoot, '.cursor', 'rules', 'keep.md'))).toBe(true); }); + + test("a user's own gstack-prefixed Cursor dir (no banner) survives and is listed", () => { + // S5: the bare gstack* glob must not sweep a dir that merely starts + // with "gstack" — provenance comes from the generated banner, and a + // hand-written SKILL.md never carries it. + const foreign = path.join(mockHome, '.cursor', 'skills', 'gstack-fork-notes'); + fs.mkdirSync(foreign, { recursive: true }); + fs.writeFileSync(path.join(foreign, 'SKILL.md'), '# my own notes\n'); + + const foreignLocal = path.join(mockGitRoot, '.cursor', 'skills', 'gstack-my-rules'); + fs.mkdirSync(foreignLocal, { recursive: true }); + fs.writeFileSync(path.join(foreignLocal, 'SKILL.md'), '# hand-written\n'); + + 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(foreign)).toBe(true); + expect(fs.existsSync(foreignLocal)).toBe(true); + const stderr = result.stderr.toString(); + expect(stderr).toContain('left in place'); + expect(stderr).toContain('gstack-fork-notes'); + expect(stderr).toContain('gstack-my-rules'); + }); }); });