From 4aac13baae24429d2e94c77c988ed41925e7ffe0 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Wed, 1 Apr 2026 15:56:51 -0700 Subject: [PATCH] fix: top-level skill dirs so Claude discovers unprefixed names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace directory symlinks (gstack/qa → qa) with real directories containing a SKILL.md symlink. Claude Code auto-prefixes skills nested under a parent dir symlink, so /plan-ceo-review became "Unknown skill" even with skill_prefix=false. Real dirs fix this. Also syncs package.json version to match VERSION file and updates test assertions to match the new mkdir + ln approach. Co-Authored-By: Claude Opus 4.6 --- bin/gstack-relink | 26 ++++++++++++++---- package.json | 2 +- setup | 55 +++++++++++++++++++++++++++---------- test/gen-skill-docs.test.ts | 9 +++--- 4 files changed, 66 insertions(+), 26 deletions(-) diff --git a/bin/gstack-relink b/bin/gstack-relink index 4647f6df..31e6b82f 100755 --- a/bin/gstack-relink +++ b/bin/gstack-relink @@ -36,6 +36,16 @@ SKILLS_DIR="${GSTACK_SKILLS_DIR:-$(dirname "$INSTALL_DIR")}" # Read prefix setting PREFIX=$("$GSTACK_CONFIG" get skill_prefix 2>/dev/null || echo "false") +# Helper: remove old skill entry (symlink or real directory with symlinked SKILL.md) +_cleanup_skill_entry() { + local entry="$1" + if [ -L "$entry" ]; then + rm -f "$entry" + elif [ -d "$entry" ] && [ -L "$entry/SKILL.md" ]; then + rm -rf "$entry" + fi +} + # Discover skills (directories with SKILL.md, excluding meta dirs) SKILL_COUNT=0 for skill_dir in "$INSTALL_DIR"/*/; do @@ -51,18 +61,22 @@ for skill_dir in "$INSTALL_DIR"/*/; do gstack-*) link_name="$skill" ;; *) link_name="gstack-$skill" ;; esac - ln -sfn "$INSTALL_DIR/$skill" "$SKILLS_DIR/$link_name" - # Remove old flat symlink if it exists (and isn't the same as the new link) - [ "$link_name" != "$skill" ] && [ -L "$SKILLS_DIR/$skill" ] && rm -f "$SKILLS_DIR/$skill" + # Remove old flat entry if it exists (and isn't the same as the new link) + [ "$link_name" != "$skill" ] && _cleanup_skill_entry "$SKILLS_DIR/$skill" else - # Create flat symlink, remove gstack-* if exists - ln -sfn "$INSTALL_DIR/$skill" "$SKILLS_DIR/$skill" + link_name="$skill" # Don't remove gstack-* dirs that are their real name (e.g., gstack-upgrade) case "$skill" in gstack-*) ;; # Already the real name, no old prefixed link to clean - *) [ -L "$SKILLS_DIR/gstack-$skill" ] && rm -f "$SKILLS_DIR/gstack-$skill" ;; + *) _cleanup_skill_entry "$SKILLS_DIR/gstack-$skill" ;; esac fi + target="$SKILLS_DIR/$link_name" + # Upgrade old directory symlinks to real directories + [ -L "$target" ] && rm -f "$target" + # Create real directory with symlinked SKILL.md (absolute path) + mkdir -p "$target" + ln -snf "$INSTALL_DIR/$skill/SKILL.md" "$target/SKILL.md" SKILL_COUNT=$((SKILL_COUNT + 1)) done diff --git a/package.json b/package.json index 50ec0914..af7d165a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "0.15.0.0", + "version": "0.15.1.0", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/setup b/setup index 91f0c9e7..2fdd2892 100755 --- a/setup +++ b/setup @@ -263,9 +263,11 @@ fi mkdir -p "$HOME/.gstack/projects" # ─── Helper: link Claude skill subdirectories into a skills parent directory ── -# When SKILL_PREFIX=1 (default), symlinks are prefixed with "gstack-" to avoid -# namespace pollution (e.g., gstack-review instead of review). -# Use --no-prefix to restore the old flat names. +# Creates real directories (not symlinks) at the top level with a SKILL.md symlink +# inside. This ensures Claude discovers them as top-level skills, not nested under +# gstack/ (which would auto-prefix them as gstack-*). +# When SKILL_PREFIX=1, directories are prefixed with "gstack-". +# Use --no-prefix to restore flat names. link_claude_skill_dirs() { local gstack_dir="$1" local skills_dir="$2" @@ -288,9 +290,14 @@ link_claude_skill_dirs() { link_name="$skill_name" fi target="$skills_dir/$link_name" - # Create or update symlink; skip if a real file/directory exists - if [ -L "$target" ] || [ ! -e "$target" ]; then - ln -snf "gstack/$dir_name" "$target" + # Upgrade old directory symlinks to real directories + if [ -L "$target" ]; then + rm -f "$target" + fi + # Create real directory with symlinked SKILL.md (absolute path) + if [ ! -e "$target" ] || [ -d "$target" ]; then + mkdir -p "$target" + ln -snf "$gstack_dir/$dir_name/SKILL.md" "$target/SKILL.md" linked+=("$link_name") fi fi @@ -300,9 +307,9 @@ link_claude_skill_dirs() { fi } -# ─── Helper: remove old unprefixed Claude skill symlinks ────────────────────── +# ─── Helper: remove old unprefixed Claude skill entries ─────────────────────── # Migration: when switching from flat names to gstack- prefixed names, -# clean up stale symlinks that point into the gstack directory. +# clean up stale symlinks or directories that point into the gstack directory. cleanup_old_claude_symlinks() { local gstack_dir="$1" local skills_dir="$2" @@ -314,7 +321,7 @@ cleanup_old_claude_symlinks() { # Skip already-prefixed dirs (gstack-upgrade) — no old symlink to clean case "$skill_name" in gstack-*) continue ;; esac old_target="$skills_dir/$skill_name" - # Only remove if it's a symlink pointing into gstack/ + # Remove directory symlinks pointing into gstack/ if [ -L "$old_target" ]; then link_dest="$(readlink "$old_target" 2>/dev/null || true)" case "$link_dest" in @@ -323,17 +330,26 @@ cleanup_old_claude_symlinks() { removed+=("$skill_name") ;; esac + # Remove real directories with symlinked SKILL.md pointing into gstack/ + elif [ -d "$old_target" ] && [ -L "$old_target/SKILL.md" ]; then + link_dest="$(readlink "$old_target/SKILL.md" 2>/dev/null || true)" + case "$link_dest" in + *gstack*) + rm -rf "$old_target" + removed+=("$skill_name") + ;; + esac fi fi done if [ ${#removed[@]} -gt 0 ]; then - echo " cleaned up old symlinks: ${removed[*]}" + echo " cleaned up old entries: ${removed[*]}" fi } -# ─── Helper: remove old prefixed Claude skill symlinks ──────────────────────── +# ─── Helper: remove old prefixed Claude skill entries ───────────────────────── # Reverse migration: when switching from gstack- prefixed names to flat names, -# clean up stale gstack-* symlinks that point into the gstack directory. +# clean up stale gstack-* symlinks or directories that point into the gstack directory. cleanup_prefixed_claude_symlinks() { local gstack_dir="$1" local skills_dir="$2" @@ -342,11 +358,11 @@ cleanup_prefixed_claude_symlinks() { if [ -f "$skill_dir/SKILL.md" ]; then skill_name="$(basename "$skill_dir")" [ "$skill_name" = "node_modules" ] && continue - # Only clean up prefixed symlinks for dirs that AREN'T already prefixed + # Only clean up prefixed entries for dirs that AREN'T already prefixed # (e.g., remove gstack-qa but NOT gstack-upgrade which is the real dir name) case "$skill_name" in gstack-*) continue ;; esac prefixed_target="$skills_dir/gstack-$skill_name" - # Only remove if it's a symlink pointing into gstack/ + # Remove directory symlinks pointing into gstack/ if [ -L "$prefixed_target" ]; then link_dest="$(readlink "$prefixed_target" 2>/dev/null || true)" case "$link_dest" in @@ -355,11 +371,20 @@ cleanup_prefixed_claude_symlinks() { removed+=("gstack-$skill_name") ;; esac + # Remove real directories with symlinked SKILL.md pointing into gstack/ + elif [ -d "$prefixed_target" ] && [ -L "$prefixed_target/SKILL.md" ]; then + link_dest="$(readlink "$prefixed_target/SKILL.md" 2>/dev/null || true)" + case "$link_dest" in + *gstack*) + rm -rf "$prefixed_target" + removed+=("gstack-$skill_name") + ;; + esac fi fi done if [ ${#removed[@]} -gt 0 ]; then - echo " cleaned up prefixed symlinks: ${removed[*]}" + echo " cleaned up prefixed entries: ${removed[*]}" fi } diff --git a/test/gen-skill-docs.test.ts b/test/gen-skill-docs.test.ts index 4a25195d..6419b6de 100644 --- a/test/gen-skill-docs.test.ts +++ b/test/gen-skill-docs.test.ts @@ -1969,13 +1969,14 @@ describe('setup script validation', () => { expect(fnBody).toContain('gstack*'); }); - test('link_claude_skill_dirs creates relative symlinks', () => { - // Claude links should be relative: ln -snf "gstack/$dir_name" - // Uses dir_name (not skill_name) because symlink target must point to the physical directory + test('link_claude_skill_dirs creates real directories with absolute SKILL.md symlinks', () => { + // Claude links should be real directories with absolute SKILL.md symlinks + // to ensure Claude Code discovers them as top-level skills (not nested under gstack/) const fnStart = setupContent.indexOf('link_claude_skill_dirs()'); const fnEnd = setupContent.indexOf('}', setupContent.indexOf('linked[@]}', fnStart)); const fnBody = setupContent.slice(fnStart, fnEnd); - expect(fnBody).toContain('ln -snf "gstack/$dir_name"'); + expect(fnBody).toContain('mkdir -p "$target"'); + expect(fnBody).toContain('ln -snf "$gstack_dir/$dir_name/SKILL.md" "$target/SKILL.md"'); }); test('setup supports --host auto|claude|codex|kiro', () => {