fix(setup): preserve hidden skill visibility

This commit is contained in:
mant1st 2026-07-11 10:00:00 +10:00
parent 38ff0793c3
commit f4b3b714e5
4 changed files with 81 additions and 0 deletions

View File

@ -35,6 +35,7 @@ SKILLS_DIR="${GSTACK_SKILLS_DIR:-$(dirname "$INSTALL_DIR")}"
# Read prefix setting
PREFIX=$("$GSTACK_CONFIG" get skill_prefix 2>/dev/null || echo "false")
GSTACK_HIDDEN_SKILLS_FILE="${GSTACK_HIDDEN_SKILLS_FILE:-$HOME/.gstack/hidden-skills}"
# Helper: remove old skill entry (symlink or real directory with symlinked SKILL.md)
_cleanup_skill_entry() {
@ -46,6 +47,17 @@ _cleanup_skill_entry() {
fi
}
_skill_hidden() {
local source_name="$1" projected_name="$2"
[ -f "$GSTACK_HIDDEN_SKILLS_FILE" ] || return 1
grep -Fqx "$source_name" "$GSTACK_HIDDEN_SKILLS_FILE" 2>/dev/null && return 0
case "$source_name" in
gstack-*) ;;
*) grep -Fqx "gstack-$source_name" "$GSTACK_HIDDEN_SKILLS_FILE" 2>/dev/null && return 0 ;;
esac
grep -Fqx "$projected_name" "$GSTACK_HIDDEN_SKILLS_FILE" 2>/dev/null
}
_link_root_skill_alias() {
local target="$SKILLS_DIR/_gstack-command"
@ -82,6 +94,12 @@ for skill_dir in "$INSTALL_DIR"/*/; do
*) _cleanup_skill_entry "$SKILLS_DIR/gstack-$skill" ;;
esac
fi
if _skill_hidden "$skill" "$link_name"; then
_cleanup_skill_entry "$SKILLS_DIR/$skill"
_cleanup_skill_entry "$SKILLS_DIR/gstack-$skill"
_cleanup_skill_entry "$SKILLS_DIR/$link_name"
continue
fi
target="$SKILLS_DIR/$link_name"
# Upgrade old directory symlinks to real directories
[ -L "$target" ] && rm -f "$target"

23
setup
View File

@ -531,6 +531,20 @@ fi
# 3. Ensure ~/.gstack global state directory exists
mkdir -p "$HOME/.gstack/projects"
# Optional user-level visibility denylist. One exact skill name per line;
# either source form (context-save) or projected form (gstack-context-save).
GSTACK_HIDDEN_SKILLS_FILE="${GSTACK_HIDDEN_SKILLS_FILE:-$HOME/.gstack/hidden-skills}"
_gstack_skill_hidden() {
local source_name="$1" projected_name="${2:-$1}"
[ -f "$GSTACK_HIDDEN_SKILLS_FILE" ] || return 1
grep -Fqx "$source_name" "$GSTACK_HIDDEN_SKILLS_FILE" 2>/dev/null && return 0
case "$source_name" in
gstack-*) ;;
*) grep -Fqx "gstack-$source_name" "$GSTACK_HIDDEN_SKILLS_FILE" 2>/dev/null && return 0 ;;
esac
grep -Fqx "$projected_name" "$GSTACK_HIDDEN_SKILLS_FILE" 2>/dev/null
}
# ─── Helper: link Claude skill subdirectories into a skills parent directory ──
# 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
@ -559,6 +573,14 @@ link_claude_skill_dirs() {
link_name="$skill_name"
fi
target="$skills_dir/$link_name"
if _gstack_skill_hidden "$dir_name" "$link_name"; then
if [ -L "$target" ]; then
rm -f "$target"
elif [ -d "$target" ] && [ -L "$target/SKILL.md" ]; then
rm -rf "$target"
fi
continue
fi
# Upgrade old directory symlinks to real directories
if [ -L "$target" ]; then
rm -f "$target"
@ -726,6 +748,7 @@ link_codex_skill_dirs() {
# browse/), not a skill. Linking it would overwrite the root gstack
# symlink that Step 5 already pointed at the repo root.
[ "$skill_name" = "gstack" ] && continue
_gstack_skill_hidden "$skill_name" "$skill_name" && continue
target="$skills_dir/$skill_name"
# Create or update symlink
if [ -L "$target" ] || [ ! -e "$target" ]; then

View File

@ -2318,6 +2318,7 @@ describe('setup script validation', () => {
const fnBody = setupContent.slice(fnStart, fnEnd);
expect(fnBody).toContain('.agents/skills');
expect(fnBody).toContain('gstack*');
expect(fnBody).toContain('_gstack_skill_hidden');
});
test('link_claude_skill_dirs creates real directories with absolute SKILL.md symlinks', () => {
@ -2329,6 +2330,7 @@ describe('setup script validation', () => {
expect(fnBody).toContain('mkdir -p "$target"');
// v1.36.0.0: routes through _link_or_copy helper for Windows fallback (cp on MSYS2/Git Bash).
expect(fnBody).toContain('_link_or_copy "$gstack_dir/$dir_name/SKILL.md" "$target/SKILL.md"');
expect(fnBody).toContain('_gstack_skill_hidden');
});
// REGRESSION: cleanup functions must handle both old symlinks AND new real-directory pattern

View File

@ -113,6 +113,44 @@ describe('gstack-relink (#578)', () => {
expect(output).toContain('flat');
});
test('does not relink a skill listed in the visibility denylist', () => {
setupMockInstall(['context-save', 'qa']);
const hiddenFile = path.join(tmpDir, 'hidden-skills');
fs.writeFileSync(hiddenFile, 'gstack-context-save\n');
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`, {
GSTACK_INSTALL_DIR: installDir,
GSTACK_SKILLS_DIR: skillsDir,
});
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
GSTACK_INSTALL_DIR: installDir,
GSTACK_SKILLS_DIR: skillsDir,
GSTACK_HIDDEN_SKILLS_FILE: hiddenFile,
});
expect(fs.existsSync(path.join(skillsDir, 'gstack-context-save'))).toBe(false);
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(true);
});
test('visibility denylist also applies in no-prefix mode', () => {
setupMockInstall(['context-save', 'qa']);
const hiddenFile = path.join(tmpDir, 'hidden-skills');
fs.writeFileSync(hiddenFile, 'gstack-context-save\n');
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`, {
GSTACK_INSTALL_DIR: installDir,
GSTACK_SKILLS_DIR: skillsDir,
});
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
GSTACK_INSTALL_DIR: installDir,
GSTACK_SKILLS_DIR: skillsDir,
GSTACK_HIDDEN_SKILLS_FILE: hiddenFile,
});
expect(fs.existsSync(path.join(skillsDir, 'context-save'))).toBe(false);
expect(fs.existsSync(path.join(skillsDir, 'qa'))).toBe(true);
});
// REGRESSION: unprefixed skills must be real directories, not symlinks (#761)
// Claude Code auto-prefixes skills nested under a parent dir symlink.
// e.g., `qa -> gstack/qa` gets discovered as "gstack-qa", not "qa".