diff --git a/scripts/gen-skill-docs.ts b/scripts/gen-skill-docs.ts index 75deb5f4..94f39101 100644 --- a/scripts/gen-skill-docs.ts +++ b/scripts/gen-skill-docs.ts @@ -83,11 +83,15 @@ const OPENAI_LITMUS_CHECKS = [ // ─── External Host Helpers ─────────────────────────────────── // Re-export local copy for use in this file (matches codex-helpers.ts) -function externalSkillName(skillDir: string): string { +// Accepts optional frontmatter name to support directory/invocation name divergence +function externalSkillName(skillDir: string, frontmatterName?: string): string { + // Root skill (skillDir === '' or '.') always maps to 'gstack' regardless of frontmatter if (skillDir === '.' || skillDir === '') return 'gstack'; + // Use frontmatter name when it differs from directory name (e.g., run-tests/ with name: test) + const baseName = frontmatterName && frontmatterName !== skillDir ? frontmatterName : skillDir; // Don't double-prefix: gstack-upgrade → gstack-upgrade (not gstack-gstack-upgrade) - if (skillDir.startsWith('gstack-')) return skillDir; - return `gstack-${skillDir}`; + if (baseName.startsWith('gstack-')) return baseName; + return `gstack-${baseName}`; } function extractNameAndDescription(content: string): { name: string; description: string } { @@ -255,11 +259,12 @@ function processExternalHost( skillDir: string, extractedDescription: string, ctx: TemplateContext, + frontmatterName?: string, ): { content: string; outputPath: string; outputDir: string; symlinkLoop: boolean } { const config = EXTERNAL_HOST_CONFIG[host]; if (!config) throw new Error(`No external host config for: ${host}`); - const name = externalSkillName(skillDir === '.' ? '' : skillDir); + const name = externalSkillName(skillDir === '.' ? '' : skillDir, frontmatterName); const outputDir = path.join(ROOT, config.hostSubdir, 'skills', name); fs.mkdirSync(outputDir, { recursive: true }); const outputPath = path.join(outputDir, 'SKILL.md'); @@ -324,10 +329,13 @@ function processTemplate(tmplPath: string, host: Host = 'claude'): { outputPath: // Determine skill directory relative to ROOT const skillDir = path.relative(ROOT, path.dirname(tmplPath)); - // Extract skill name from frontmatter for TemplateContext + // Extract skill name from frontmatter early — needed for both TemplateContext and external host output paths. + // When frontmatter name: differs from directory name (e.g., run-tests/ with name: test), + // the frontmatter name is used for external skill naming and setup script symlinks. const { name: extractedName, description: extractedDescription } = extractNameAndDescription(tmplContent); const skillName = extractedName || path.basename(path.dirname(tmplPath)); + // Extract benefits-from list from frontmatter (inline YAML: benefits-from: [a, b]) const benefitsMatch = tmplContent.match(/^benefits-from:\s*\[([^\]]*)\]/m); const benefitsFrom = benefitsMatch @@ -362,7 +370,7 @@ function processTemplate(tmplPath: string, host: Host = 'claude'): { outputPath: if (host === 'claude') { content = transformFrontmatter(content, host); } else { - const result = processExternalHost(content, tmplContent, host, skillDir, extractedDescription, ctx); + const result = processExternalHost(content, tmplContent, host, skillDir, extractedDescription, ctx, extractedName || undefined); content = result.content; outputPath = result.outputPath; symlinkLoop = result.symlinkLoop; diff --git a/setup b/setup index b9260713..c298bb87 100755 --- a/setup +++ b/setup @@ -266,9 +266,12 @@ link_claude_skill_dirs() { local linked=() for skill_dir in "$gstack_dir"/*/; do if [ -f "$skill_dir/SKILL.md" ]; then - skill_name="$(basename "$skill_dir")" + dir_name="$(basename "$skill_dir")" # Skip node_modules - [ "$skill_name" = "node_modules" ] && continue + [ "$dir_name" = "node_modules" ] && continue + # Use frontmatter name: if present (e.g., run-tests/ with name: test → symlink as "test") + skill_name=$(grep -m1 '^name:' "$skill_dir/SKILL.md" 2>/dev/null | sed 's/^name:[[:space:]]*//' | tr -d '[:space:]') + [ -z "$skill_name" ] && skill_name="$dir_name" # Apply gstack- prefix unless --no-prefix or already prefixed if [ "$SKILL_PREFIX" -eq 1 ]; then case "$skill_name" in @@ -281,7 +284,7 @@ link_claude_skill_dirs() { 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/$skill_name" "$target" + ln -snf "gstack/$dir_name" "$target" linked+=("$link_name") fi fi diff --git a/test/gen-skill-docs.test.ts b/test/gen-skill-docs.test.ts index 27672ede..85edacfb 100644 --- a/test/gen-skill-docs.test.ts +++ b/test/gen-skill-docs.test.ts @@ -1793,11 +1793,12 @@ describe('setup script validation', () => { }); test('link_claude_skill_dirs creates relative symlinks', () => { - // Claude links should be relative: ln -snf "gstack/skill_name" + // 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 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/$skill_name"'); + expect(fnBody).toContain('ln -snf "gstack/$dir_name"'); }); test('setup supports --host auto|claude|codex|kiro', () => {