Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions bin/gstack-relink
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,17 @@ _cleanup_skill_entry() {
fi
}

_link_root_skill_alias() {
local target="$SKILLS_DIR/_gstack-command"

[ -f "$INSTALL_DIR/SKILL.md" ] || return 0
[ -L "$target" ] && rm -f "$target"
mkdir -p "$target"
ln -snf "$INSTALL_DIR/SKILL.md" "$target/SKILL.md"
}

_link_root_skill_alias

# Discover skills (directories with SKILL.md, excluding meta dirs)
SKILL_COUNT=0
for skill_dir in "$INSTALL_DIR"/*/; do
Expand Down
22 changes: 22 additions & 0 deletions setup
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,26 @@ link_claude_skill_dirs() {
fi
}

# Claude Code skips the repo-shaped ~/.claude/skills/gstack directory when
# building the user-facing slash-command list. Keep the repo path for runtime
# assets, and add a separate thin wrapper whose frontmatter name remains
# `gstack` so `/gstack` can autocomplete.
link_claude_root_skill_alias() {
local gstack_dir="$1"
local skills_dir="$2"
local target="$skills_dir/_gstack-command"

[ -f "$gstack_dir/SKILL.md" ] || return 0
if [ -L "$target" ]; then
rm -f "$target"
fi
mkdir -p "$target"
if [ -L "$target/SKILL.md" ]; then rm "$target/SKILL.md"; fi
_link_or_copy "$gstack_dir/SKILL.md" "$target/SKILL.md"
echo " linked root skill alias: gstack"
_print_windows_copy_note_once
}

# ─── Helper: remove old unprefixed Claude skill entries ───────────────────────
# Migration: when switching from flat names to gstack- prefixed names,
# clean up stale symlinks or directories that point into the gstack directory.
Expand Down Expand Up @@ -838,6 +858,7 @@ if [ "$INSTALL_CLAUDE" -eq 1 ]; then
# reads the correct (patched) name: values for symlink naming
"$SOURCE_GSTACK_DIR/bin/gstack-patch-names" "$SOURCE_GSTACK_DIR" "$SKILL_PREFIX"
link_claude_skill_dirs "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"
link_claude_root_skill_alias "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"
# Self-healing: re-run gstack-relink to ensure name: fields and directory
# names are consistent with the config. This catches cases where an interrupted
# setup, stale git state, or gen:skill-docs left name: fields out of sync.
Expand Down Expand Up @@ -909,6 +930,7 @@ if [ "$INSTALL_CLAUDE" -eq 1 ]; then
fi
"$SOURCE_GSTACK_DIR/bin/gstack-patch-names" "$SOURCE_GSTACK_DIR" "$SKILL_PREFIX"
link_claude_skill_dirs "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"
link_claude_root_skill_alias "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"
GSTACK_RELINK="$SOURCE_GSTACK_DIR/bin/gstack-relink"
if [ -x "$GSTACK_RELINK" ]; then
GSTACK_SKILLS_DIR="$INSTALL_SKILLS_DIR" GSTACK_INSTALL_DIR="$SOURCE_GSTACK_DIR" "$GSTACK_RELINK" >/dev/null 2>&1 || true
Expand Down
14 changes: 14 additions & 0 deletions test/gen-skill-docs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2273,6 +2273,20 @@ describe('setup script validation', () => {
expect(fnBody).toContain('rm -f "$target"');
});

test('setup links root gstack skill through a thin Claude wrapper alias', () => {
const fnStart = setupContent.indexOf('link_claude_root_skill_alias()');
const fnEnd = setupContent.indexOf('# ─── Helper: remove old unprefixed Claude skill entries', fnStart);
const fnBody = setupContent.slice(fnStart, fnEnd);
expect(fnBody).toContain('_gstack-command');
expect(fnBody).toContain('_link_or_copy "$gstack_dir/SKILL.md" "$target/SKILL.md"');

const claudeSection = setupContent.slice(
setupContent.indexOf('# 4. Install for Claude'),
setupContent.indexOf('# 5. Install for Codex')
);
expect(claudeSection).toContain('link_claude_root_skill_alias "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"');
});

test('setup supports --host auto|claude|codex|kiro|opencode', () => {
expect(setupContent).toContain('--host');
expect(setupContent).toContain('claude|codex|kiro|factory|opencode|auto');
Expand Down
31 changes: 31 additions & 0 deletions test/relink.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,37 @@ describe('gstack-relink (#578)', () => {
expect(fs.lstatSync(path.join(skillsDir, 'qa', 'SKILL.md')).isSymbolicLink()).toBe(true);
});

test('creates a thin root alias wrapper for the /gstack slash command', () => {
setupMockInstall(['qa']);
fs.writeFileSync(
path.join(installDir, 'SKILL.md'),
'---\nname: gstack\ndescription: root\n---\n# gstack',
);

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,
});

const aliasDir = path.join(skillsDir, '_gstack-command');
const aliasSkill = path.join(aliasDir, 'SKILL.md');
expect(fs.lstatSync(aliasDir).isDirectory()).toBe(true);
expect(fs.lstatSync(aliasDir).isSymbolicLink()).toBe(false);
expect(fs.lstatSync(aliasSkill).isSymbolicLink()).toBe(true);
expect(fs.readlinkSync(aliasSkill)).toBe(path.join(installDir, 'SKILL.md'));
expect(fs.readFileSync(aliasSkill, 'utf-8')).toContain('name: gstack');

run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`, {
GSTACK_INSTALL_DIR: installDir,
GSTACK_SKILLS_DIR: skillsDir,
});
expect(fs.existsSync(aliasSkill)).toBe(true);
});

// FIRST INSTALL: --no-prefix must create ONLY flat names, zero gstack-* pollution
test('first install --no-prefix: only flat names exist, zero gstack-* entries', () => {
setupMockInstall(['qa', 'ship', 'review', 'plan-ceo-review', 'gstack-upgrade']);
Expand Down
Loading