diff --git a/test/gen-skill-docs.test.ts b/test/gen-skill-docs.test.ts index 6419b6de..b0a7538f 100644 --- a/test/gen-skill-docs.test.ts +++ b/test/gen-skill-docs.test.ts @@ -1979,6 +1979,35 @@ describe('setup script validation', () => { expect(fnBody).toContain('ln -snf "$gstack_dir/$dir_name/SKILL.md" "$target/SKILL.md"'); }); + // REGRESSION: cleanup functions must handle both old symlinks AND new real-directory pattern + test('cleanup functions handle real directories with symlinked SKILL.md', () => { + // cleanup_old_claude_symlinks must detect and remove real dirs with SKILL.md symlinks + const cleanupOldStart = setupContent.indexOf('cleanup_old_claude_symlinks()'); + const cleanupOldEnd = setupContent.indexOf('}', setupContent.indexOf('cleaned up old', cleanupOldStart)); + const cleanupOldBody = setupContent.slice(cleanupOldStart, cleanupOldEnd); + expect(cleanupOldBody).toContain('-d "$old_target"'); + expect(cleanupOldBody).toContain('-L "$old_target/SKILL.md"'); + expect(cleanupOldBody).toContain('rm -rf "$old_target"'); + + // cleanup_prefixed_claude_symlinks must also handle the new pattern + const cleanupPrefixedStart = setupContent.indexOf('cleanup_prefixed_claude_symlinks()'); + const cleanupPrefixedEnd = setupContent.indexOf('}', setupContent.indexOf('cleaned up prefixed', cleanupPrefixedStart)); + const cleanupPrefixedBody = setupContent.slice(cleanupPrefixedStart, cleanupPrefixedEnd); + expect(cleanupPrefixedBody).toContain('-d "$prefixed_target"'); + expect(cleanupPrefixedBody).toContain('-L "$prefixed_target/SKILL.md"'); + expect(cleanupPrefixedBody).toContain('rm -rf "$prefixed_target"'); + }); + + // REGRESSION: link function must upgrade old directory symlinks + test('link_claude_skill_dirs removes old directory symlinks before creating real dirs', () => { + const fnStart = setupContent.indexOf('link_claude_skill_dirs()'); + const fnEnd = setupContent.indexOf('}', setupContent.indexOf('linked[@]}', fnStart)); + const fnBody = setupContent.slice(fnStart, fnEnd); + // Must check for and remove old symlinks before mkdir + expect(fnBody).toContain('if [ -L "$target" ]'); + expect(fnBody).toContain('rm -f "$target"'); + }); + test('setup supports --host auto|claude|codex|kiro', () => { expect(setupContent).toContain('--host'); expect(setupContent).toContain('claude|codex|kiro|factory|auto'); diff --git a/test/relink.test.ts b/test/relink.test.ts index b368d2bf..9e560b71 100644 --- a/test/relink.test.ts +++ b/test/relink.test.ts @@ -97,6 +97,71 @@ describe('gstack-relink (#578)', () => { expect(output).toContain('flat'); }); + // 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". + // The fix: create real directories with SKILL.md symlinks inside. + test('unprefixed skills are real directories with SKILL.md symlinks, not dir symlinks', () => { + setupMockInstall(['qa', 'ship', 'review', 'plan-ceo-review']); + run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`); + run(`${path.join(installDir, 'bin', 'gstack-relink')}`, { + GSTACK_INSTALL_DIR: installDir, + GSTACK_SKILLS_DIR: skillsDir, + }); + for (const skill of ['qa', 'ship', 'review', 'plan-ceo-review']) { + const skillPath = path.join(skillsDir, skill); + const skillMdPath = path.join(skillPath, 'SKILL.md'); + // Must be a real directory, NOT a symlink + expect(fs.lstatSync(skillPath).isDirectory()).toBe(true); + expect(fs.lstatSync(skillPath).isSymbolicLink()).toBe(false); + // Must contain a SKILL.md that IS a symlink + expect(fs.existsSync(skillMdPath)).toBe(true); + expect(fs.lstatSync(skillMdPath).isSymbolicLink()).toBe(true); + // The SKILL.md symlink must point to the source skill's SKILL.md + const target = fs.readlinkSync(skillMdPath); + expect(target).toContain(skill); + expect(target).toEndWith('/SKILL.md'); + } + }); + + // Same invariant for prefixed mode + test('prefixed skills are real directories with SKILL.md symlinks, not dir symlinks', () => { + setupMockInstall(['qa', 'ship']); + run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`); + run(`${path.join(installDir, 'bin', 'gstack-relink')}`, { + GSTACK_INSTALL_DIR: installDir, + GSTACK_SKILLS_DIR: skillsDir, + }); + for (const skill of ['gstack-qa', 'gstack-ship']) { + const skillPath = path.join(skillsDir, skill); + const skillMdPath = path.join(skillPath, 'SKILL.md'); + expect(fs.lstatSync(skillPath).isDirectory()).toBe(true); + expect(fs.lstatSync(skillPath).isSymbolicLink()).toBe(false); + expect(fs.lstatSync(skillMdPath).isSymbolicLink()).toBe(true); + } + }); + + // Upgrade: old directory symlinks get replaced with real directories + test('upgrades old directory symlinks to real directories', () => { + setupMockInstall(['qa', 'ship']); + // Simulate old behavior: create directory symlinks (the old pattern) + fs.symlinkSync(path.join(installDir, 'qa'), path.join(skillsDir, 'qa')); + fs.symlinkSync(path.join(installDir, 'ship'), path.join(skillsDir, 'ship')); + // Verify they start as symlinks + expect(fs.lstatSync(path.join(skillsDir, 'qa')).isSymbolicLink()).toBe(true); + + run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`); + run(`${path.join(installDir, 'bin', 'gstack-relink')}`, { + GSTACK_INSTALL_DIR: installDir, + GSTACK_SKILLS_DIR: skillsDir, + }); + + // After relink: must be real directories, not symlinks + expect(fs.lstatSync(path.join(skillsDir, 'qa')).isSymbolicLink()).toBe(false); + expect(fs.lstatSync(path.join(skillsDir, 'qa')).isDirectory()).toBe(true); + expect(fs.lstatSync(path.join(skillsDir, 'qa', 'SKILL.md')).isSymbolicLink()).toBe(true); + }); + // Test 13: cleans stale symlinks from opposite mode test('cleans up stale symlinks from opposite mode', () => { setupMockInstall(['qa', 'ship']);