From f4b3b714e51f5ea4084cf38a8d575672bebca708 Mon Sep 17 00:00:00 2001 From: mant1st Date: Sat, 11 Jul 2026 10:00:00 +1000 Subject: [PATCH] fix(setup): preserve hidden skill visibility --- bin/gstack-relink | 18 ++++++++++++++++++ setup | 23 ++++++++++++++++++++++ test/gen-skill-docs.test.ts | 2 ++ test/relink.test.ts | 38 +++++++++++++++++++++++++++++++++++++ 4 files changed, 81 insertions(+) diff --git a/bin/gstack-relink b/bin/gstack-relink index dd2a681fa..ea2483da0 100755 --- a/bin/gstack-relink +++ b/bin/gstack-relink @@ -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" diff --git a/setup b/setup index 275236cd3..f823dab9b 100755 --- a/setup +++ b/setup @@ -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 diff --git a/test/gen-skill-docs.test.ts b/test/gen-skill-docs.test.ts index 69aebc29a..d034c80bf 100644 --- a/test/gen-skill-docs.test.ts +++ b/test/gen-skill-docs.test.ts @@ -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 diff --git a/test/relink.test.ts b/test/relink.test.ts index d83c4cd37..fc9f69813 100644 --- a/test/relink.test.ts +++ b/test/relink.test.ts @@ -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".