diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts index 79cd6eba5..e8df28737 100644 --- a/gitnexus/src/cli/setup.ts +++ b/gitnexus/src/cli/setup.ts @@ -10,6 +10,7 @@ import fs from 'fs/promises'; import path from 'path'; import os from 'os'; import { fileURLToPath } from 'url'; +import { glob } from 'glob'; import { getGlobalDir } from '../storage/repo-manager.js'; const __filename = fileURLToPath(import.meta.url); @@ -240,8 +241,6 @@ async function setupOpenCode(result: SetupResult): Promise { // ─── Skill Installation ─────────────────────────────────────────── -const SKILL_NAMES = ['gitnexus-exploring', 'gitnexus-debugging', 'gitnexus-impact-analysis', 'gitnexus-refactoring', 'gitnexus-guide', 'gitnexus-cli']; - /** * Install GitNexus skills to a target directory. * Each skill is installed as {targetDir}/gitnexus-{skillName}/SKILL.md @@ -255,25 +254,38 @@ async function installSkillsTo(targetDir: string): Promise { const installed: string[] = []; const skillsRoot = path.join(__dirname, '..', '..', 'skills'); - for (const skillName of SKILL_NAMES) { + let flatFiles: string[] = []; + let dirSkillFiles: string[] = []; + try { + [flatFiles, dirSkillFiles] = await Promise.all([ + glob('*.md', { cwd: skillsRoot }), + glob('*/SKILL.md', { cwd: skillsRoot }), + ]); + } catch { + return []; + } + + const skillSources = new Map(); + + for (const relPath of dirSkillFiles) { + skillSources.set(path.dirname(relPath), { isDirectory: true }); + } + for (const relPath of flatFiles) { + const skillName = path.basename(relPath, '.md'); + if (!skillSources.has(skillName)) { + skillSources.set(skillName, { isDirectory: false }); + } + } + + for (const [skillName, source] of skillSources) { const skillDir = path.join(targetDir, skillName); try { - // Try directory-based skill first (skills/{name}/SKILL.md) - const dirSource = path.join(skillsRoot, skillName); - const dirSkillFile = path.join(dirSource, 'SKILL.md'); - - let isDirectory = false; - try { - const stat = await fs.stat(dirSource); - isDirectory = stat.isDirectory(); - } catch { /* not a directory */ } - - if (isDirectory) { + if (source.isDirectory) { + const dirSource = path.join(skillsRoot, skillName); await copyDirRecursive(dirSource, skillDir); installed.push(skillName); } else { - // Fall back to flat file (skills/{name}.md) const flatSource = path.join(skillsRoot, `${skillName}.md`); const content = await fs.readFile(flatSource, 'utf-8'); await fs.mkdir(skillDir, { recursive: true }); diff --git a/gitnexus/test/integration/setup-skills.test.ts b/gitnexus/test/integration/setup-skills.test.ts new file mode 100644 index 000000000..9f60909e9 --- /dev/null +++ b/gitnexus/test/integration/setup-skills.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fs from 'fs/promises'; +import path from 'path'; +import os from 'os'; +import { fileURLToPath } from 'url'; +import { setupCommand } from '../../src/cli/setup.js'; + +describe('setupCommand skills integration', () => { + let tempHome: string; + const originalHome = process.env.HOME; + const testId = `${Date.now()}-${process.pid}`; + const flatSkillName = `test-flat-skill-${testId}`; + const dirSkillName = `test-dir-skill-${testId}`; + const testDir = path.dirname(fileURLToPath(import.meta.url)); + const packageSkillsRoot = path.resolve(testDir, '..', '..', 'skills'); + + beforeAll(async () => { + tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-setup-home-')); + process.env.HOME = tempHome; + await fs.mkdir(path.join(tempHome, '.cursor'), { recursive: true }); + + // Create temporary source skills to verify both supported source layouts: + // - flat file: skills/{name}.md + // - directory: skills/{name}/SKILL.md (+ nested files copied recursively) + await fs.writeFile( + path.join(packageSkillsRoot, `${flatSkillName}.md`), + `---\nname: ${flatSkillName}\ndescription: temp flat skill\n---\n\n# Flat Test Skill`, + 'utf-8', + ); + await fs.mkdir(path.join(packageSkillsRoot, dirSkillName, 'references'), { recursive: true }); + await fs.writeFile( + path.join(packageSkillsRoot, dirSkillName, 'SKILL.md'), + `---\nname: ${dirSkillName}\ndescription: temp directory skill\n---\n\n# Directory Test Skill`, + 'utf-8', + ); + await fs.writeFile( + path.join(packageSkillsRoot, dirSkillName, 'references', 'note.md'), + '# Directory Nested File', + 'utf-8', + ); + }); + + afterAll(async () => { + await fs.rm(path.join(packageSkillsRoot, `${flatSkillName}.md`), { force: true }); + await fs.rm(path.join(packageSkillsRoot, dirSkillName), { recursive: true, force: true }); + process.env.HOME = originalHome; + await fs.rm(tempHome, { recursive: true, force: true }); + }); + + it('installs packaged, flat-file, and directory skills into cursor skills directory', async () => { + await setupCommand(); + + const cursorSkillsRoot = path.join(tempHome, '.cursor', 'skills'); + const entries = await fs.readdir(cursorSkillsRoot, { withFileTypes: true }); + const skillDirs = entries.filter(e => e.isDirectory()).map(e => e.name); + + expect(skillDirs.length).toBeGreaterThan(0); + expect(skillDirs).toContain('gitnexus-cli'); + + const skillContent = await fs.readFile( + path.join(cursorSkillsRoot, 'gitnexus-cli', 'SKILL.md'), + 'utf-8', + ); + expect(skillContent).toContain('GitNexus CLI Commands'); + + // Flat file source should be installed as {name}/SKILL.md. + const flatInstalled = await fs.readFile( + path.join(cursorSkillsRoot, flatSkillName, 'SKILL.md'), + 'utf-8', + ); + expect(flatInstalled).toContain('# Flat Test Skill'); + + // Directory source should be copied recursively with nested files preserved. + const dirInstalled = await fs.readFile( + path.join(cursorSkillsRoot, dirSkillName, 'SKILL.md'), + 'utf-8', + ); + expect(dirInstalled).toContain('# Directory Test Skill'); + const nestedInstalled = await fs.readFile( + path.join(cursorSkillsRoot, dirSkillName, 'references', 'note.md'), + 'utf-8', + ); + expect(nestedInstalled).toContain('Directory Nested File'); + }); +});