Skip to content
42 changes: 27 additions & 15 deletions gitnexus/src/cli/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -240,8 +241,6 @@ async function setupOpenCode(result: SetupResult): Promise<void> {

// ─── 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
Expand All @@ -255,25 +254,38 @@ async function installSkillsTo(targetDir: string): Promise<string[]> {
const installed: string[] = [];
const skillsRoot = path.join(__dirname, '..', '..', 'skills');

for (const skillName of SKILL_NAMES) {
const skillDir = path.join(targetDir, skillName);
let flatFiles: string[] = [];
let dirSkillFiles: string[] = [];
try {
[flatFiles, dirSkillFiles] = await Promise.all([
glob('*.md', { cwd: skillsRoot }),
glob('*/SKILL.md', { cwd: skillsRoot }),
]);
} catch {
return [];
}

try {
// Try directory-based skill first (skills/{name}/SKILL.md)
const dirSource = path.join(skillsRoot, skillName);
const dirSkillFile = path.join(dirSource, 'SKILL.md');
const skillSources = new Map<string, { isDirectory: boolean }>();

let isDirectory = false;
try {
const stat = await fs.stat(dirSource);
isDirectory = stat.isDirectory();
} catch { /* not a directory */ }
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 });
}
}

if (isDirectory) {
for (const [skillName, source] of skillSources) {
const skillDir = path.join(targetDir, skillName);

try {
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 });
Expand Down
85 changes: 85 additions & 0 deletions gitnexus/test/integration/setup-skills.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading