diff --git a/tests/build.test.js b/tests/build.test.js index ac4baa63e..ad5bd8de1 100644 --- a/tests/build.test.js +++ b/tests/build.test.js @@ -1,4 +1,4 @@ -import { describe, test, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test'; +import { describe, test, expect, beforeEach, afterEach, spyOn } from 'bun:test'; import fs from 'fs'; import path from 'path'; import * as utils from '../scripts/lib/utils.js'; @@ -22,7 +22,6 @@ describe('build orchestration', () => { test('should call readSourceFiles with root directory', () => { const readSourceFilesSpy = spyOn(utils, 'readSourceFiles').mockReturnValue({ - commands: [], skills: [] }); @@ -35,14 +34,15 @@ describe('build orchestration', () => { const ROOT_DIR = TEST_DIR; const DIST_DIR = path.join(ROOT_DIR, 'dist'); - const { commands, skills } = utils.readSourceFiles(ROOT_DIR); - transformers.transformCursor(commands, skills, DIST_DIR); - transformers.transformClaudeCode(commands, skills, DIST_DIR); - transformers.transformGemini(commands, skills, DIST_DIR); - transformers.transformCodex(commands, skills, DIST_DIR); + const { skills } = utils.readSourceFiles(ROOT_DIR); + const patterns = utils.readPatterns(ROOT_DIR); + transformers.transformCursor(skills, DIST_DIR, patterns); + transformers.transformClaudeCode(skills, DIST_DIR, patterns); + transformers.transformGemini(skills, DIST_DIR, patterns); + transformers.transformCodex(skills, DIST_DIR, patterns); expect(readSourceFilesSpy).toHaveBeenCalledWith(ROOT_DIR); - + readSourceFilesSpy.mockRestore(); transformCursorSpy.mockRestore(); transformClaudeCodeSpy.mockRestore(); @@ -50,18 +50,16 @@ describe('build orchestration', () => { transformCodexSpy.mockRestore(); }); - test('should call all four transformers with correct arguments', () => { - const commands = [ - { name: 'cmd1', description: 'Command 1', args: [], body: 'Body 1' } - ]; + test('should call all transformers with correct arguments', () => { const skills = [ { name: 'skill1', description: 'Skill 1', license: 'MIT', body: 'Skill body 1' } ]; + const patterns = { patterns: [], antipatterns: [] }; const readSourceFilesSpy = spyOn(utils, 'readSourceFiles').mockReturnValue({ - commands, skills }); + const readPatternsSpy = spyOn(utils, 'readPatterns').mockReturnValue(patterns); const transformCursorSpy = spyOn(transformers, 'transformCursor').mockImplementation(() => {}); const transformClaudeCodeSpy = spyOn(transformers, 'transformClaudeCode').mockImplementation(() => {}); @@ -72,17 +70,19 @@ describe('build orchestration', () => { const DIST_DIR = path.join(ROOT_DIR, 'dist'); const sourceFiles = utils.readSourceFiles(ROOT_DIR); - transformers.transformCursor(sourceFiles.commands, sourceFiles.skills, DIST_DIR); - transformers.transformClaudeCode(sourceFiles.commands, sourceFiles.skills, DIST_DIR); - transformers.transformGemini(sourceFiles.commands, sourceFiles.skills, DIST_DIR); - transformers.transformCodex(sourceFiles.commands, sourceFiles.skills, DIST_DIR); + const patternData = utils.readPatterns(ROOT_DIR); + transformers.transformCursor(sourceFiles.skills, DIST_DIR, patternData); + transformers.transformClaudeCode(sourceFiles.skills, DIST_DIR, patternData); + transformers.transformGemini(sourceFiles.skills, DIST_DIR, patternData); + transformers.transformCodex(sourceFiles.skills, DIST_DIR, patternData); - expect(transformCursorSpy).toHaveBeenCalledWith(commands, skills, DIST_DIR); - expect(transformClaudeCodeSpy).toHaveBeenCalledWith(commands, skills, DIST_DIR); - expect(transformGeminiSpy).toHaveBeenCalledWith(commands, skills, DIST_DIR); - expect(transformCodexSpy).toHaveBeenCalledWith(commands, skills, DIST_DIR); + expect(transformCursorSpy).toHaveBeenCalledWith(skills, DIST_DIR, patterns); + expect(transformClaudeCodeSpy).toHaveBeenCalledWith(skills, DIST_DIR, patterns); + expect(transformGeminiSpy).toHaveBeenCalledWith(skills, DIST_DIR, patterns); + expect(transformCodexSpy).toHaveBeenCalledWith(skills, DIST_DIR, patterns); readSourceFilesSpy.mockRestore(); + readPatternsSpy.mockRestore(); transformCursorSpy.mockRestore(); transformClaudeCodeSpy.mockRestore(); transformGeminiSpy.mockRestore(); @@ -90,10 +90,12 @@ describe('build orchestration', () => { }); test('should handle empty source files', () => { + const patterns = { patterns: [], antipatterns: [] }; + const readSourceFilesSpy = spyOn(utils, 'readSourceFiles').mockReturnValue({ - commands: [], skills: [] }); + const readPatternsSpy = spyOn(utils, 'readPatterns').mockReturnValue(patterns); const transformCursorSpy = spyOn(transformers, 'transformCursor').mockImplementation(() => {}); const transformClaudeCodeSpy = spyOn(transformers, 'transformClaudeCode').mockImplementation(() => {}); @@ -103,18 +105,20 @@ describe('build orchestration', () => { const ROOT_DIR = TEST_DIR; const DIST_DIR = path.join(ROOT_DIR, 'dist'); - const { commands, skills } = utils.readSourceFiles(ROOT_DIR); - transformers.transformCursor(commands, skills, DIST_DIR); - transformers.transformClaudeCode(commands, skills, DIST_DIR); - transformers.transformGemini(commands, skills, DIST_DIR); - transformers.transformCodex(commands, skills, DIST_DIR); + const { skills } = utils.readSourceFiles(ROOT_DIR); + const patternData = utils.readPatterns(ROOT_DIR); + transformers.transformCursor(skills, DIST_DIR, patternData); + transformers.transformClaudeCode(skills, DIST_DIR, patternData); + transformers.transformGemini(skills, DIST_DIR, patternData); + transformers.transformCodex(skills, DIST_DIR, patternData); - expect(transformCursorSpy).toHaveBeenCalledWith([], [], DIST_DIR); - expect(transformClaudeCodeSpy).toHaveBeenCalledWith([], [], DIST_DIR); - expect(transformGeminiSpy).toHaveBeenCalledWith([], [], DIST_DIR); - expect(transformCodexSpy).toHaveBeenCalledWith([], [], DIST_DIR); + expect(transformCursorSpy).toHaveBeenCalledWith([], DIST_DIR, patterns); + expect(transformClaudeCodeSpy).toHaveBeenCalledWith([], DIST_DIR, patterns); + expect(transformGeminiSpy).toHaveBeenCalledWith([], DIST_DIR, patterns); + expect(transformCodexSpy).toHaveBeenCalledWith([], DIST_DIR, patterns); readSourceFilesSpy.mockRestore(); + readPatternsSpy.mockRestore(); transformCursorSpy.mockRestore(); transformClaudeCodeSpy.mockRestore(); transformGeminiSpy.mockRestore(); @@ -123,17 +127,6 @@ describe('build orchestration', () => { test('integration: full build creates all expected outputs', () => { // Create test source files - const commandContent = `--- -name: test-command -description: A test command -args: - - name: target - description: Target parameter - required: false ---- - -This is a test command body with {{target}} placeholder.`; - const skillContent = `--- name: test-skill description: A test skill @@ -142,113 +135,121 @@ license: MIT This is a test skill body.`; - utils.writeFile(path.join(TEST_DIR, 'source/commands/test-command.md'), commandContent); - utils.writeFile(path.join(TEST_DIR, 'source/skills/test-skill.md'), skillContent); + const skillDir = path.join(TEST_DIR, 'source/skills/test-skill'); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), skillContent); // Run the build process const DIST_DIR = path.join(TEST_DIR, 'dist'); - const { commands, skills } = utils.readSourceFiles(TEST_DIR); - - transformers.transformCursor(commands, skills, DIST_DIR); - transformers.transformClaudeCode(commands, skills, DIST_DIR); - transformers.transformGemini(commands, skills, DIST_DIR); - transformers.transformCodex(commands, skills, DIST_DIR); + const { skills } = utils.readSourceFiles(TEST_DIR); + const patterns = utils.readPatterns(TEST_DIR); + + transformers.transformCursor(skills, DIST_DIR, patterns); + transformers.transformClaudeCode(skills, DIST_DIR, patterns); + transformers.transformGemini(skills, DIST_DIR, patterns); + transformers.transformCodex(skills, DIST_DIR, patterns); // Verify Cursor outputs - expect(fs.existsSync(path.join(DIST_DIR, 'cursor/commands/test-command.md'))).toBe(true); - expect(fs.existsSync(path.join(DIST_DIR, 'cursor/rules/test-skill.md'))).toBe(true); + expect(fs.existsSync(path.join(DIST_DIR, 'cursor/.cursor/skills/test-skill/SKILL.md'))).toBe(true); // Verify Claude Code outputs - expect(fs.existsSync(path.join(DIST_DIR, 'claude-code/commands/test-command.md'))).toBe(true); - expect(fs.existsSync(path.join(DIST_DIR, 'claude-code/skills/test-skill/SKILL.md'))).toBe(true); + expect(fs.existsSync(path.join(DIST_DIR, 'claude-code/.claude/skills/test-skill/SKILL.md'))).toBe(true); // Verify Gemini outputs - expect(fs.existsSync(path.join(DIST_DIR, 'gemini/commands/test-command.toml'))).toBe(true); - expect(fs.existsSync(path.join(DIST_DIR, 'gemini/GEMINI.test-skill.md'))).toBe(true); - expect(fs.existsSync(path.join(DIST_DIR, 'gemini/GEMINI.md'))).toBe(true); + expect(fs.existsSync(path.join(DIST_DIR, 'gemini/.gemini/skills/test-skill/SKILL.md'))).toBe(true); // Verify Codex outputs - expect(fs.existsSync(path.join(DIST_DIR, 'codex/prompts/test-command.md'))).toBe(true); - expect(fs.existsSync(path.join(DIST_DIR, 'codex/AGENTS.test-skill.md'))).toBe(true); - expect(fs.existsSync(path.join(DIST_DIR, 'codex/AGENTS.md'))).toBe(true); + expect(fs.existsSync(path.join(DIST_DIR, 'codex/.codex/skills/test-skill/SKILL.md'))).toBe(true); }); test('integration: verify transformations are correct', () => { - const commandContent = `--- -name: normalize -description: Normalize design + const skillContent = `--- +name: audit +description: Run technical quality checks +user-invokable: true args: - name: target description: Target element required: false --- -Please normalize {{target}} to match the design system.`; +Please audit {{target}} for technical quality. Ask {{model}} for help.`; - utils.writeFile(path.join(TEST_DIR, 'source/commands/normalize.md'), commandContent); + const skillDir = path.join(TEST_DIR, 'source/skills/audit'); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), skillContent); const DIST_DIR = path.join(TEST_DIR, 'dist'); - const { commands, skills } = utils.readSourceFiles(TEST_DIR); - - transformers.transformCursor(commands, skills, DIST_DIR); - transformers.transformClaudeCode(commands, skills, DIST_DIR); - transformers.transformGemini(commands, skills, DIST_DIR); - transformers.transformCodex(commands, skills, DIST_DIR); + const { skills } = utils.readSourceFiles(TEST_DIR); + const patterns = utils.readPatterns(TEST_DIR); - // Verify Cursor: body only, no frontmatter - const cursorContent = fs.readFileSync(path.join(DIST_DIR, 'cursor/commands/normalize.md'), 'utf-8'); - expect(cursorContent).not.toContain('---'); + transformers.transformCursor(skills, DIST_DIR, patterns); + transformers.transformClaudeCode(skills, DIST_DIR, patterns); + transformers.transformGemini(skills, DIST_DIR, patterns); + transformers.transformCodex(skills, DIST_DIR, patterns); + + // Verify Cursor: full frontmatter with user-invokable + const cursorContent = fs.readFileSync(path.join(DIST_DIR, 'cursor/.cursor/skills/audit/SKILL.md'), 'utf-8'); + expect(cursorContent).toContain('---'); + expect(cursorContent).toContain('name: audit'); expect(cursorContent).toContain('{{target}}'); + expect(cursorContent).toContain('the model'); - // Verify Claude Code: full frontmatter - const claudeContent = fs.readFileSync(path.join(DIST_DIR, 'claude-code/commands/normalize.md'), 'utf-8'); + // Verify Claude Code: full frontmatter with user-invokable and args + const claudeContent = fs.readFileSync(path.join(DIST_DIR, 'claude-code/.claude/skills/audit/SKILL.md'), 'utf-8'); expect(claudeContent).toContain('---'); - expect(claudeContent).toContain('name: normalize'); + expect(claudeContent).toContain('name: audit'); + expect(claudeContent).toContain('user-invokable: true'); expect(claudeContent).toContain('{{target}}'); + expect(claudeContent).toContain('Claude'); - // Verify Gemini: TOML with {{args}} - const geminiContent = fs.readFileSync(path.join(DIST_DIR, 'gemini/commands/normalize.toml'), 'utf-8'); - expect(geminiContent).toContain('description = "Normalize design"'); - expect(geminiContent).toContain('{{args}}'); - expect(geminiContent).not.toContain('{{target}}'); + // Verify Gemini: skill in skills directory + expect(fs.existsSync(path.join(DIST_DIR, 'gemini/.gemini/skills/audit/SKILL.md'))).toBe(true); + const geminiContent = fs.readFileSync(path.join(DIST_DIR, 'gemini/.gemini/skills/audit/SKILL.md'), 'utf-8'); + expect(geminiContent).toContain('{{args}}'); // Replaced for user-invokable in Gemini + expect(geminiContent).toContain('Gemini'); - // Verify Codex: $VARIABLE - const codexContent = fs.readFileSync(path.join(DIST_DIR, 'codex/prompts/normalize.md'), 'utf-8'); - expect(codexContent).toContain('$TARGET'); - expect(codexContent).not.toContain('{{target}}'); + // Verify Codex: skill in skills directory + expect(fs.existsSync(path.join(DIST_DIR, 'codex/.codex/skills/audit/SKILL.md'))).toBe(true); + const codexContent = fs.readFileSync(path.join(DIST_DIR, 'codex/.codex/skills/audit/SKILL.md'), 'utf-8'); + expect(codexContent).toContain('$TARGET'); // Replaced for user-invokable in Codex + expect(codexContent).toContain('GPT'); }); - test('integration: multiple commands and skills', () => { - utils.writeFile(path.join(TEST_DIR, 'source/commands/cmd1.md'), '---\nname: cmd1\n---\nBody1'); - utils.writeFile(path.join(TEST_DIR, 'source/commands/cmd2.md'), '---\nname: cmd2\n---\nBody2'); - utils.writeFile(path.join(TEST_DIR, 'source/skills/skill1.md'), '---\nname: skill1\n---\nSkill1'); - utils.writeFile(path.join(TEST_DIR, 'source/skills/skill2.md'), '---\nname: skill2\n---\nSkill2'); + test('integration: multiple skills', () => { + const skill1Dir = path.join(TEST_DIR, 'source/skills/skill1'); + fs.mkdirSync(skill1Dir, { recursive: true }); + fs.writeFileSync(path.join(skill1Dir, 'SKILL.md'), '---\nname: skill1\n---\nSkill1'); + + const skill2Dir = path.join(TEST_DIR, 'source/skills/skill2'); + fs.mkdirSync(skill2Dir, { recursive: true }); + fs.writeFileSync(path.join(skill2Dir, 'SKILL.md'), '---\nname: skill2\n---\nSkill2'); const DIST_DIR = path.join(TEST_DIR, 'dist'); - const { commands, skills } = utils.readSourceFiles(TEST_DIR); - - expect(commands).toHaveLength(2); + const { skills } = utils.readSourceFiles(TEST_DIR); + const patterns = utils.readPatterns(TEST_DIR); + expect(skills).toHaveLength(2); - transformers.transformCursor(commands, skills, DIST_DIR); - transformers.transformClaudeCode(commands, skills, DIST_DIR); - transformers.transformGemini(commands, skills, DIST_DIR); - transformers.transformCodex(commands, skills, DIST_DIR); + transformers.transformCursor(skills, DIST_DIR, patterns); + transformers.transformClaudeCode(skills, DIST_DIR, patterns); + transformers.transformGemini(skills, DIST_DIR, patterns); + transformers.transformCodex(skills, DIST_DIR, patterns); // Verify all files exist - expect(fs.existsSync(path.join(DIST_DIR, 'cursor/commands/cmd1.md'))).toBe(true); - expect(fs.existsSync(path.join(DIST_DIR, 'cursor/commands/cmd2.md'))).toBe(true); - expect(fs.existsSync(path.join(DIST_DIR, 'cursor/rules/skill1.md'))).toBe(true); - expect(fs.existsSync(path.join(DIST_DIR, 'cursor/rules/skill2.md'))).toBe(true); + expect(fs.existsSync(path.join(DIST_DIR, 'cursor/.cursor/skills/skill1/SKILL.md'))).toBe(true); + expect(fs.existsSync(path.join(DIST_DIR, 'cursor/.cursor/skills/skill2/SKILL.md'))).toBe(true); + expect(fs.existsSync(path.join(DIST_DIR, 'claude-code/.claude/skills/skill1/SKILL.md'))).toBe(true); + expect(fs.existsSync(path.join(DIST_DIR, 'claude-code/.claude/skills/skill2/SKILL.md'))).toBe(true); }); test('should call transformers in correct order', () => { const callOrder = []; const readSourceFilesSpy = spyOn(utils, 'readSourceFiles').mockReturnValue({ - commands: [], skills: [] }); + const readPatternsSpy = spyOn(utils, 'readPatterns').mockReturnValue({ patterns: [], antipatterns: [] }); const transformCursorSpy = spyOn(transformers, 'transformCursor').mockImplementation(() => { callOrder.push('cursor'); @@ -266,19 +267,34 @@ Please normalize {{target}} to match the design system.`; const ROOT_DIR = TEST_DIR; const DIST_DIR = path.join(ROOT_DIR, 'dist'); - const { commands, skills } = utils.readSourceFiles(ROOT_DIR); - transformers.transformCursor(commands, skills, DIST_DIR); - transformers.transformClaudeCode(commands, skills, DIST_DIR); - transformers.transformGemini(commands, skills, DIST_DIR); - transformers.transformCodex(commands, skills, DIST_DIR); + const { skills } = utils.readSourceFiles(ROOT_DIR); + const patterns = utils.readPatterns(ROOT_DIR); + transformers.transformCursor(skills, DIST_DIR, patterns); + transformers.transformClaudeCode(skills, DIST_DIR, patterns); + transformers.transformGemini(skills, DIST_DIR, patterns); + transformers.transformCodex(skills, DIST_DIR, patterns); expect(callOrder).toEqual(['cursor', 'claude-code', 'gemini', 'codex']); readSourceFilesSpy.mockRestore(); + readPatternsSpy.mockRestore(); transformCursorSpy.mockRestore(); transformClaudeCodeSpy.mockRestore(); transformGeminiSpy.mockRestore(); transformCodexSpy.mockRestore(); }); -}); + test('should include agents and kiro transformers', () => { + const { skills } = utils.readSourceFiles(TEST_DIR); + const patterns = utils.readPatterns(TEST_DIR); + const DIST_DIR = path.join(TEST_DIR, 'dist'); + + // These should not throw + transformers.transformAgents(skills, DIST_DIR, patterns); + transformers.transformKiro(skills, DIST_DIR, patterns); + + // Verify outputs + expect(fs.existsSync(path.join(DIST_DIR, 'agents/.agents/skills'))).toBe(true); + expect(fs.existsSync(path.join(DIST_DIR, 'kiro/.kiro/skills'))).toBe(true); + }); +}); diff --git a/tests/lib/transformers/claude-code.test.js b/tests/lib/transformers/claude-code.test.js index 55cdd84ed..f3ac5f24f 100644 --- a/tests/lib/transformers/claude-code.test.js +++ b/tests/lib/transformers/claude-code.test.js @@ -20,63 +20,14 @@ describe('transformClaudeCode', () => { }); test('should create correct directory structure', () => { - const commands = []; const skills = []; - - transformClaudeCode(commands, skills, TEST_DIR); - - expect(fs.existsSync(path.join(TEST_DIR, 'claude-code/commands'))).toBe(true); - expect(fs.existsSync(path.join(TEST_DIR, 'claude-code/skills'))).toBe(true); + + transformClaudeCode(skills, TEST_DIR); + + expect(fs.existsSync(path.join(TEST_DIR, 'claude-code/.claude/skills'))).toBe(true); }); - test('should preserve full frontmatter for commands', () => { - const commands = [ - { - name: 'test-command', - description: 'A test command', - args: [ - { name: 'target', description: 'The target', required: false }, - { name: 'output', description: 'Output format', required: true } - ], - body: 'Command body here.' - } - ]; - - transformClaudeCode(commands, [], TEST_DIR); - - const outputPath = path.join(TEST_DIR, 'claude-code/commands/test-command.md'); - const content = fs.readFileSync(outputPath, 'utf-8'); - const parsed = parseFrontmatter(content); - - expect(parsed.frontmatter.name).toBe('test-command'); - expect(parsed.frontmatter.description).toBe('A test command'); - expect(parsed.frontmatter.args).toBeArray(); - expect(parsed.frontmatter.args).toHaveLength(2); - expect(parsed.frontmatter.args[0].name).toBe('target'); - expect(parsed.frontmatter.args[1].required).toBe(true); - expect(parsed.body).toBe('Command body here.'); - }); - - test('should handle commands without args', () => { - const commands = [ - { - name: 'simple-cmd', - description: 'Simple command', - args: [], - body: 'Simple body.' - } - ]; - - transformClaudeCode(commands, [], TEST_DIR); - - const content = fs.readFileSync(path.join(TEST_DIR, 'claude-code/commands/simple-cmd.md'), 'utf-8'); - const parsed = parseFrontmatter(content); - - expect(parsed.frontmatter.name).toBe('simple-cmd'); - expect(parsed.frontmatter.args).toBeUndefined(); - }); - - test('should create skills in subdirectories with SKILL.md filename', () => { + test('should create skill with full frontmatter', () => { const skills = [ { name: 'test-skill', @@ -85,21 +36,84 @@ describe('transformClaudeCode', () => { body: 'Skill instructions.' } ]; - - transformClaudeCode([], skills, TEST_DIR); - - const outputPath = path.join(TEST_DIR, 'claude-code/skills/test-skill/SKILL.md'); + + transformClaudeCode(skills, TEST_DIR); + + const outputPath = path.join(TEST_DIR, 'claude-code/.claude/skills/test-skill/SKILL.md'); expect(fs.existsSync(outputPath)).toBe(true); - + const content = fs.readFileSync(outputPath, 'utf-8'); const parsed = parseFrontmatter(content); - + expect(parsed.frontmatter.name).toBe('test-skill'); expect(parsed.frontmatter.description).toBe('A test skill'); expect(parsed.frontmatter.license).toBe('MIT'); expect(parsed.body).toBe('Skill instructions.'); }); + test('should add user-invokable flag for user-invokable skills', () => { + const skills = [ + { + name: 'audit', + description: 'Audit command', + userInvokable: true, + body: 'Audit the code.' + } + ]; + + transformClaudeCode(skills, TEST_DIR); + + const content = fs.readFileSync(path.join(TEST_DIR, 'claude-code/.claude/skills/audit/SKILL.md'), 'utf-8'); + const parsed = parseFrontmatter(content); + + expect(parsed.frontmatter['user-invokable']).toBe(true); + }); + + test('should include args in frontmatter for user-invokable skills', () => { + const skills = [ + { + name: 'test-command', + description: 'A test command', + userInvokable: true, + args: [ + { name: 'target', description: 'The target', required: false }, + { name: 'output', description: 'Output format', required: true } + ], + body: 'Command body here.' + } + ]; + + transformClaudeCode(skills, TEST_DIR); + + const outputPath = path.join(TEST_DIR, 'claude-code/.claude/skills/test-command/SKILL.md'); + const content = fs.readFileSync(outputPath, 'utf-8'); + const parsed = parseFrontmatter(content); + + expect(parsed.frontmatter.args).toBeArray(); + expect(parsed.frontmatter.args).toHaveLength(2); + expect(parsed.frontmatter.args[0].name).toBe('target'); + expect(parsed.frontmatter.args[1].required).toBe(true); + }); + + test('should handle skills without args', () => { + const skills = [ + { + name: 'simple-skill', + description: 'Simple skill', + userInvokable: true, + args: [], + body: 'Simple body.' + } + ]; + + transformClaudeCode(skills, TEST_DIR); + + const content = fs.readFileSync(path.join(TEST_DIR, 'claude-code/.claude/skills/simple-skill/SKILL.md'), 'utf-8'); + const parsed = parseFrontmatter(content); + + expect(parsed.frontmatter.args).toBeUndefined(); + }); + test('should handle skills without license', () => { const skills = [ { @@ -109,27 +123,13 @@ describe('transformClaudeCode', () => { body: 'Body content.' } ]; - - transformClaudeCode([], skills, TEST_DIR); - - const content = fs.readFileSync(path.join(TEST_DIR, 'claude-code/skills/no-license-skill/SKILL.md'), 'utf-8'); - const parsed = parseFrontmatter(content); - - expect(parsed.frontmatter.license).toBeUndefined(); - }); - test('should handle multiple commands', () => { - const commands = [ - { name: 'cmd1', description: 'Command 1', args: [], body: 'Body 1' }, - { name: 'cmd2', description: 'Command 2', args: [], body: 'Body 2' }, - { name: 'cmd3', description: 'Command 3', args: [], body: 'Body 3' } - ]; - - transformClaudeCode(commands, [], TEST_DIR); - - expect(fs.existsSync(path.join(TEST_DIR, 'claude-code/commands/cmd1.md'))).toBe(true); - expect(fs.existsSync(path.join(TEST_DIR, 'claude-code/commands/cmd2.md'))).toBe(true); - expect(fs.existsSync(path.join(TEST_DIR, 'claude-code/commands/cmd3.md'))).toBe(true); + transformClaudeCode(skills, TEST_DIR); + + const content = fs.readFileSync(path.join(TEST_DIR, 'claude-code/.claude/skills/no-license-skill/SKILL.md'), 'utf-8'); + const parsed = parseFrontmatter(content); + + expect(parsed.frontmatter.license).toBeUndefined(); }); test('should handle multiple skills', () => { @@ -137,70 +137,98 @@ describe('transformClaudeCode', () => { { name: 'skill1', description: 'Skill 1', license: 'MIT', body: 'Body 1' }, { name: 'skill2', description: 'Skill 2', license: 'Apache', body: 'Body 2' } ]; - - transformClaudeCode([], skills, TEST_DIR); - - expect(fs.existsSync(path.join(TEST_DIR, 'claude-code/skills/skill1/SKILL.md'))).toBe(true); - expect(fs.existsSync(path.join(TEST_DIR, 'claude-code/skills/skill2/SKILL.md'))).toBe(true); + + transformClaudeCode(skills, TEST_DIR); + + expect(fs.existsSync(path.join(TEST_DIR, 'claude-code/.claude/skills/skill1/SKILL.md'))).toBe(true); + expect(fs.existsSync(path.join(TEST_DIR, 'claude-code/.claude/skills/skill2/SKILL.md'))).toBe(true); }); test('should clean existing directory before writing', () => { - fs.mkdirSync(path.join(TEST_DIR, 'claude-code/commands'), { recursive: true }); - fs.writeFileSync(path.join(TEST_DIR, 'claude-code/commands/old.md'), 'old'); - - const commands = [{ name: 'new', description: 'New', args: [], body: 'New' }]; - transformClaudeCode(commands, [], TEST_DIR); - - expect(fs.existsSync(path.join(TEST_DIR, 'claude-code/commands/old.md'))).toBe(false); - expect(fs.existsSync(path.join(TEST_DIR, 'claude-code/commands/new.md'))).toBe(true); + // Create a pre-existing file structure + const existingDir = path.join(TEST_DIR, 'claude-code/.claude/skills/old'); + fs.mkdirSync(existingDir, { recursive: true }); + fs.writeFileSync(path.join(existingDir, 'SKILL.md'), 'old'); + + const skills = [{ name: 'new', description: 'New', license: '', body: 'New' }]; + transformClaudeCode(skills, TEST_DIR); + + expect(fs.existsSync(path.join(TEST_DIR, 'claude-code/.claude/skills/old/SKILL.md'))).toBe(false); + expect(fs.existsSync(path.join(TEST_DIR, 'claude-code/.claude/skills/new/SKILL.md'))).toBe(true); }); test('should preserve {{placeholder}} syntax in body', () => { - const commands = [ + const skills = [ { name: 'with-placeholder', description: 'Has placeholder', + userInvokable: true, args: [{ name: 'target', description: 'Target', required: false }], body: 'Process {{target}} and generate output.' } ]; - - transformClaudeCode(commands, [], TEST_DIR); - - const content = fs.readFileSync(path.join(TEST_DIR, 'claude-code/commands/with-placeholder.md'), 'utf-8'); + + transformClaudeCode(skills, TEST_DIR); + + const content = fs.readFileSync(path.join(TEST_DIR, 'claude-code/.claude/skills/with-placeholder/SKILL.md'), 'utf-8'); expect(content).toContain('{{target}}'); }); + test('should copy reference files', () => { + const skills = [ + { + name: 'frontend-design', + description: 'Design skill', + license: 'MIT', + body: 'Design instructions.', + references: [ + { name: 'typography', content: 'Typography reference', filePath: '/fake/path/typography.md' }, + { name: 'color', content: 'Color reference', filePath: '/fake/path/color.md' } + ] + } + ]; + + transformClaudeCode(skills, TEST_DIR); + + expect(fs.existsSync(path.join(TEST_DIR, 'claude-code/.claude/skills/frontend-design/reference/typography.md'))).toBe(true); + expect(fs.existsSync(path.join(TEST_DIR, 'claude-code/.claude/skills/frontend-design/reference/color.md'))).toBe(true); + + const typoContent = fs.readFileSync(path.join(TEST_DIR, 'claude-code/.claude/skills/frontend-design/reference/typography.md'), 'utf-8'); + expect(typoContent).toBe('Typography reference'); + }); + test('should log correct summary', () => { const consoleMock = mock(() => {}); const originalLog = console.log; console.log = consoleMock; - - const commands = [{ name: 'cmd1', description: 'Test', args: [], body: 'body' }]; - const skills = [{ name: 'skill1', description: 'Test', license: '', body: 'body' }]; - - transformClaudeCode(commands, skills, TEST_DIR); - + + const skills = [ + { name: 'skill1', description: 'Test', license: '', userInvokable: true, body: 'body' }, + { name: 'skill2', description: 'Test', license: '', userInvokable: false, body: 'body' } + ]; + + transformClaudeCode(skills, TEST_DIR); + console.log = originalLog; - - expect(consoleMock).toHaveBeenCalledWith('✓ Claude Code: 1 commands, 1 skills'); + + expect(consoleMock).toHaveBeenCalledWith(expect.stringContaining('✓ Claude Code:')); + expect(consoleMock).toHaveBeenCalledWith(expect.stringContaining('2 skills')); + expect(consoleMock).toHaveBeenCalledWith(expect.stringContaining('1 user-invokable')); }); test('should handle empty arrays', () => { - transformClaudeCode([], [], TEST_DIR); - - const commandFiles = fs.readdirSync(path.join(TEST_DIR, 'claude-code/commands')); - const skillDirs = fs.readdirSync(path.join(TEST_DIR, 'claude-code/skills')); - - expect(commandFiles).toHaveLength(0); + transformClaudeCode([], TEST_DIR); + + const skillDirs = fs.readdirSync(path.join(TEST_DIR, 'claude-code/.claude/skills')); expect(skillDirs).toHaveLength(0); }); test('should format frontmatter correctly with args', () => { - const commands = [ + const skills = [ { name: 'test', description: 'Test command', + userInvokable: true, args: [ { name: 'arg1', description: 'First arg', required: true }, { name: 'arg2', description: 'Second arg', required: false } @@ -208,11 +236,11 @@ describe('transformClaudeCode', () => { body: 'Body' } ]; - - transformClaudeCode(commands, [], TEST_DIR); - - const content = fs.readFileSync(path.join(TEST_DIR, 'claude-code/commands/test.md'), 'utf-8'); - + + transformClaudeCode(skills, TEST_DIR); + + const content = fs.readFileSync(path.join(TEST_DIR, 'claude-code/.claude/skills/test/SKILL.md'), 'utf-8'); + expect(content).toContain('---'); expect(content).toContain('name: test'); expect(content).toContain('description: Test command'); @@ -225,11 +253,11 @@ describe('transformClaudeCode', () => { }); test('should preserve multiline body content', () => { - const commands = [ + const skills = [ { name: 'multiline', description: 'Test', - args: [], + license: '', body: `First paragraph. Second paragraph with details. @@ -238,15 +266,91 @@ Second paragraph with details. - List item 2` } ]; - - transformClaudeCode(commands, [], TEST_DIR); - - const content = fs.readFileSync(path.join(TEST_DIR, 'claude-code/commands/multiline.md'), 'utf-8'); + + transformClaudeCode(skills, TEST_DIR); + + const content = fs.readFileSync(path.join(TEST_DIR, 'claude-code/.claude/skills/multiline/SKILL.md'), 'utf-8'); const parsed = parseFrontmatter(content); - + expect(parsed.body).toContain('First paragraph.'); expect(parsed.body).toContain('Second paragraph'); expect(parsed.body).toContain('- List item 1'); }); -}); + test('should support prefix option', () => { + const skills = [ + { name: 'audit', description: 'Audit', license: '', userInvokable: true, body: 'Audit body' } + ]; + + transformClaudeCode(skills, TEST_DIR, null, { prefix: 'i-', outputSuffix: '-prefixed' }); + + expect(fs.existsSync(path.join(TEST_DIR, 'claude-code-prefixed/.claude/skills/i-audit/SKILL.md'))).toBe(true); + + const content = fs.readFileSync(path.join(TEST_DIR, 'claude-code-prefixed/.claude/skills/i-audit/SKILL.md'), 'utf-8'); + expect(content).toContain('name: i-audit'); + }); + + test('should include compatibility in frontmatter', () => { + const skills = [ + { + name: 'test', + description: 'Test', + compatibility: 'claude-code', + body: 'Body' + } + ]; + + transformClaudeCode(skills, TEST_DIR); + + const content = fs.readFileSync(path.join(TEST_DIR, 'claude-code/.claude/skills/test/SKILL.md'), 'utf-8'); + expect(content).toContain('compatibility: claude-code'); + }); + + test('should include allowed-tools in frontmatter', () => { + const skills = [ + { + name: 'test', + description: 'Test', + allowedTools: 'Bash,Edit', + body: 'Body' + } + ]; + + transformClaudeCode(skills, TEST_DIR); + + const content = fs.readFileSync(path.join(TEST_DIR, 'claude-code/.claude/skills/test/SKILL.md'), 'utf-8'); + expect(content).toContain('allowed-tools: Bash,Edit'); + }); + + test('should replace {{model}} placeholder', () => { + const skills = [ + { + name: 'test', + description: 'Test', + license: '', + body: 'Ask {{model}} for help.' + } + ]; + + transformClaudeCode(skills, TEST_DIR); + + const content = fs.readFileSync(path.join(TEST_DIR, 'claude-code/.claude/skills/test/SKILL.md'), 'utf-8'); + expect(content).toContain('Ask Claude for help.'); + }); + + test('should replace {{config_file}} placeholder', () => { + const skills = [ + { + name: 'test', + description: 'Test', + license: '', + body: 'See {{config_file}} for more.' + } + ]; + + transformClaudeCode(skills, TEST_DIR); + + const content = fs.readFileSync(path.join(TEST_DIR, 'claude-code/.claude/skills/test/SKILL.md'), 'utf-8'); + expect(content).toContain('See CLAUDE.md for more.'); + }); +}); diff --git a/tests/lib/transformers/codex.test.js b/tests/lib/transformers/codex.test.js index 4afc067b0..b5afbcd86 100644 --- a/tests/lib/transformers/codex.test.js +++ b/tests/lib/transformers/codex.test.js @@ -20,140 +20,14 @@ describe('transformCodex', () => { }); test('should create correct directory structure', () => { - const commands = []; const skills = []; - - transformCodex(commands, skills, TEST_DIR); - - expect(fs.existsSync(path.join(TEST_DIR, 'codex/prompts'))).toBe(true); - expect(fs.existsSync(path.join(TEST_DIR, 'codex'))).toBe(true); + + transformCodex(skills, TEST_DIR); + + expect(fs.existsSync(path.join(TEST_DIR, 'codex/.codex/skills'))).toBe(true); }); - test('should create command with custom frontmatter format', () => { - const commands = [ - { - name: 'test-command', - description: 'A test command', - args: [], - body: 'Command body content.' - } - ]; - - transformCodex(commands, [], TEST_DIR); - - const outputPath = path.join(TEST_DIR, 'codex/prompts/test-command.md'); - expect(fs.existsSync(outputPath)).toBe(true); - - const content = fs.readFileSync(outputPath, 'utf-8'); - const parsed = parseFrontmatter(content); - - expect(parsed.frontmatter.description).toBe('A test command'); - expect(parsed.body).toBe('Command body content.'); - }); - - test('should create argument-hint for required args', () => { - const commands = [ - { - name: 'with-args', - description: 'Command with args', - args: [ - { name: 'target', description: 'Target', required: true }, - { name: 'output', description: 'Output', required: true } - ], - body: 'Body' - } - ]; - - transformCodex(commands, [], TEST_DIR); - - const content = fs.readFileSync(path.join(TEST_DIR, 'codex/prompts/with-args.md'), 'utf-8'); - const parsed = parseFrontmatter(content); - - expect(parsed.frontmatter['argument-hint']).toBe(' '); - }); - - test('should create argument-hint for optional args', () => { - const commands = [ - { - name: 'optional-args', - description: 'Command with optional args', - args: [ - { name: 'format', description: 'Format', required: false } - ], - body: 'Body' - } - ]; - - transformCodex(commands, [], TEST_DIR); - - const content = fs.readFileSync(path.join(TEST_DIR, 'codex/prompts/optional-args.md'), 'utf-8'); - const parsed = parseFrontmatter(content); - - expect(parsed.frontmatter['argument-hint']).toBe('[FORMAT=]'); - }); - - test('should create argument-hint with mixed required and optional args', () => { - const commands = [ - { - name: 'mixed-args', - description: 'Mixed args', - args: [ - { name: 'input', description: 'Input', required: true }, - { name: 'format', description: 'Format', required: false }, - { name: 'output', description: 'Output', required: true } - ], - body: 'Body' - } - ]; - - transformCodex(commands, [], TEST_DIR); - - const content = fs.readFileSync(path.join(TEST_DIR, 'codex/prompts/mixed-args.md'), 'utf-8'); - const parsed = parseFrontmatter(content); - - expect(parsed.frontmatter['argument-hint']).toBe(' [FORMAT=] '); - }); - - test('should transform {{argname}} to $ARGNAME', () => { - const commands = [ - { - name: 'normalize', - description: 'Normalize', - args: [{ name: 'target', description: 'Target', required: false }], - body: 'Please normalize {{target}} to match the design system.' - } - ]; - - transformCodex(commands, [], TEST_DIR); - - const content = fs.readFileSync(path.join(TEST_DIR, 'codex/prompts/normalize.md'), 'utf-8'); - const parsed = parseFrontmatter(content); - - expect(parsed.body).toContain('$TARGET'); - expect(parsed.body).not.toContain('{{target}}'); - }); - - test('should transform multiple different placeholders', () => { - const commands = [ - { - name: 'multi-arg', - description: 'Multiple args', - args: [], - body: 'Process {{input}} and output to {{output}} with {{format}}.' - } - ]; - - transformCodex(commands, [], TEST_DIR); - - const content = fs.readFileSync(path.join(TEST_DIR, 'codex/prompts/multi-arg.md'), 'utf-8'); - const parsed = parseFrontmatter(content); - - expect(parsed.body).toContain('$INPUT'); - expect(parsed.body).toContain('$OUTPUT'); - expect(parsed.body).toContain('$FORMAT'); - }); - - test('should create modular skill files', () => { + test('should create skill files with frontmatter and body in .codex/skills/ directory', () => { const skills = [ { name: 'test-skill', @@ -162,96 +36,213 @@ describe('transformCodex', () => { body: 'Skill instructions here.' } ]; - - transformCodex([], skills, TEST_DIR); - - const outputPath = path.join(TEST_DIR, 'codex/AGENTS.test-skill.md'); + + transformCodex(skills, TEST_DIR); + + const outputPath = path.join(TEST_DIR, 'codex/.codex/skills/test-skill/SKILL.md'); expect(fs.existsSync(outputPath)).toBe(true); - + const content = fs.readFileSync(outputPath, 'utf-8'); - expect(content).toBe('Skill instructions here.'); + const parsed = parseFrontmatter(content); + + expect(parsed.frontmatter.name).toBe('test-skill'); + expect(parsed.frontmatter.description).toBe('A test skill'); + expect(parsed.body).toBe('Skill instructions here.'); }); - test('should create main AGENTS.md with routing instructions', () => { + test('should create argument-hint for required args', () => { + const skills = [ + { + name: 'with-args', + description: 'Command with args', + userInvokable: true, + args: [ + { name: 'target', description: 'Target', required: true }, + { name: 'output', description: 'Output', required: true } + ], + body: 'Body' + } + ]; + + transformCodex(skills, TEST_DIR); + + const content = fs.readFileSync(path.join(TEST_DIR, 'codex/.codex/skills/with-args/SKILL.md'), 'utf-8'); + const parsed = parseFrontmatter(content); + + expect(parsed.frontmatter['argument-hint']).toBe(' '); + }); + + test('should create argument-hint for optional args', () => { + const skills = [ + { + name: 'optional-args', + description: 'Command with optional args', + userInvokable: true, + args: [ + { name: 'format', description: 'Format', required: false } + ], + body: 'Body' + } + ]; + + transformCodex(skills, TEST_DIR); + + const content = fs.readFileSync(path.join(TEST_DIR, 'codex/.codex/skills/optional-args/SKILL.md'), 'utf-8'); + const parsed = parseFrontmatter(content); + + expect(parsed.frontmatter['argument-hint']).toBe('[FORMAT=]'); + }); + + test('should create argument-hint with mixed required and optional args', () => { + const skills = [ + { + name: 'mixed-args', + description: 'Mixed args', + userInvokable: true, + args: [ + { name: 'input', description: 'Input', required: true }, + { name: 'format', description: 'Format', required: false }, + { name: 'output', description: 'Output', required: true } + ], + body: 'Body' + } + ]; + + transformCodex(skills, TEST_DIR); + + const content = fs.readFileSync(path.join(TEST_DIR, 'codex/.codex/skills/mixed-args/SKILL.md'), 'utf-8'); + const parsed = parseFrontmatter(content); + + expect(parsed.frontmatter['argument-hint']).toBe(' [FORMAT=] '); + }); + + test('should transform {{argname}} to $ARGNAME for user-invokable skills', () => { + const skills = [ + { + name: 'normalize', + description: 'Normalize', + userInvokable: true, + args: [{ name: 'target', description: 'Target', required: false }], + body: 'Please normalize {{target}} to match the design system.' + } + ]; + + transformCodex(skills, TEST_DIR); + + const content = fs.readFileSync(path.join(TEST_DIR, 'codex/.codex/skills/normalize/SKILL.md'), 'utf-8'); + const parsed = parseFrontmatter(content); + + expect(parsed.body).toContain('$TARGET'); + expect(parsed.body).not.toContain('{{target}}'); + }); + + test('should transform multiple different placeholders', () => { + const skills = [ + { + name: 'multi-arg', + description: 'Multiple args', + userInvokable: true, + args: [], + body: 'Process {{input}} and output to {{output}} with {{format}}.' + } + ]; + + transformCodex(skills, TEST_DIR); + + const content = fs.readFileSync(path.join(TEST_DIR, 'codex/.codex/skills/multi-arg/SKILL.md'), 'utf-8'); + const parsed = parseFrontmatter(content); + + expect(parsed.body).toContain('$INPUT'); + expect(parsed.body).toContain('$OUTPUT'); + expect(parsed.body).toContain('$FORMAT'); + }); + + test('should handle multiple skills', () => { + const skills = [ + { name: 'skill1', description: 'Skill 1', license: 'MIT', body: 'Body 1' }, + { name: 'skill2', description: 'Skill 2', license: 'Apache', body: 'Body 2' }, + { name: 'skill3', description: 'Skill 3', license: 'MIT', body: 'Body 3' } + ]; + + transformCodex(skills, TEST_DIR); + + expect(fs.existsSync(path.join(TEST_DIR, 'codex/.codex/skills/skill1/SKILL.md'))).toBe(true); + expect(fs.existsSync(path.join(TEST_DIR, 'codex/.codex/skills/skill2/SKILL.md'))).toBe(true); + expect(fs.existsSync(path.join(TEST_DIR, 'codex/.codex/skills/skill3/SKILL.md'))).toBe(true); + }); + + test('should copy reference files', () => { const skills = [ { name: 'frontend-design', - description: 'Create distinctive, production-grade frontend interfaces', + description: 'Design skill', license: 'MIT', - body: 'Frontend design instructions.' - }, - { - name: 'backend-api', - description: 'Design robust API endpoints', - license: 'Apache', - body: 'Backend API instructions.' + body: 'Design instructions.', + references: [ + { name: 'typography', content: 'Typography reference', filePath: '/fake/path/typography.md' } + ] } ]; - - transformCodex([], skills, TEST_DIR); - - const agentsMdPath = path.join(TEST_DIR, 'codex/AGENTS.md'); - expect(fs.existsSync(agentsMdPath)).toBe(true); - - const content = fs.readFileSync(agentsMdPath, 'utf-8'); - expect(content).toContain('# Codex Agent Instructions'); - expect(content).toContain('## Available Skills'); - expect(content).toContain('### frontend-design'); - expect(content).toContain('**When to use**: Create distinctive, production-grade frontend interfaces'); - expect(content).toContain('**Read**: `AGENTS.frontend-design.md`'); - expect(content).toContain('### backend-api'); - expect(content).toContain('**Read**: `AGENTS.backend-api.md`'); + + transformCodex(skills, TEST_DIR); + + expect(fs.existsSync(path.join(TEST_DIR, 'codex/.codex/skills/frontend-design/reference/typography.md'))).toBe(true); + + const typoContent = fs.readFileSync(path.join(TEST_DIR, 'codex/.codex/skills/frontend-design/reference/typography.md'), 'utf-8'); + expect(typoContent).toBe('Typography reference'); }); - test('should handle multiple commands', () => { - const commands = [ - { name: 'cmd1', description: 'Command 1', args: [], body: 'Body 1' }, - { name: 'cmd2', description: 'Command 2', args: [], body: 'Body 2' }, - { name: 'cmd3', description: 'Command 3', args: [], body: 'Body 3' } + test('should log correct summary', () => { + const consoleMock = mock(() => {}); + const originalLog = console.log; + console.log = consoleMock; + + const skills = [ + { name: 'skill1', description: 'Test', license: '', userInvokable: true, body: 'body' }, + { name: 'skill2', description: 'Test', license: '', userInvokable: false, body: 'body' } ]; - - transformCodex(commands, [], TEST_DIR); - - expect(fs.existsSync(path.join(TEST_DIR, 'codex/prompts/cmd1.md'))).toBe(true); - expect(fs.existsSync(path.join(TEST_DIR, 'codex/prompts/cmd2.md'))).toBe(true); - expect(fs.existsSync(path.join(TEST_DIR, 'codex/prompts/cmd3.md'))).toBe(true); + + transformCodex(skills, TEST_DIR); + + console.log = originalLog; + + expect(consoleMock).toHaveBeenCalledWith(expect.stringContaining('✓ Codex:')); + expect(consoleMock).toHaveBeenCalledWith(expect.stringContaining('2 skills')); + expect(consoleMock).toHaveBeenCalledWith(expect.stringContaining('1 user-invokable')); }); - test('should clean existing directory before writing', () => { - fs.mkdirSync(path.join(TEST_DIR, 'codex/prompts'), { recursive: true }); - fs.writeFileSync(path.join(TEST_DIR, 'codex/prompts/old.md'), 'old'); - - const commands = [{ name: 'new', description: 'New', args: [], body: 'New' }]; - transformCodex(commands, [], TEST_DIR); - - expect(fs.existsSync(path.join(TEST_DIR, 'codex/prompts/old.md'))).toBe(false); - expect(fs.existsSync(path.join(TEST_DIR, 'codex/prompts/new.md'))).toBe(true); + test('should handle empty arrays', () => { + transformCodex([], TEST_DIR); + + const skillDirs = fs.readdirSync(path.join(TEST_DIR, 'codex/.codex/skills')); + expect(skillDirs).toHaveLength(0); }); - test('should handle commands without args', () => { - const commands = [ + test('should handle user-invokable skills without args', () => { + const skills = [ { name: 'no-args', description: 'No args command', + userInvokable: true, args: [], body: 'Body content' } ]; - - transformCodex(commands, [], TEST_DIR); - - const content = fs.readFileSync(path.join(TEST_DIR, 'codex/prompts/no-args.md'), 'utf-8'); + + transformCodex(skills, TEST_DIR); + + const content = fs.readFileSync(path.join(TEST_DIR, 'codex/.codex/skills/no-args/SKILL.md'), 'utf-8'); const parsed = parseFrontmatter(content); - + expect(parsed.frontmatter['argument-hint']).toBeUndefined(); }); test('should preserve multiline body', () => { - const commands = [ + const skills = [ { name: 'multiline', description: 'Test', - args: [], + license: '', body: `First line. Second line after blank. @@ -260,112 +251,58 @@ Second line after blank. - Bullet 2` } ]; - - transformCodex(commands, [], TEST_DIR); - - const content = fs.readFileSync(path.join(TEST_DIR, 'codex/prompts/multiline.md'), 'utf-8'); + + transformCodex(skills, TEST_DIR); + + const content = fs.readFileSync(path.join(TEST_DIR, 'codex/.codex/skills/multiline/SKILL.md'), 'utf-8'); const parsed = parseFrontmatter(content); - + expect(parsed.body).toContain('First line.\n\nSecond line'); expect(parsed.body).toContain('- Bullet 1\n- Bullet 2'); }); - test('should log correct summary', () => { - const consoleMock = mock(() => {}); - const originalLog = console.log; - console.log = consoleMock; - - const commands = [{ name: 'cmd1', description: 'Test', args: [], body: 'body' }]; - const skills = [{ name: 'skill1', description: 'Test', license: '', body: 'body' }]; - - transformCodex(commands, skills, TEST_DIR); - - console.log = originalLog; - - expect(consoleMock).toHaveBeenCalledWith('✓ Codex: 1 prompts, 1 skills (modular)'); - }); - - test('should handle empty arrays', () => { - transformCodex([], [], TEST_DIR); - - const promptFiles = fs.readdirSync(path.join(TEST_DIR, 'codex/prompts')); - expect(promptFiles).toHaveLength(0); - - // Should still create AGENTS.md even with no skills - expect(fs.existsSync(path.join(TEST_DIR, 'codex/AGENTS.md'))).toBe(true); - }); - - test('should handle body without placeholders', () => { - const commands = [ - { - name: 'no-placeholders', - description: 'No placeholders', - args: [], - body: 'Just plain text without any placeholders.' - } - ]; - - transformCodex(commands, [], TEST_DIR); - - const content = fs.readFileSync(path.join(TEST_DIR, 'codex/prompts/no-placeholders.md'), 'utf-8'); - const parsed = parseFrontmatter(content); - - expect(parsed.body).toBe('Just plain text without any placeholders.'); - }); - - test('AGENTS.md should have proper structure', () => { + test('should support prefix option', () => { const skills = [ - { name: 'skill1', description: 'First skill', license: '', body: 'body1' } + { name: 'audit', description: 'Audit', license: '', userInvokable: true, body: 'Audit body' } ]; - - transformCodex([], skills, TEST_DIR); - - const content = fs.readFileSync(path.join(TEST_DIR, 'codex/AGENTS.md'), 'utf-8'); - - expect(content).toContain('# Codex Agent Instructions'); - expect(content).toContain('## Available Skills'); - expect(content).toContain('## How to Use Skills'); - expect(content).toContain('### skill1'); - expect(content).toContain('**When to use**: First skill'); - expect(content).toContain('**Read**: `AGENTS.skill1.md`'); + + transformCodex(skills, TEST_DIR, null, { prefix: 'i-', outputSuffix: '-prefixed' }); + + expect(fs.existsSync(path.join(TEST_DIR, 'codex-prefixed/.codex/skills/i-audit/SKILL.md'))).toBe(true); + + const content = fs.readFileSync(path.join(TEST_DIR, 'codex-prefixed/.codex/skills/i-audit/SKILL.md'), 'utf-8'); + expect(content).toContain('name: i-audit'); }); - test('should handle arg names with hyphens', () => { - const commands = [ - { - name: 'hyphen-arg', - description: 'Test', - args: [], - body: 'Process {{my-input}} and {{output-file}}.' - } - ]; - - transformCodex(commands, [], TEST_DIR); - - const content = fs.readFileSync(path.join(TEST_DIR, 'codex/prompts/hyphen-arg.md'), 'utf-8'); - const parsed = parseFrontmatter(content); - - expect(parsed.body).toContain('$MY-INPUT'); - expect(parsed.body).toContain('$OUTPUT-FILE'); - }); - - test('should create proper frontmatter structure', () => { - const commands = [ + test('should replace {{model}} placeholder', () => { + const skills = [ { name: 'test', - description: 'Test command', - args: [{ name: 'arg1', description: 'Arg 1', required: true }], - body: 'Body' + description: 'Test', + license: '', + body: 'Ask {{model}} for help.' } ]; - - transformCodex(commands, [], TEST_DIR); - - const content = fs.readFileSync(path.join(TEST_DIR, 'codex/prompts/test.md'), 'utf-8'); - - expect(content).toContain('---'); - expect(content).toContain('description: Test command'); - expect(content).toContain('argument-hint: '); + + transformCodex(skills, TEST_DIR); + + const content = fs.readFileSync(path.join(TEST_DIR, 'codex/.codex/skills/test/SKILL.md'), 'utf-8'); + expect(content).toContain('Ask GPT for help.'); + }); + + test('should replace {{config_file}} placeholder', () => { + const skills = [ + { + name: 'test', + description: 'Test', + license: '', + body: 'See {{config_file}} for more.' + } + ]; + + transformCodex(skills, TEST_DIR); + + const content = fs.readFileSync(path.join(TEST_DIR, 'codex/.codex/skills/test/SKILL.md'), 'utf-8'); + expect(content).toContain('See AGENTS.md for more.'); }); }); - diff --git a/tests/lib/transformers/cursor.test.js b/tests/lib/transformers/cursor.test.js index 88e840067..39fc3308b 100644 --- a/tests/lib/transformers/cursor.test.js +++ b/tests/lib/transformers/cursor.test.js @@ -19,154 +19,135 @@ describe('transformCursor', () => { }); test('should create correct directory structure', () => { - const commands = []; const skills = []; - - transformCursor(commands, skills, TEST_DIR); - - expect(fs.existsSync(path.join(TEST_DIR, 'cursor/commands'))).toBe(true); - expect(fs.existsSync(path.join(TEST_DIR, 'cursor/rules'))).toBe(true); + + transformCursor(skills, TEST_DIR); + + expect(fs.existsSync(path.join(TEST_DIR, 'cursor/.cursor/skills'))).toBe(true); }); - test('should strip frontmatter from commands and output body only', () => { - const commands = [ - { - name: 'test-command', - description: 'A test command', - args: [{ name: 'target', description: 'Target', required: false }], - body: 'This is the command body content.' - } - ]; - const skills = []; - - transformCursor(commands, skills, TEST_DIR); - - const outputPath = path.join(TEST_DIR, 'cursor/commands/test-command.md'); - expect(fs.existsSync(outputPath)).toBe(true); - - const content = fs.readFileSync(outputPath, 'utf-8'); - expect(content).toBe('This is the command body content.'); - expect(content).not.toContain('---'); - expect(content).not.toContain('name:'); - expect(content).not.toContain('description:'); - }); - - test('should strip frontmatter from skills and output body only', () => { - const commands = []; + test('should create skill with frontmatter and body', () => { const skills = [ { name: 'test-skill', description: 'A test skill', license: 'MIT', - body: 'These are the skill instructions.' + body: 'Skill instructions here.' } ]; - - transformCursor(commands, skills, TEST_DIR); - - const outputPath = path.join(TEST_DIR, 'cursor/rules/test-skill.md'); + + transformCursor(skills, TEST_DIR); + + const outputPath = path.join(TEST_DIR, 'cursor/.cursor/skills/test-skill/SKILL.md'); expect(fs.existsSync(outputPath)).toBe(true); - + const content = fs.readFileSync(outputPath, 'utf-8'); - expect(content).toBe('These are the skill instructions.'); - expect(content).not.toContain('---'); + expect(content).toContain('---'); + expect(content).toContain('name: test-skill'); + expect(content).toContain('description: A test skill'); + expect(content).toContain('license: MIT'); + expect(content).toContain('Skill instructions here.'); + }); + + test('should handle skills without license', () => { + const skills = [ + { + name: 'no-license-skill', + description: 'Skill without license', + license: '', + body: 'Body content.' + } + ]; + + transformCursor(skills, TEST_DIR); + + const content = fs.readFileSync(path.join(TEST_DIR, 'cursor/.cursor/skills/no-license-skill/SKILL.md'), 'utf-8'); expect(content).not.toContain('license:'); }); - test('should handle multiple commands', () => { - const commands = [ - { name: 'cmd1', description: 'Command 1', args: [], body: 'Body 1' }, - { name: 'cmd2', description: 'Command 2', args: [], body: 'Body 2' }, - { name: 'cmd3', description: 'Command 3', args: [], body: 'Body 3' } - ]; - const skills = []; - - transformCursor(commands, skills, TEST_DIR); - - expect(fs.existsSync(path.join(TEST_DIR, 'cursor/commands/cmd1.md'))).toBe(true); - expect(fs.existsSync(path.join(TEST_DIR, 'cursor/commands/cmd2.md'))).toBe(true); - expect(fs.existsSync(path.join(TEST_DIR, 'cursor/commands/cmd3.md'))).toBe(true); - }); - test('should handle multiple skills', () => { - const commands = []; const skills = [ { name: 'skill1', description: 'Skill 1', license: 'MIT', body: 'Skill body 1' }, { name: 'skill2', description: 'Skill 2', license: 'Apache', body: 'Skill body 2' } ]; - - transformCursor(commands, skills, TEST_DIR); - - expect(fs.existsSync(path.join(TEST_DIR, 'cursor/rules/skill1.md'))).toBe(true); - expect(fs.existsSync(path.join(TEST_DIR, 'cursor/rules/skill2.md'))).toBe(true); + + transformCursor(skills, TEST_DIR); + + expect(fs.existsSync(path.join(TEST_DIR, 'cursor/.cursor/skills/skill1/SKILL.md'))).toBe(true); + expect(fs.existsSync(path.join(TEST_DIR, 'cursor/.cursor/skills/skill2/SKILL.md'))).toBe(true); }); - test('should clean existing directory before writing', () => { - // Create a pre-existing file - fs.mkdirSync(path.join(TEST_DIR, 'cursor/commands'), { recursive: true }); - fs.writeFileSync(path.join(TEST_DIR, 'cursor/commands/old-file.md'), 'old content'); - - const commands = [ - { name: 'new-cmd', description: 'New', args: [], body: 'New body' } - ]; - - transformCursor(commands, [], TEST_DIR); - - expect(fs.existsSync(path.join(TEST_DIR, 'cursor/commands/old-file.md'))).toBe(false); - expect(fs.existsSync(path.join(TEST_DIR, 'cursor/commands/new-cmd.md'))).toBe(true); - }); - - test('should handle commands with placeholder args in body', () => { - const commands = [ + test('should copy reference files', () => { + const skills = [ { - name: 'normalize', - description: 'Normalize design', - args: [{ name: 'target', description: 'Target', required: false }], - body: 'Please normalize {{target}} to match the design system.' + name: 'frontend-design', + description: 'Design skill', + license: 'MIT', + body: 'Design instructions.', + references: [ + { name: 'typography', content: 'Typography reference', filePath: '/fake/path/typography.md' }, + { name: 'color', content: 'Color reference', filePath: '/fake/path/color.md' } + ] } ]; - - transformCursor(commands, [], TEST_DIR); - - const content = fs.readFileSync(path.join(TEST_DIR, 'cursor/commands/normalize.md'), 'utf-8'); - // Cursor transformer should preserve the body as-is - expect(content).toBe('Please normalize {{target}} to match the design system.'); + + transformCursor(skills, TEST_DIR); + + expect(fs.existsSync(path.join(TEST_DIR, 'cursor/.cursor/skills/frontend-design/reference/typography.md'))).toBe(true); + expect(fs.existsSync(path.join(TEST_DIR, 'cursor/.cursor/skills/frontend-design/reference/color.md'))).toBe(true); + + const typoContent = fs.readFileSync(path.join(TEST_DIR, 'cursor/.cursor/skills/frontend-design/reference/typography.md'), 'utf-8'); + expect(typoContent).toBe('Typography reference'); + }); + + test('should handle skills without references', () => { + const skills = [ + { + name: 'simple-skill', + description: 'Simple', + license: '', + body: 'Body', + references: [] + } + ]; + + transformCursor(skills, TEST_DIR); + + expect(fs.existsSync(path.join(TEST_DIR, 'cursor/.cursor/skills/simple-skill/SKILL.md'))).toBe(true); + expect(fs.existsSync(path.join(TEST_DIR, 'cursor/.cursor/skills/simple-skill/reference'))).toBe(false); }); test('should log correct summary', () => { const consoleMock = mock(() => {}); const originalLog = console.log; console.log = consoleMock; - - const commands = [{ name: 'cmd1', description: '', args: [], body: 'body1' }]; - const skills = [{ name: 'skill1', description: '', license: '', body: 'body1' }]; - - transformCursor(commands, skills, TEST_DIR); - + + const skills = [ + { name: 'skill1', description: '', license: '', userInvokable: true, body: 'body1' }, + { name: 'skill2', description: '', license: '', userInvokable: false, body: 'body2' } + ]; + + transformCursor(skills, TEST_DIR); + console.log = originalLog; - - expect(consoleMock).toHaveBeenCalledWith('✓ Cursor: 1 commands, 1 skills (downgraded)'); + + expect(consoleMock).toHaveBeenCalledWith(expect.stringContaining('✓ Cursor:')); + expect(consoleMock).toHaveBeenCalledWith(expect.stringContaining('2 skills')); + expect(consoleMock).toHaveBeenCalledWith(expect.stringContaining('1 user-invokable')); }); - test('should handle empty commands and skills arrays', () => { - transformCursor([], [], TEST_DIR); - - expect(fs.existsSync(path.join(TEST_DIR, 'cursor/commands'))).toBe(true); - expect(fs.existsSync(path.join(TEST_DIR, 'cursor/rules'))).toBe(true); - - const commandFiles = fs.readdirSync(path.join(TEST_DIR, 'cursor/commands')); - const ruleFiles = fs.readdirSync(path.join(TEST_DIR, 'cursor/rules')); - - expect(commandFiles).toHaveLength(0); - expect(ruleFiles).toHaveLength(0); + test('should handle empty skills array', () => { + transformCursor([], TEST_DIR); + + expect(fs.existsSync(path.join(TEST_DIR, 'cursor/.cursor/skills'))).toBe(true); }); test('should preserve line breaks and formatting in body', () => { - const commands = [ + const skills = [ { name: 'formatted', description: 'Test', - args: [], + license: '', body: `Line 1 Line 3 after blank line @@ -177,12 +158,88 @@ Line 3 after blank line End.` } ]; - - transformCursor(commands, [], TEST_DIR); - - const content = fs.readFileSync(path.join(TEST_DIR, 'cursor/commands/formatted.md'), 'utf-8'); + + transformCursor(skills, TEST_DIR); + + const content = fs.readFileSync(path.join(TEST_DIR, 'cursor/.cursor/skills/formatted/SKILL.md'), 'utf-8'); expect(content).toContain('Line 1\n\nLine 3'); expect(content).toContain('- Bullet 1\n- Bullet 2'); }); -}); + test('should support prefix option', () => { + const skills = [ + { name: 'audit', description: 'Audit', license: '', body: 'Audit body' } + ]; + + transformCursor(skills, TEST_DIR, null, { prefix: 'i-', outputSuffix: '-prefixed' }); + + expect(fs.existsSync(path.join(TEST_DIR, 'cursor-prefixed/.cursor/skills/i-audit/SKILL.md'))).toBe(true); + + const content = fs.readFileSync(path.join(TEST_DIR, 'cursor-prefixed/.cursor/skills/i-audit/SKILL.md'), 'utf-8'); + expect(content).toContain('name: i-audit'); + }); + + test('should replace {{model}} placeholder', () => { + const skills = [ + { + name: 'test', + description: 'Test', + license: '', + body: 'Ask {{model}} for help.' + } + ]; + + transformCursor(skills, TEST_DIR); + + const content = fs.readFileSync(path.join(TEST_DIR, 'cursor/.cursor/skills/test/SKILL.md'), 'utf-8'); + expect(content).toContain('Ask the model for help.'); + }); + + test('should replace {{config_file}} placeholder', () => { + const skills = [ + { + name: 'test', + description: 'Test', + license: '', + body: 'See {{config_file}} for more.' + } + ]; + + transformCursor(skills, TEST_DIR); + + const content = fs.readFileSync(path.join(TEST_DIR, 'cursor/.cursor/skills/test/SKILL.md'), 'utf-8'); + expect(content).toContain('See .cursorrules for more.'); + }); + + test('should replace {{ask_instruction}} placeholder', () => { + const skills = [ + { + name: 'test', + description: 'Test', + license: '', + body: 'When unsure, {{ask_instruction}}' + } + ]; + + transformCursor(skills, TEST_DIR); + + const content = fs.readFileSync(path.join(TEST_DIR, 'cursor/.cursor/skills/test/SKILL.md'), 'utf-8'); + expect(content).toContain('When unsure, ask the user directly to clarify what you cannot infer.'); + }); + + test('should clean existing directory before writing', () => { + // Create a pre-existing file structure + const existingDir = path.join(TEST_DIR, 'cursor/.cursor/skills/old-skill'); + fs.mkdirSync(existingDir, { recursive: true }); + fs.writeFileSync(path.join(existingDir, 'SKILL.md'), 'old content'); + + const skills = [ + { name: 'new-skill', description: 'New', license: '', body: 'New body' } + ]; + + transformCursor(skills, TEST_DIR); + + expect(fs.existsSync(path.join(TEST_DIR, 'cursor/.cursor/skills/old-skill/SKILL.md'))).toBe(false); + expect(fs.existsSync(path.join(TEST_DIR, 'cursor/.cursor/skills/new-skill/SKILL.md'))).toBe(true); + }); +}); diff --git a/tests/lib/transformers/gemini.test.js b/tests/lib/transformers/gemini.test.js index 03b996875..648d7a3c3 100644 --- a/tests/lib/transformers/gemini.test.js +++ b/tests/lib/transformers/gemini.test.js @@ -19,88 +19,14 @@ describe('transformGemini', () => { }); test('should create correct directory structure', () => { - const commands = []; const skills = []; - - transformGemini(commands, skills, TEST_DIR); - - expect(fs.existsSync(path.join(TEST_DIR, 'gemini/commands'))).toBe(true); - expect(fs.existsSync(path.join(TEST_DIR, 'gemini'))).toBe(true); + + transformGemini(skills, TEST_DIR); + + expect(fs.existsSync(path.join(TEST_DIR, 'gemini/.gemini/skills'))).toBe(true); }); - test('should convert command to TOML format', () => { - const commands = [ - { - name: 'test-command', - description: 'A test command', - args: [], - body: 'Command body content.' - } - ]; - - transformGemini(commands, [], TEST_DIR); - - const outputPath = path.join(TEST_DIR, 'gemini/commands/test-command.toml'); - expect(fs.existsSync(outputPath)).toBe(true); - - const content = fs.readFileSync(outputPath, 'utf-8'); - expect(content).toContain('description = "A test command"'); - expect(content).toContain('prompt = """'); - expect(content).toContain('Command body content.'); - expect(content).toContain('"""'); - }); - - test('should replace {{argname}} placeholders with {{args}}', () => { - const commands = [ - { - name: 'normalize', - description: 'Normalize design', - args: [{ name: 'target', description: 'Target', required: false }], - body: 'Please normalize {{target}} to match the design system.' - } - ]; - - transformGemini(commands, [], TEST_DIR); - - const content = fs.readFileSync(path.join(TEST_DIR, 'gemini/commands/normalize.toml'), 'utf-8'); - expect(content).toContain('{{args}}'); - expect(content).not.toContain('{{target}}'); - }); - - test('should replace multiple different placeholders with {{args}}', () => { - const commands = [ - { - name: 'multi-arg', - description: 'Multiple args', - args: [], - body: 'Process {{input}} and output to {{output}} with {{format}}.' - } - ]; - - transformGemini(commands, [], TEST_DIR); - - const content = fs.readFileSync(path.join(TEST_DIR, 'gemini/commands/multi-arg.toml'), 'utf-8'); - const argsMatches = content.match(/\{\{args\}\}/g); - expect(argsMatches).toHaveLength(3); - }); - - test('should escape quotes in description', () => { - const commands = [ - { - name: 'with-quotes', - description: 'A command with "quotes" in description', - args: [], - body: 'Body content.' - } - ]; - - transformGemini(commands, [], TEST_DIR); - - const content = fs.readFileSync(path.join(TEST_DIR, 'gemini/commands/with-quotes.toml'), 'utf-8'); - expect(content).toContain('description = "A command with \\"quotes\\" in description"'); - }); - - test('should create modular skill files', () => { + test('should create skill files with frontmatter and body in .gemini/skills/ directory', () => { const skills = [ { name: 'test-skill', @@ -109,170 +35,176 @@ describe('transformGemini', () => { body: 'Skill instructions here.' } ]; - - transformGemini([], skills, TEST_DIR); - - const outputPath = path.join(TEST_DIR, 'gemini/GEMINI.test-skill.md'); + + transformGemini(skills, TEST_DIR); + + const outputPath = path.join(TEST_DIR, 'gemini/.gemini/skills/test-skill/SKILL.md'); expect(fs.existsSync(outputPath)).toBe(true); - + const content = fs.readFileSync(outputPath, 'utf-8'); - expect(content).toBe('Skill instructions here.'); + expect(content).toContain('---'); + expect(content).toContain('name: test-skill'); + expect(content).toContain('description: A test skill'); + expect(content).toContain('Skill instructions here.'); }); - test('should create main GEMINI.md with imports', () => { + test('should handle multiple skills', () => { + const skills = [ + { name: 'skill1', description: 'Skill 1', license: 'MIT', body: 'Body 1' }, + { name: 'skill2', description: 'Skill 2', license: 'Apache', body: 'Body 2' }, + { name: 'skill3', description: 'Skill 3', license: 'MIT', body: 'Body 3' } + ]; + + transformGemini(skills, TEST_DIR); + + expect(fs.existsSync(path.join(TEST_DIR, 'gemini/.gemini/skills/skill1/SKILL.md'))).toBe(true); + expect(fs.existsSync(path.join(TEST_DIR, 'gemini/.gemini/skills/skill2/SKILL.md'))).toBe(true); + expect(fs.existsSync(path.join(TEST_DIR, 'gemini/.gemini/skills/skill3/SKILL.md'))).toBe(true); + }); + + test('should handle user-invokable skills with args', () => { + const skills = [ + { + name: 'normalize', + description: 'Normalize design', + userInvokable: true, + args: [{ name: 'target', description: 'Target', required: false }], + body: 'Please normalize {{target}} to match the design system.' + } + ]; + + transformGemini(skills, TEST_DIR); + + const content = fs.readFileSync(path.join(TEST_DIR, 'gemini/.gemini/skills/normalize/SKILL.md'), 'utf-8'); + // For user-invokable skills, {{arg}} placeholders become {{args}} + expect(content).toContain('{{args}}'); + expect(content).not.toContain('{{target}}'); + }); + + test('should replace multiple different placeholders with {{args}} for user-invokable skills', () => { + const skills = [ + { + name: 'multi-arg', + description: 'Multiple args', + userInvokable: true, + args: [], + body: 'Process {{input}} and output to {{output}} with {{format}}.' + } + ]; + + transformGemini(skills, TEST_DIR); + + const content = fs.readFileSync(path.join(TEST_DIR, 'gemini/.gemini/skills/multi-arg/SKILL.md'), 'utf-8'); + const argsMatches = content.match(/\{\{args\}\}/g); + expect(argsMatches).toHaveLength(3); + }); + + test('should not replace placeholders for non-user-invokable skills', () => { + const skills = [ + { + name: 'passive-skill', + description: 'Passive skill', + userInvokable: false, + body: 'Process {{target}} normally.' + } + ]; + + transformGemini(skills, TEST_DIR); + + const content = fs.readFileSync(path.join(TEST_DIR, 'gemini/.gemini/skills/passive-skill/SKILL.md'), 'utf-8'); + expect(content).toContain('{{target}}'); + expect(content).not.toContain('{{args}}'); + }); + + test('should copy reference files', () => { const skills = [ { name: 'frontend-design', - description: 'Create distinctive, production-grade frontend interfaces', + description: 'Design skill', license: 'MIT', - body: 'Frontend design instructions.' - }, - { - name: 'backend-api', - description: 'Design robust API endpoints', - license: 'Apache', - body: 'Backend API instructions.' + body: 'Design instructions.', + references: [ + { name: 'typography', content: 'Typography reference', filePath: '/fake/path/typography.md' } + ] } ]; - - transformGemini([], skills, TEST_DIR); - - const geminiMdPath = path.join(TEST_DIR, 'gemini/GEMINI.md'); - expect(fs.existsSync(geminiMdPath)).toBe(true); - - const content = fs.readFileSync(geminiMdPath, 'utf-8'); - expect(content).toContain('# Gemini Context'); - expect(content).toContain('frontend-design'); - expect(content).toContain('Create distinctive, production-grade frontend interfaces'); - expect(content).toContain('@./GEMINI.frontend-design.md'); - expect(content).toContain('backend-api'); - expect(content).toContain('@./GEMINI.backend-api.md'); - }); - test('should handle multiple commands', () => { - const commands = [ - { name: 'cmd1', description: 'Command 1', args: [], body: 'Body 1' }, - { name: 'cmd2', description: 'Command 2', args: [], body: 'Body 2' }, - { name: 'cmd3', description: 'Command 3', args: [], body: 'Body 3' } - ]; - - transformGemini(commands, [], TEST_DIR); - - expect(fs.existsSync(path.join(TEST_DIR, 'gemini/commands/cmd1.toml'))).toBe(true); - expect(fs.existsSync(path.join(TEST_DIR, 'gemini/commands/cmd2.toml'))).toBe(true); - expect(fs.existsSync(path.join(TEST_DIR, 'gemini/commands/cmd3.toml'))).toBe(true); - }); + transformGemini(skills, TEST_DIR); - test('should clean existing directory before writing', () => { - fs.mkdirSync(path.join(TEST_DIR, 'gemini/commands'), { recursive: true }); - fs.writeFileSync(path.join(TEST_DIR, 'gemini/commands/old.toml'), 'old'); - - const commands = [{ name: 'new', description: 'New', args: [], body: 'New' }]; - transformGemini(commands, [], TEST_DIR); - - expect(fs.existsSync(path.join(TEST_DIR, 'gemini/commands/old.toml'))).toBe(false); - expect(fs.existsSync(path.join(TEST_DIR, 'gemini/commands/new.toml'))).toBe(true); - }); + expect(fs.existsSync(path.join(TEST_DIR, 'gemini/.gemini/skills/frontend-design/reference/typography.md'))).toBe(true); - test('should preserve multiline body in TOML triple-quoted strings', () => { - const commands = [ - { - name: 'multiline', - description: 'Test', - args: [], - body: `First line. - -Second line after blank. - -- Bullet 1 -- Bullet 2` - } - ]; - - transformGemini(commands, [], TEST_DIR); - - const content = fs.readFileSync(path.join(TEST_DIR, 'gemini/commands/multiline.toml'), 'utf-8'); - expect(content).toContain('First line.\n\nSecond line'); - expect(content).toContain('- Bullet 1\n- Bullet 2'); + const typoContent = fs.readFileSync(path.join(TEST_DIR, 'gemini/.gemini/skills/frontend-design/reference/typography.md'), 'utf-8'); + expect(typoContent).toBe('Typography reference'); }); test('should log correct summary', () => { const consoleMock = mock(() => {}); const originalLog = console.log; console.log = consoleMock; - - const commands = [{ name: 'cmd1', description: 'Test', args: [], body: 'body' }]; - const skills = [{ name: 'skill1', description: 'Test', license: '', body: 'body' }]; - - transformGemini(commands, skills, TEST_DIR); - + + const skills = [ + { name: 'skill1', description: 'Test', license: '', userInvokable: true, body: 'body' }, + { name: 'skill2', description: 'Test', license: '', userInvokable: false, body: 'body' } + ]; + + transformGemini(skills, TEST_DIR); + console.log = originalLog; - - expect(consoleMock).toHaveBeenCalledWith('✓ Gemini: 1 commands (TOML), 1 skills (modular)'); + + expect(consoleMock).toHaveBeenCalledWith(expect.stringContaining('✓ Gemini:')); + expect(consoleMock).toHaveBeenCalledWith(expect.stringContaining('2 skills')); + expect(consoleMock).toHaveBeenCalledWith(expect.stringContaining('1 user-invokable')); }); test('should handle empty arrays', () => { - transformGemini([], [], TEST_DIR); - - const commandFiles = fs.readdirSync(path.join(TEST_DIR, 'gemini/commands')); - expect(commandFiles).toHaveLength(0); - - // Should still create GEMINI.md even with no skills - expect(fs.existsSync(path.join(TEST_DIR, 'gemini/GEMINI.md'))).toBe(true); + transformGemini([], TEST_DIR); + + const skillDirs = fs.readdirSync(path.join(TEST_DIR, 'gemini/.gemini/skills')); + expect(skillDirs).toHaveLength(0); }); - test('should create proper TOML structure', () => { - const commands = [ + test('should support prefix option', () => { + const skills = [ + { name: 'audit', description: 'Audit', license: '', body: 'Audit body' } + ]; + + transformGemini(skills, TEST_DIR, null, { prefix: 'i-', outputSuffix: '-prefixed' }); + + expect(fs.existsSync(path.join(TEST_DIR, 'gemini-prefixed/.gemini/skills/i-audit/SKILL.md'))).toBe(true); + + const content = fs.readFileSync(path.join(TEST_DIR, 'gemini-prefixed/.gemini/skills/i-audit/SKILL.md'), 'utf-8'); + expect(content).toContain('name: i-audit'); + }); + + test('should replace {{model}} placeholder', () => { + const skills = [ { name: 'test', - description: 'Test command', - args: [], - body: 'Test body' + description: 'Test', + license: '', + body: 'Ask {{model}} for help.' } ]; - - transformGemini(commands, [], TEST_DIR); - - const content = fs.readFileSync(path.join(TEST_DIR, 'gemini/commands/test.toml'), 'utf-8'); - - // Check for proper TOML structure - const lines = content.split('\n'); - expect(lines[0]).toMatch(/^description = /); - expect(lines[1]).toBe('prompt = """'); - expect(lines[lines.length - 1]).toBe('"""'); + + transformGemini(skills, TEST_DIR); + + const content = fs.readFileSync(path.join(TEST_DIR, 'gemini/.gemini/skills/test/SKILL.md'), 'utf-8'); + expect(content).toContain('Ask Gemini for help.'); }); - test('should handle body without placeholders', () => { - const commands = [ - { - name: 'no-placeholders', - description: 'No args', - args: [], - body: 'Just plain text without any placeholders.' - } - ]; - - transformGemini(commands, [], TEST_DIR); - - const content = fs.readFileSync(path.join(TEST_DIR, 'gemini/commands/no-placeholders.toml'), 'utf-8'); - expect(content).toContain('Just plain text without any placeholders.'); - expect(content).not.toContain('{{args}}'); - }); - - test('GEMINI.md should have proper structure', () => { + test('should replace {{config_file}} placeholder', () => { const skills = [ - { name: 'skill1', description: 'First skill', license: '', body: 'body1' } + { + name: 'test', + description: 'Test', + license: '', + body: 'See {{config_file}} for more.' + } ]; - - transformGemini([], skills, TEST_DIR); - - const content = fs.readFileSync(path.join(TEST_DIR, 'gemini/GEMINI.md'), 'utf-8'); - - expect(content).toContain('# Gemini Context'); - expect(content).toContain('## Available Skills'); - expect(content).toContain('## How Skills Work'); - expect(content).toContain('### skill1'); - expect(content).toContain('**When to use**: First skill'); + + transformGemini(skills, TEST_DIR); + + const content = fs.readFileSync(path.join(TEST_DIR, 'gemini/.gemini/skills/test/SKILL.md'), 'utf-8'); + expect(content).toContain('See GEMINI.md for more.'); }); }); - diff --git a/tests/lib/utils.test.js b/tests/lib/utils.test.js index 3a88ead9d..d4cd064ce 100644 --- a/tests/lib/utils.test.js +++ b/tests/lib/utils.test.js @@ -1,14 +1,15 @@ import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; import fs from 'fs'; import path from 'path'; -import { - parseFrontmatter, - readFilesRecursive, +import { + parseFrontmatter, + readFilesRecursive, readSourceFiles, ensureDir, cleanDir, writeFile, - generateYamlFrontmatter + generateYamlFrontmatter, + readPatterns } from '../../scripts/lib/utils.js'; // Temporary test directory @@ -17,22 +18,22 @@ const TEST_DIR = path.join(process.cwd(), 'test-tmp'); describe('parseFrontmatter', () => { test('should parse basic frontmatter with simple key-value pairs', () => { const content = `--- -name: test-command -description: A test command +name: test-skill +description: A test skill --- This is the body content.`; const result = parseFrontmatter(content); - expect(result.frontmatter.name).toBe('test-command'); - expect(result.frontmatter.description).toBe('A test command'); + expect(result.frontmatter.name).toBe('test-skill'); + expect(result.frontmatter.description).toBe('A test skill'); expect(result.body).toBe('This is the body content.'); }); test('should parse frontmatter with args array', () => { const content = `--- -name: test-command -description: A test command +name: test-skill +description: A test skill args: - name: target description: The target to normalize @@ -45,7 +46,7 @@ args: Body here.`; const result = parseFrontmatter(content); - expect(result.frontmatter.name).toBe('test-command'); + expect(result.frontmatter.name).toBe('test-skill'); expect(result.frontmatter.args).toBeArray(); expect(result.frontmatter.args).toHaveLength(2); expect(result.frontmatter.args[0].name).toBe('target'); @@ -57,7 +58,7 @@ Body here.`; test('should return empty frontmatter when no frontmatter present', () => { const content = 'Just some content without frontmatter.'; const result = parseFrontmatter(content); - + expect(result.frontmatter).toEqual({}); expect(result.body).toBe(content); }); @@ -68,7 +69,7 @@ name: test --- `; const result = parseFrontmatter(content); - + expect(result.frontmatter.name).toBe('test'); expect(result.body).toBe(''); }); @@ -85,25 +86,62 @@ Skill body.`; const result = parseFrontmatter(content); expect(result.frontmatter.license).toBe('MIT'); }); + + test('should parse user-invokable boolean', () => { + const content = `--- +name: test-skill +user-invokable: true +--- + +Body.`; + + const result = parseFrontmatter(content); + expect(result.frontmatter['user-invokable']).toBe(true); + }); + + test('should parse user-invokable as string true (code behavior)', () => { + const content = `--- +name: test-skill +user-invokable: 'true' +--- + +Body.`; + + const result = parseFrontmatter(content); + // The parseFrontmatter function doesn't strip quotes from YAML string values + expect(result.frontmatter['user-invokable']).toBe("'true'"); + }); + + test('should parse allowed-tools field', () => { + const content = `--- +name: test-skill +allowed-tools: Bash +--- + +Body.`; + + const result = parseFrontmatter(content); + expect(result.frontmatter['allowed-tools']).toBe('Bash'); + }); }); describe('generateYamlFrontmatter', () => { test('should generate basic frontmatter', () => { const data = { - name: 'test-command', + name: 'test-skill', description: 'A test' }; const result = generateYamlFrontmatter(data); expect(result).toContain('---'); - expect(result).toContain('name: test-command'); + expect(result).toContain('name: test-skill'); expect(result).toContain('description: A test'); }); test('should generate frontmatter with args array', () => { const data = { name: 'test', - description: 'Test command', + description: 'Test skill', args: [ { name: 'target', description: 'The target', required: false }, { name: 'output', description: 'Output format', required: true } @@ -118,6 +156,17 @@ describe('generateYamlFrontmatter', () => { expect(result).toContain('required: true'); }); + test('should generate frontmatter with boolean', () => { + const data = { + name: 'test', + description: 'Test', + 'user-invokable': true + }; + + const result = generateYamlFrontmatter(data); + expect(result).toContain('user-invokable: true'); + }); + test('should roundtrip: generate and parse back', () => { const original = { name: 'roundtrip-test', @@ -148,7 +197,7 @@ describe('ensureDir', () => { test('should create directory if it does not exist', () => { const testPath = path.join(TEST_DIR, 'new-dir'); ensureDir(testPath); - + expect(fs.existsSync(testPath)).toBe(true); expect(fs.statSync(testPath).isDirectory()).toBe(true); }); @@ -156,14 +205,14 @@ describe('ensureDir', () => { test('should create nested directories', () => { const testPath = path.join(TEST_DIR, 'level1', 'level2', 'level3'); ensureDir(testPath); - + expect(fs.existsSync(testPath)).toBe(true); }); test('should not throw if directory already exists', () => { const testPath = path.join(TEST_DIR, 'existing'); fs.mkdirSync(testPath, { recursive: true }); - + expect(() => ensureDir(testPath)).not.toThrow(); }); }); @@ -182,9 +231,9 @@ describe('cleanDir', () => { test('should remove directory and all contents', () => { const filePath = path.join(TEST_DIR, 'test.txt'); fs.writeFileSync(filePath, 'content'); - + expect(fs.existsSync(filePath)).toBe(true); - + cleanDir(TEST_DIR); expect(fs.existsSync(TEST_DIR)).toBe(false); }); @@ -198,7 +247,7 @@ describe('cleanDir', () => { const nestedPath = path.join(TEST_DIR, 'level1', 'level2'); ensureDir(nestedPath); fs.writeFileSync(path.join(nestedPath, 'file.txt'), 'content'); - + cleanDir(TEST_DIR); expect(fs.existsSync(TEST_DIR)).toBe(false); }); @@ -214,9 +263,9 @@ describe('writeFile', () => { test('should write file with content', () => { const filePath = path.join(TEST_DIR, 'test.txt'); const content = 'Hello, world!'; - + writeFile(filePath, content); - + expect(fs.existsSync(filePath)).toBe(true); expect(fs.readFileSync(filePath, 'utf-8')).toBe(content); }); @@ -224,7 +273,7 @@ describe('writeFile', () => { test('should create parent directories automatically', () => { const filePath = path.join(TEST_DIR, 'nested', 'deep', 'file.txt'); writeFile(filePath, 'content'); - + expect(fs.existsSync(filePath)).toBe(true); expect(fs.readFileSync(filePath, 'utf-8')).toBe('content'); }); @@ -233,7 +282,7 @@ describe('writeFile', () => { const filePath = path.join(TEST_DIR, 'file.txt'); writeFile(filePath, 'first'); writeFile(filePath, 'second'); - + expect(fs.readFileSync(filePath, 'utf-8')).toBe('second'); }); }); @@ -253,7 +302,7 @@ describe('readFilesRecursive', () => { writeFile(path.join(TEST_DIR, 'file1.md'), 'content1'); writeFile(path.join(TEST_DIR, 'file2.md'), 'content2'); writeFile(path.join(TEST_DIR, 'file3.txt'), 'not markdown'); - + const files = readFilesRecursive(TEST_DIR); expect(files).toHaveLength(2); expect(files.some(f => f.endsWith('file1.md'))).toBe(true); @@ -264,7 +313,7 @@ describe('readFilesRecursive', () => { writeFile(path.join(TEST_DIR, 'root.md'), 'root'); writeFile(path.join(TEST_DIR, 'sub', 'nested.md'), 'nested'); writeFile(path.join(TEST_DIR, 'sub', 'deep', 'deeper.md'), 'deeper'); - + const files = readFilesRecursive(TEST_DIR); expect(files).toHaveLength(3); expect(files.some(f => f.endsWith('root.md'))).toBe(true); @@ -280,7 +329,7 @@ describe('readFilesRecursive', () => { test('should return empty array for directory with no markdown files', () => { writeFile(path.join(TEST_DIR, 'file.txt'), 'text'); writeFile(path.join(TEST_DIR, 'file.js'), 'code'); - + const files = readFilesRecursive(TEST_DIR); expect(files).toEqual([]); }); @@ -299,30 +348,7 @@ describe('readSourceFiles', () => { } }); - test('should read and parse command files', () => { - const commandContent = `--- -name: test-command -description: A test command -args: - - name: target - description: Target arg - required: false ---- - -Command body content.`; - - writeFile(path.join(testRootDir, 'source/commands/test-command.md'), commandContent); - - const { commands, skills } = readSourceFiles(testRootDir); - - expect(commands).toHaveLength(1); - expect(commands[0].name).toBe('test-command'); - expect(commands[0].description).toBe('A test command'); - expect(commands[0].args).toHaveLength(1); - expect(commands[0].body).toBe('Command body content.'); - }); - - test('should read and parse skill files', () => { + test('should read and parse skill files from directory-based structure', () => { const skillContent = `--- name: test-skill description: A test skill @@ -331,10 +357,12 @@ license: MIT Skill instructions here.`; - writeFile(path.join(testRootDir, 'source/skills/test-skill.md'), skillContent); - - const { commands, skills } = readSourceFiles(testRootDir); - + const skillDir = path.join(testRootDir, 'source/skills/test-skill'); + ensureDir(skillDir); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), skillContent); + + const { skills } = readSourceFiles(testRootDir); + expect(skills).toHaveLength(1); expect(skills[0].name).toBe('test-skill'); expect(skills[0].description).toBe('A test skill'); @@ -342,34 +370,229 @@ Skill instructions here.`; expect(skills[0].body).toBe('Skill instructions here.'); }); + test('should read skill with user-invokable flag', () => { + const skillContent = `--- +name: audit +description: Run technical quality checks +user-invokable: true +--- + +Audit the code.`; + + const skillDir = path.join(testRootDir, 'source/skills/audit'); + ensureDir(skillDir); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), skillContent); + + const { skills } = readSourceFiles(testRootDir); + + expect(skills).toHaveLength(1); + expect(skills[0].userInvokable).toBe(true); + }); + + test('should read skill with reference files', () => { + const skillContent = `--- +name: frontend-design +description: Frontend design skill +--- + +Frontend design instructions.`; + + const skillDir = path.join(testRootDir, 'source/skills/frontend-design'); + ensureDir(skillDir); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), skillContent); + + const refDir = path.join(skillDir, 'reference'); + ensureDir(refDir); + fs.writeFileSync(path.join(refDir, 'typography.md'), 'Typography reference content.'); + fs.writeFileSync(path.join(refDir, 'color.md'), 'Color reference content.'); + + const { skills } = readSourceFiles(testRootDir); + + expect(skills).toHaveLength(1); + expect(skills[0].references).toHaveLength(2); + // References may not be in a specific order due to fs.readdirSync + const refNames = skills[0].references.map(r => r.name).sort(); + expect(refNames).toEqual(['color', 'typography']); + }); + test('should use filename as name if not in frontmatter', () => { - writeFile(path.join(testRootDir, 'source/commands/my-command.md'), 'Just body, no frontmatter.'); - - const { commands } = readSourceFiles(testRootDir); - - expect(commands[0].name).toBe('my-command'); + const skillDir = path.join(testRootDir, 'source/skills/my-skill'); + ensureDir(skillDir); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), 'Just body, no frontmatter.'); + + const { skills } = readSourceFiles(testRootDir); + + expect(skills).toHaveLength(1); + expect(skills[0].name).toBe('my-skill'); }); test('should handle empty source directories', () => { - ensureDir(path.join(testRootDir, 'source/commands')); ensureDir(path.join(testRootDir, 'source/skills')); - - const { commands, skills } = readSourceFiles(testRootDir); - - expect(commands).toEqual([]); + + const { skills } = readSourceFiles(testRootDir); + expect(skills).toEqual([]); }); - test('should read multiple commands and skills', () => { - writeFile(path.join(testRootDir, 'source/commands/cmd1.md'), '---\nname: cmd1\n---\nBody1'); - writeFile(path.join(testRootDir, 'source/commands/cmd2.md'), '---\nname: cmd2\n---\nBody2'); - writeFile(path.join(testRootDir, 'source/skills/skill1.md'), '---\nname: skill1\n---\nSkill1'); - writeFile(path.join(testRootDir, 'source/skills/skill2.md'), '---\nname: skill2\n---\nSkill2'); - - const { commands, skills } = readSourceFiles(testRootDir); - - expect(commands).toHaveLength(2); + test('should read multiple skills', () => { + const skill1Dir = path.join(testRootDir, 'source/skills/skill1'); + ensureDir(skill1Dir); + fs.writeFileSync(path.join(skill1Dir, 'SKILL.md'), '---\nname: skill1\n---\nSkill1'); + + const skill2Dir = path.join(testRootDir, 'source/skills/skill2'); + ensureDir(skill2Dir); + fs.writeFileSync(path.join(skill2Dir, 'SKILL.md'), '---\nname: skill2\n---\nSkill2'); + + const { skills } = readSourceFiles(testRootDir); + expect(skills).toHaveLength(2); }); + + test('should ignore non-md files in skill directories', () => { + const skillDir = path.join(testRootDir, 'source/skills/test-skill'); + ensureDir(skillDir); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '---\nname: test-skill\n---\nBody'); + + const refDir = path.join(skillDir, 'reference'); + ensureDir(refDir); + fs.writeFileSync(path.join(refDir, 'readme.txt'), 'Not a markdown file'); + fs.writeFileSync(path.join(refDir, 'typography.md'), 'Valid reference'); + + const { skills } = readSourceFiles(testRootDir); + + expect(skills).toHaveLength(1); + expect(skills[0].references).toHaveLength(1); + expect(skills[0].references[0].name).toBe('typography'); + }); + + test('should handle missing skills directory', () => { + const { skills } = readSourceFiles(testRootDir); + expect(skills).toEqual([]); + }); + + test('should parse all frontmatter fields correctly', () => { + const skillContent = `--- +name: test-skill +description: A comprehensive test skill +license: Apache-2.0 +compatibility: claude-code +user-invokable: true +allowed-tools: Bash,Edit +--- + +Body content.`; + + const skillDir = path.join(testRootDir, 'source/skills/test-skill'); + ensureDir(skillDir); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), skillContent); + + const { skills } = readSourceFiles(testRootDir); + + expect(skills[0].name).toBe('test-skill'); + expect(skills[0].description).toBe('A comprehensive test skill'); + expect(skills[0].license).toBe('Apache-2.0'); + expect(skills[0].compatibility).toBe('claude-code'); + expect(skills[0].userInvokable).toBe(true); + expect(skills[0].allowedTools).toBe('Bash,Edit'); + }); }); +describe('readPatterns', () => { + const testRootDir = TEST_DIR; + + beforeEach(() => { + ensureDir(testRootDir); + }); + + afterEach(() => { + if (fs.existsSync(testRootDir)) { + fs.rmSync(testRootDir, { recursive: true, force: true }); + } + }); + + test('should extract DO and DON\'T patterns from SKILL.md', () => { + const skillContent = `--- +name: frontend-design +--- + +### Typography +**DO**: Use variable fonts for flexibility. +**DON'T**: Use system fonts like Arial. + +### Color & Contrast +**DO**: Ensure WCAG AA compliance. +**DON'T**: Use gray text on colored backgrounds. + +### Layout & Space +**DO**: Use consistent spacing scale. +**DON'T**: Nest cards inside cards.`; + + const skillDir = path.join(testRootDir, 'source/skills/frontend-design'); + ensureDir(skillDir); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), skillContent); + + const { patterns, antipatterns } = readPatterns(testRootDir); + + expect(patterns).toHaveLength(3); + expect(antipatterns).toHaveLength(3); + + expect(patterns[0].name).toBe('Typography'); + expect(patterns[0].items).toContain('Use variable fonts for flexibility.'); + expect(antipatterns[0].items).toContain('Use system fonts like Arial.'); + }); + + test('should normalize "Color & Theme" to "Color & Contrast"', () => { + const skillContent = `--- +name: frontend-design +--- + +### Color & Theme +**DO**: Use OKLCH color space. +**DON'T**: Use pure black.`; + + const skillDir = path.join(testRootDir, 'source/skills/frontend-design'); + ensureDir(skillDir); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), skillContent); + + const { patterns, antipatterns } = readPatterns(testRootDir); + + expect(patterns[0].name).toBe('Color & Contrast'); + }); + + test('should handle missing SKILL.md file', () => { + ensureDir(path.join(testRootDir, 'source/skills/frontend-design')); + + const { patterns, antipatterns } = readPatterns(testRootDir); + + expect(patterns).toEqual([]); + expect(antipatterns).toEqual([]); + }); + + test('should return patterns in consistent section order', () => { + const skillContent = `--- +name: frontend-design +--- + +### Motion +**DO**: Use ease-out for natural movement. + +### Typography +**DO**: Use modular scale. + +### Color & Contrast +**DO**: Use tinted neutrals.`; + + const skillDir = path.join(testRootDir, 'source/skills/frontend-design'); + ensureDir(skillDir); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), skillContent); + + const { patterns } = readPatterns(testRootDir); + + // Patterns are returned in predefined section order, not source order + // Only sections with content are included + expect(patterns[0].name).toBe('Typography'); + expect(patterns[1].name).toBe('Color & Contrast'); + expect(patterns[2].name).toBe('Motion'); + expect(patterns.length).toBe(3); + }); +});