fix: top-level skill dirs so Claude discovers unprefixed names

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 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-04-01 15:56:51 -07:00
parent 6169273d16
commit 4aac13baae
4 changed files with 66 additions and 26 deletions
+20 -6
View File
@@ -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
+1 -1
View File
@@ -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",
+40 -15
View File
@@ -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
}
+5 -4
View File
@@ -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', () => {