import { describe, it, expect, afterEach } from 'vitest'; import { mkdirSync, writeFileSync, rmSync, mkdtempSync, existsSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { SkillInstaller } from './installer.js'; describe('SkillInstaller', () => { // Objective: verify that the installer correctly copies, upgrades, and // removes skill directories in the managed area. const tmpDirs: string[] = []; /** Create a temp directory and track it for cleanup. */ function makeTmpDir(): string { const dir = mkdtempSync(join(tmpdir(), 'flynn-test-')); tmpDirs.push(dir); return dir; } /** Create a minimal source skill directory with SKILL.md and optional manifest. */ function makeSourceSkill( parentDir: string, dirName: string, options: { manifest?: Record; instructions?: string } = {}, ): string { const skillDir = join(parentDir, dirName); mkdirSync(skillDir, { recursive: true }); writeFileSync(join(skillDir, 'SKILL.md'), options.instructions ?? `# ${dirName}\nDefault instructions.`); if (options.manifest) { writeFileSync(join(skillDir, 'manifest.json'), JSON.stringify(options.manifest)); } return skillDir; } afterEach(() => { for (const dir of tmpDirs) { rmSync(dir, { recursive: true, force: true }); } tmpDirs.length = 0; }); it('creates managedDir in constructor if it does not exist', () => { // Positive: the constructor should ensure the managed directory exists. const tmp = makeTmpDir(); const managedDir = join(tmp, 'managed', 'nested'); new SkillInstaller(managedDir); expect(existsSync(managedDir)).toBe(true); }); it('installs a skill from a source directory', () => { // Positive: install should copy SKILL.md and return a loaded Skill. const tmp = makeTmpDir(); const managedDir = join(tmp, 'managed'); const sourceDir = makeSourceSkill(tmp, 'my-skill', { manifest: { name: 'my-skill', description: 'A skill', version: '1.0.0' }, instructions: '# My Skill\nDo the thing.', }); const installer = new SkillInstaller(managedDir); const skill = installer.install(sourceDir); expect(skill).not.toBeNull(); expect(skill!.manifest.name).toBe('my-skill'); expect(skill!.instructions).toBe('# My Skill\nDo the thing.'); expect(existsSync(join(managedDir, 'my-skill', 'SKILL.md'))).toBe(true); }); it('installed skill has tier "managed"', () => { // Positive: regardless of source manifest, installed skill tier must be 'managed'. const tmp = makeTmpDir(); const managedDir = join(tmp, 'managed'); const sourceDir = makeSourceSkill(tmp, 'tier-skill', { manifest: { name: 'tier-skill', description: 'Tier test', version: '1.0.0', tier: 'bundled' }, }); const installer = new SkillInstaller(managedDir); const skill = installer.install(sourceDir); expect(skill).not.toBeNull(); expect(skill!.manifest.tier).toBe('managed'); }); it('uses manifest.json name field for the installed directory name', () => { // Positive: directory in managed area should match manifest name, not source dirname. const tmp = makeTmpDir(); const managedDir = join(tmp, 'managed'); const sourceDir = makeSourceSkill(tmp, 'source-dirname', { manifest: { name: 'manifest-name', description: 'Named via manifest', version: '1.0.0' }, }); const installer = new SkillInstaller(managedDir); installer.install(sourceDir); expect(existsSync(join(managedDir, 'manifest-name', 'SKILL.md'))).toBe(true); expect(existsSync(join(managedDir, 'source-dirname'))).toBe(false); }); it('falls back to basename when no manifest.json is present', () => { // Positive: without manifest.json, directory name should come from source basename. const tmp = makeTmpDir(); const managedDir = join(tmp, 'managed'); const sourceDir = makeSourceSkill(tmp, 'fallback-name'); // No manifest.json written const installer = new SkillInstaller(managedDir); installer.install(sourceDir); expect(existsSync(join(managedDir, 'fallback-name', 'SKILL.md'))).toBe(true); }); it('upgrades an existing skill by replacing it', () => { // Positive: re-installing should overwrite the old version. const tmp = makeTmpDir(); const managedDir = join(tmp, 'managed'); const sourceV1 = makeSourceSkill(tmp, 'v1-source', { manifest: { name: 'upgradable', description: 'Version 1', version: '1.0.0' }, instructions: '# V1', }); const installer = new SkillInstaller(managedDir); installer.install(sourceV1); // Create v2 source const sourceV2 = makeSourceSkill(tmp, 'v2-source', { manifest: { name: 'upgradable', description: 'Version 2', version: '2.0.0' }, instructions: '# V2', }); const skill = installer.install(sourceV2); expect(skill).not.toBeNull(); expect(skill!.manifest.version).toBe('2.0.0'); expect(skill!.instructions).toBe('# V2'); }); it('throws when source directory does not exist', () => { // Negative: install should throw a descriptive error for missing sources. const tmp = makeTmpDir(); const managedDir = join(tmp, 'managed'); const installer = new SkillInstaller(managedDir); expect(() => installer.install('/tmp/does-not-exist-xyz-abc-123')).toThrow( 'Source directory does not exist', ); }); it('throws when source directory has no SKILL.md', () => { // Negative: install should throw when SKILL.md is missing. const tmp = makeTmpDir(); const managedDir = join(tmp, 'managed'); const emptyDir = join(tmp, 'empty-skill'); mkdirSync(emptyDir); const installer = new SkillInstaller(managedDir); expect(() => installer.install(emptyDir)).toThrow('does not contain SKILL.md'); }); it('uninstalls a skill', () => { // Positive: uninstall should remove the directory and return true. const tmp = makeTmpDir(); const managedDir = join(tmp, 'managed'); const sourceDir = makeSourceSkill(tmp, 'removable', { manifest: { name: 'removable', description: 'Will be removed', version: '1.0.0' }, }); const installer = new SkillInstaller(managedDir); installer.install(sourceDir); const result = installer.uninstall('removable'); expect(result).toBe(true); expect(existsSync(join(managedDir, 'removable'))).toBe(false); }); it('returns false when uninstalling a nonexistent skill', () => { // Negative: uninstalling a name that was never installed should return false. const tmp = makeTmpDir(); const managedDir = join(tmp, 'managed'); const installer = new SkillInstaller(managedDir); expect(installer.uninstall('ghost')).toBe(false); }); it('listInstalled lists all installed skills', () => { // Positive: every installed skill should appear in the list. const tmp = makeTmpDir(); const managedDir = join(tmp, 'managed'); const sourceA = makeSourceSkill(tmp, 'skill-a', { manifest: { name: 'skill-a', description: 'Skill A', version: '1.0.0' }, }); const sourceB = makeSourceSkill(tmp, 'skill-b', { manifest: { name: 'skill-b', description: 'Skill B', version: '1.0.0' }, }); const installer = new SkillInstaller(managedDir); installer.install(sourceA); installer.install(sourceB); const installed = installer.listInstalled(); expect(installed).toHaveLength(2); expect(installed).toContain('skill-a'); expect(installed).toContain('skill-b'); }); it('isInstalled checks correctly', () => { // Positive/Negative: should return true for installed, false for not installed. const tmp = makeTmpDir(); const managedDir = join(tmp, 'managed'); const sourceDir = makeSourceSkill(tmp, 'check-me', { manifest: { name: 'check-me', description: 'Check me', version: '1.0.0' }, }); const installer = new SkillInstaller(managedDir); installer.install(sourceDir); expect(installer.isInstalled('check-me')).toBe(true); expect(installer.isInstalled('not-installed')).toBe(false); }); });