diff --git a/scripts/lib/transformers/kiro.js b/scripts/lib/transformers/kiro.js index 620db85b5..4d20ca728 100644 --- a/scripts/lib/transformers/kiro.js +++ b/scripts/lib/transformers/kiro.js @@ -51,7 +51,7 @@ export function transformKiro(skills, distDir, patterns = null, options = {}) { ensureDir(refDir); for (const ref of skill.references) { const refOutputPath = path.join(refDir, `${ref.name}.md`); - const refContent = replacePlaceholders(ref.content, 'kiro', commandNames); + const refContent = replacePlaceholders(ref.content, 'kiro'); writeFile(refOutputPath, refContent); refCount++; } diff --git a/tests/lib/transformers/agents.test.js b/tests/lib/transformers/agents.test.js new file mode 100644 index 000000000..d43273eed --- /dev/null +++ b/tests/lib/transformers/agents.test.js @@ -0,0 +1,330 @@ +import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test'; +import fs from 'fs'; +import path from 'path'; +import { transformAgents } from '../../../scripts/lib/transformers/agents.js'; +import { parseFrontmatter } from '../../../scripts/lib/utils.js'; + +const TEST_DIR = path.join(process.cwd(), 'test-tmp-agents'); + +describe('transformAgents', () => { + beforeEach(() => { + if (fs.existsSync(TEST_DIR)) { + fs.rmSync(TEST_DIR, { recursive: true, force: true }); + } + }); + + afterEach(() => { + if (fs.existsSync(TEST_DIR)) { + fs.rmSync(TEST_DIR, { recursive: true, force: true }); + } + }); + + test('should create correct directory structure', () => { + transformAgents([], TEST_DIR); + expect(fs.existsSync(path.join(TEST_DIR, 'agents/.agents/skills'))).toBe(true); + }); + + test('should create skill with full frontmatter', () => { + const skills = [ + { + name: 'test-skill', + description: 'A test skill', + license: 'MIT', + body: 'Skill instructions.' + } + ]; + + transformAgents(skills, TEST_DIR); + + const outputPath = path.join(TEST_DIR, 'agents/.agents/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.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.' + } + ]; + + transformAgents(skills, TEST_DIR); + + const content = fs.readFileSync(path.join(TEST_DIR, 'agents/.agents/skills/audit/SKILL.md'), 'utf-8'); + const parsed = parseFrontmatter(content); + + expect(parsed.frontmatter['user-invokable']).toBe(true); + }); + + test('should not add user-invokable flag for non-user-invokable skills', () => { + const skills = [ + { + name: 'helper', + description: 'Helper skill', + body: 'Helper body.' + } + ]; + + transformAgents(skills, TEST_DIR); + + const content = fs.readFileSync(path.join(TEST_DIR, 'agents/.agents/skills/helper/SKILL.md'), 'utf-8'); + const parsed = parseFrontmatter(content); + + expect(parsed.frontmatter['user-invokable']).toBeUndefined(); + }); + + 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: 'format', description: 'Format', required: false } + ], + body: 'Process {{target}} in {{format}}.' + } + ]; + + transformAgents(skills, TEST_DIR); + + const content = fs.readFileSync(path.join(TEST_DIR, 'agents/.agents/skills/with-args/SKILL.md'), 'utf-8'); + const parsed = parseFrontmatter(content); + + expect(parsed.frontmatter['argument-hint']).toBe(' [FORMAT=]'); + }); + + test('should not add argument-hint for skills without args', () => { + const skills = [ + { + name: 'no-args', + description: 'No args', + userInvokable: true, + args: [], + body: 'Simple body.' + } + ]; + + transformAgents(skills, TEST_DIR); + + const content = fs.readFileSync(path.join(TEST_DIR, 'agents/.agents/skills/no-args/SKILL.md'), 'utf-8'); + const parsed = parseFrontmatter(content); + + expect(parsed.frontmatter['argument-hint']).toBeUndefined(); + }); + + test('should not add argument-hint for non-user-invokable skills with args', () => { + const skills = [ + { + name: 'internal', + description: 'Internal skill', + userInvokable: false, + args: [{ name: 'target', description: 'Target', required: true }], + body: 'Body.' + } + ]; + + transformAgents(skills, TEST_DIR); + + const content = fs.readFileSync(path.join(TEST_DIR, 'agents/.agents/skills/internal/SKILL.md'), 'utf-8'); + const parsed = parseFrontmatter(content); + + expect(parsed.frontmatter['argument-hint']).toBeUndefined(); + }); + + test('should handle multiple skills', () => { + const skills = [ + { name: 'skill1', description: 'Skill 1', body: 'Body 1' }, + { name: 'skill2', description: 'Skill 2', body: 'Body 2' }, + { name: 'skill3', description: 'Skill 3', body: 'Body 3' } + ]; + + transformAgents(skills, TEST_DIR); + + expect(fs.existsSync(path.join(TEST_DIR, 'agents/.agents/skills/skill1/SKILL.md'))).toBe(true); + expect(fs.existsSync(path.join(TEST_DIR, 'agents/.agents/skills/skill2/SKILL.md'))).toBe(true); + expect(fs.existsSync(path.join(TEST_DIR, 'agents/.agents/skills/skill3/SKILL.md'))).toBe(true); + }); + + test('should replace {{model}} placeholder', () => { + const skills = [ + { + name: 'test', + description: 'Test', + body: 'Ask {{model}} for help.' + } + ]; + + transformAgents(skills, TEST_DIR); + + const content = fs.readFileSync(path.join(TEST_DIR, 'agents/.agents/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', + body: 'See {{config_file}} for details.' + } + ]; + + transformAgents(skills, TEST_DIR); + + const content = fs.readFileSync(path.join(TEST_DIR, 'agents/.agents/skills/test/SKILL.md'), 'utf-8'); + expect(content).toContain('See .github/copilot-instructions.md for details.'); + }); + + test('should replace {{available_commands}} placeholder', () => { + const skills = [ + { name: 'audit', description: 'Audit', userInvokable: true, body: 'Available: {{available_commands}}' }, + { name: 'polish', description: 'Polish', userInvokable: true, body: 'Polish body.' } + ]; + + transformAgents(skills, TEST_DIR); + + const content = fs.readFileSync(path.join(TEST_DIR, 'agents/.agents/skills/audit/SKILL.md'), 'utf-8'); + expect(content).toContain('Available: /audit, /polish'); + }); + + test('should copy reference files', () => { + const skills = [ + { + name: 'frontend-design', + description: 'Design skill', + body: 'Design instructions.', + references: [ + { name: 'typography', content: 'Typography reference', filePath: '/fake/path/typography.md' }, + { name: 'color', content: 'Color reference', filePath: '/fake/path/color.md' } + ] + } + ]; + + transformAgents(skills, TEST_DIR); + + const typoPath = path.join(TEST_DIR, 'agents/.agents/skills/frontend-design/reference/typography.md'); + const colorPath = path.join(TEST_DIR, 'agents/.agents/skills/frontend-design/reference/color.md'); + + expect(fs.existsSync(typoPath)).toBe(true); + expect(fs.existsSync(colorPath)).toBe(true); + expect(fs.readFileSync(typoPath, 'utf-8')).toBe('Typography reference'); + }); + + test('should replace placeholders in reference files', () => { + const skills = [ + { + name: 'test', + description: 'Test', + body: 'Body.', + references: [ + { name: 'ref', content: 'Use {{model}} with {{config_file}}.', filePath: '/fake/ref.md' } + ] + } + ]; + + transformAgents(skills, TEST_DIR); + + const refContent = fs.readFileSync(path.join(TEST_DIR, 'agents/.agents/skills/test/reference/ref.md'), 'utf-8'); + expect(refContent).toContain('Use the model with .github/copilot-instructions.md.'); + }); + + test('should support prefix option', () => { + const skills = [ + { name: 'audit', description: 'Audit', userInvokable: true, body: 'Audit body' } + ]; + + transformAgents(skills, TEST_DIR, null, { prefix: 'i-', outputSuffix: '-prefixed' }); + + const outputPath = path.join(TEST_DIR, 'agents-prefixed/.agents/skills/i-audit/SKILL.md'); + expect(fs.existsSync(outputPath)).toBe(true); + + const content = fs.readFileSync(outputPath, 'utf-8'); + expect(content).toContain('name: i-audit'); + }); + + test('should prefix skill references in body when prefix is set', () => { + const skills = [ + { name: 'audit', description: 'Audit', userInvokable: true, body: 'Run /polish after the audit skill.' }, + { name: 'polish', description: 'Polish', userInvokable: true, body: 'Polish body.' } + ]; + + transformAgents(skills, TEST_DIR, null, { prefix: 'i-', outputSuffix: '-prefixed' }); + + const content = fs.readFileSync(path.join(TEST_DIR, 'agents-prefixed/.agents/skills/i-audit/SKILL.md'), 'utf-8'); + expect(content).toContain('/i-polish'); + expect(content).toContain('the i-audit skill'); + }); + + test('should clean existing directory before writing', () => { + const existingDir = path.join(TEST_DIR, 'agents/.agents/skills/old'); + fs.mkdirSync(existingDir, { recursive: true }); + fs.writeFileSync(path.join(existingDir, 'SKILL.md'), 'old'); + + const skills = [{ name: 'new', description: 'New', body: 'New' }]; + transformAgents(skills, TEST_DIR); + + expect(fs.existsSync(path.join(TEST_DIR, 'agents/.agents/skills/old/SKILL.md'))).toBe(false); + expect(fs.existsSync(path.join(TEST_DIR, 'agents/.agents/skills/new/SKILL.md'))).toBe(true); + }); + + test('should log correct summary', () => { + const consoleMock = mock(() => {}); + const originalLog = console.log; + console.log = consoleMock; + + const skills = [ + { name: 'skill1', description: 'Test', userInvokable: true, body: 'body' }, + { name: 'skill2', description: 'Test', userInvokable: false, body: 'body' } + ]; + + transformAgents(skills, TEST_DIR); + + console.log = originalLog; + + expect(consoleMock).toHaveBeenCalledWith(expect.stringContaining('✓ Agents:')); + expect(consoleMock).toHaveBeenCalledWith(expect.stringContaining('2 skills')); + expect(consoleMock).toHaveBeenCalledWith(expect.stringContaining('1 user-invokable')); + }); + + test('should log reference file count', () => { + const consoleMock = mock(() => {}); + const originalLog = console.log; + console.log = consoleMock; + + const skills = [ + { + name: 'test', + description: 'Test', + body: 'Body.', + references: [ + { name: 'ref1', content: 'Ref 1', filePath: '/fake/ref1.md' }, + { name: 'ref2', content: 'Ref 2', filePath: '/fake/ref2.md' } + ] + } + ]; + + transformAgents(skills, TEST_DIR); + + console.log = originalLog; + + expect(consoleMock).toHaveBeenCalledWith(expect.stringContaining('2 reference files')); + }); + + test('should handle empty skills array', () => { + transformAgents([], TEST_DIR); + + const skillDirs = fs.readdirSync(path.join(TEST_DIR, 'agents/.agents/skills')); + expect(skillDirs).toHaveLength(0); + }); +}); diff --git a/tests/lib/transformers/kiro.test.js b/tests/lib/transformers/kiro.test.js new file mode 100644 index 000000000..060089aef --- /dev/null +++ b/tests/lib/transformers/kiro.test.js @@ -0,0 +1,302 @@ +import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test'; +import fs from 'fs'; +import path from 'path'; +import { transformKiro } from '../../../scripts/lib/transformers/kiro.js'; +import { parseFrontmatter } from '../../../scripts/lib/utils.js'; + +const TEST_DIR = path.join(process.cwd(), 'test-tmp-kiro'); + +describe('transformKiro', () => { + beforeEach(() => { + if (fs.existsSync(TEST_DIR)) { + fs.rmSync(TEST_DIR, { recursive: true, force: true }); + } + }); + + afterEach(() => { + if (fs.existsSync(TEST_DIR)) { + fs.rmSync(TEST_DIR, { recursive: true, force: true }); + } + }); + + test('should create correct directory structure', () => { + transformKiro([], TEST_DIR); + expect(fs.existsSync(path.join(TEST_DIR, 'kiro/.kiro/skills'))).toBe(true); + }); + + test('should create skill with full frontmatter', () => { + const skills = [ + { + name: 'test-skill', + description: 'A test skill', + license: 'MIT', + body: 'Skill instructions.' + } + ]; + + transformKiro(skills, TEST_DIR); + + const outputPath = path.join(TEST_DIR, 'kiro/.kiro/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 include compatibility in frontmatter', () => { + const skills = [ + { + name: 'test', + description: 'Test', + compatibility: 'kiro', + body: 'Body' + } + ]; + + transformKiro(skills, TEST_DIR); + + const content = fs.readFileSync(path.join(TEST_DIR, 'kiro/.kiro/skills/test/SKILL.md'), 'utf-8'); + expect(content).toContain('compatibility: kiro'); + }); + + test('should include metadata in frontmatter', () => { + const skills = [ + { + name: 'test', + description: 'Test', + metadata: 'some-metadata', + body: 'Body' + } + ]; + + transformKiro(skills, TEST_DIR); + + const content = fs.readFileSync(path.join(TEST_DIR, 'kiro/.kiro/skills/test/SKILL.md'), 'utf-8'); + expect(content).toContain('metadata: some-metadata'); + }); + + test('should not include user-invokable in frontmatter (Kiro does not use it)', () => { + const skills = [ + { + name: 'test', + description: 'Test', + userInvokable: true, + body: 'Body' + } + ]; + + transformKiro(skills, TEST_DIR); + + const content = fs.readFileSync(path.join(TEST_DIR, 'kiro/.kiro/skills/test/SKILL.md'), 'utf-8'); + expect(content).not.toContain('user-invokable'); + }); + + test('should handle multiple skills', () => { + const skills = [ + { name: 'skill1', description: 'Skill 1', body: 'Body 1' }, + { name: 'skill2', description: 'Skill 2', body: 'Body 2' }, + { name: 'skill3', description: 'Skill 3', body: 'Body 3' } + ]; + + transformKiro(skills, TEST_DIR); + + expect(fs.existsSync(path.join(TEST_DIR, 'kiro/.kiro/skills/skill1/SKILL.md'))).toBe(true); + expect(fs.existsSync(path.join(TEST_DIR, 'kiro/.kiro/skills/skill2/SKILL.md'))).toBe(true); + expect(fs.existsSync(path.join(TEST_DIR, 'kiro/.kiro/skills/skill3/SKILL.md'))).toBe(true); + }); + + test('should replace {{model}} placeholder', () => { + const skills = [ + { + name: 'test', + description: 'Test', + body: 'Ask {{model}} for help.' + } + ]; + + transformKiro(skills, TEST_DIR); + + const content = fs.readFileSync(path.join(TEST_DIR, 'kiro/.kiro/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', + body: 'See {{config_file}} for details.' + } + ]; + + transformKiro(skills, TEST_DIR); + + const content = fs.readFileSync(path.join(TEST_DIR, 'kiro/.kiro/skills/test/SKILL.md'), 'utf-8'); + expect(content).toContain('See .kiro/settings.json for details.'); + }); + + test('should replace {{available_commands}} placeholder', () => { + const skills = [ + { name: 'audit', description: 'Audit', userInvokable: true, body: 'Available: {{available_commands}}' }, + { name: 'polish', description: 'Polish', userInvokable: true, body: 'Polish body.' } + ]; + + transformKiro(skills, TEST_DIR); + + const content = fs.readFileSync(path.join(TEST_DIR, 'kiro/.kiro/skills/audit/SKILL.md'), 'utf-8'); + expect(content).toContain('Available: /audit, /polish'); + }); + + test('should copy reference files', () => { + const skills = [ + { + name: 'frontend-design', + description: 'Design skill', + body: 'Design instructions.', + references: [ + { name: 'typography', content: 'Typography reference', filePath: '/fake/path/typography.md' }, + { name: 'color', content: 'Color reference', filePath: '/fake/path/color.md' } + ] + } + ]; + + transformKiro(skills, TEST_DIR); + + const typoPath = path.join(TEST_DIR, 'kiro/.kiro/skills/frontend-design/reference/typography.md'); + const colorPath = path.join(TEST_DIR, 'kiro/.kiro/skills/frontend-design/reference/color.md'); + + expect(fs.existsSync(typoPath)).toBe(true); + expect(fs.existsSync(colorPath)).toBe(true); + expect(fs.readFileSync(typoPath, 'utf-8')).toBe('Typography reference'); + }); + + test('should replace placeholders in reference files without commandNames', () => { + const skills = [ + { + name: 'test', + description: 'Test', + userInvokable: true, + body: 'Body with {{available_commands}}.', + references: [ + { name: 'ref', content: 'Use {{model}} with {{config_file}}. Commands: {{available_commands}}.', filePath: '/fake/ref.md' } + ] + } + ]; + + transformKiro(skills, TEST_DIR); + + const refContent = fs.readFileSync(path.join(TEST_DIR, 'kiro/.kiro/skills/test/reference/ref.md'), 'utf-8'); + expect(refContent).toContain('Use Claude with .kiro/settings.json.'); + // Reference files should NOT get commandNames, so {{available_commands}} becomes empty string + expect(refContent).toContain('Commands: .'); + }); + + test('should support prefix option', () => { + const skills = [ + { name: 'audit', description: 'Audit', userInvokable: true, body: 'Audit body' } + ]; + + transformKiro(skills, TEST_DIR, null, { prefix: 'i-', outputSuffix: '-prefixed' }); + + const outputPath = path.join(TEST_DIR, 'kiro-prefixed/.kiro/skills/i-audit/SKILL.md'); + expect(fs.existsSync(outputPath)).toBe(true); + + const content = fs.readFileSync(outputPath, 'utf-8'); + expect(content).toContain('name: i-audit'); + }); + + test('should prefix skill references in body when prefix is set', () => { + const skills = [ + { name: 'audit', description: 'Audit', userInvokable: true, body: 'Run /polish after the audit skill.' }, + { name: 'polish', description: 'Polish', userInvokable: true, body: 'Polish body.' } + ]; + + transformKiro(skills, TEST_DIR, null, { prefix: 'i-', outputSuffix: '-prefixed' }); + + const content = fs.readFileSync(path.join(TEST_DIR, 'kiro-prefixed/.kiro/skills/i-audit/SKILL.md'), 'utf-8'); + expect(content).toContain('/i-polish'); + expect(content).toContain('the i-audit skill'); + }); + + test('should clean existing directory before writing', () => { + const existingDir = path.join(TEST_DIR, 'kiro/.kiro/skills/old'); + fs.mkdirSync(existingDir, { recursive: true }); + fs.writeFileSync(path.join(existingDir, 'SKILL.md'), 'old'); + + const skills = [{ name: 'new', description: 'New', body: 'New' }]; + transformKiro(skills, TEST_DIR); + + expect(fs.existsSync(path.join(TEST_DIR, 'kiro/.kiro/skills/old/SKILL.md'))).toBe(false); + expect(fs.existsSync(path.join(TEST_DIR, 'kiro/.kiro/skills/new/SKILL.md'))).toBe(true); + }); + + test('should log correct summary', () => { + const consoleMock = mock(() => {}); + const originalLog = console.log; + console.log = consoleMock; + + const skills = [ + { name: 'skill1', description: 'Test', userInvokable: true, body: 'body' }, + { name: 'skill2', description: 'Test', userInvokable: false, body: 'body' } + ]; + + transformKiro(skills, TEST_DIR); + + console.log = originalLog; + + expect(consoleMock).toHaveBeenCalledWith(expect.stringContaining('✓ Kiro:')); + expect(consoleMock).toHaveBeenCalledWith(expect.stringContaining('2 skills')); + expect(consoleMock).toHaveBeenCalledWith(expect.stringContaining('1 user-invokable')); + }); + + test('should log reference file count', () => { + const consoleMock = mock(() => {}); + const originalLog = console.log; + console.log = consoleMock; + + const skills = [ + { + name: 'test', + description: 'Test', + body: 'Body.', + references: [ + { name: 'ref1', content: 'Ref 1', filePath: '/fake/ref1.md' } + ] + } + ]; + + transformKiro(skills, TEST_DIR); + + console.log = originalLog; + + expect(consoleMock).toHaveBeenCalledWith(expect.stringContaining('1 reference files')); + }); + + test('should handle empty skills array', () => { + transformKiro([], TEST_DIR); + + const skillDirs = fs.readdirSync(path.join(TEST_DIR, 'kiro/.kiro/skills')); + expect(skillDirs).toHaveLength(0); + }); + + test('should not include license if empty', () => { + const skills = [ + { + name: 'test', + description: 'Test', + license: '', + body: 'Body' + } + ]; + + transformKiro(skills, TEST_DIR); + + const content = fs.readFileSync(path.join(TEST_DIR, 'kiro/.kiro/skills/test/SKILL.md'), 'utf-8'); + expect(content).not.toContain('license:'); + }); +}); diff --git a/tests/lib/utils.test.js b/tests/lib/utils.test.js index d4cd064ce..ba8739d3e 100644 --- a/tests/lib/utils.test.js +++ b/tests/lib/utils.test.js @@ -9,7 +9,9 @@ import { cleanDir, writeFile, generateYamlFrontmatter, - readPatterns + readPatterns, + replacePlaceholders, + prefixSkillReferences } from '../../scripts/lib/utils.js'; // Temporary test directory @@ -596,3 +598,123 @@ name: frontend-design expect(patterns.length).toBe(3); }); }); + +describe('replacePlaceholders', () => { + test('should replace {{model}} with provider-specific value', () => { + expect(replacePlaceholders('Ask {{model}} for help.', 'claude-code')).toBe('Ask Claude for help.'); + expect(replacePlaceholders('Ask {{model}} for help.', 'gemini')).toBe('Ask Gemini for help.'); + expect(replacePlaceholders('Ask {{model}} for help.', 'codex')).toBe('Ask GPT for help.'); + expect(replacePlaceholders('Ask {{model}} for help.', 'cursor')).toBe('Ask the model for help.'); + expect(replacePlaceholders('Ask {{model}} for help.', 'agents')).toBe('Ask the model for help.'); + expect(replacePlaceholders('Ask {{model}} for help.', 'kiro')).toBe('Ask Claude for help.'); + }); + + test('should replace {{config_file}} with provider-specific value', () => { + expect(replacePlaceholders('See {{config_file}}.', 'claude-code')).toBe('See CLAUDE.md.'); + expect(replacePlaceholders('See {{config_file}}.', 'cursor')).toBe('See .cursorrules.'); + expect(replacePlaceholders('See {{config_file}}.', 'gemini')).toBe('See GEMINI.md.'); + expect(replacePlaceholders('See {{config_file}}.', 'codex')).toBe('See AGENTS.md.'); + expect(replacePlaceholders('See {{config_file}}.', 'agents')).toBe('See .github/copilot-instructions.md.'); + expect(replacePlaceholders('See {{config_file}}.', 'kiro')).toBe('See .kiro/settings.json.'); + }); + + test('should replace {{ask_instruction}} with provider-specific value', () => { + const result = replacePlaceholders('{{ask_instruction}}', 'claude-code'); + expect(result).toBe('STOP and call the AskUserQuestionTool to clarify.'); + + const cursorResult = replacePlaceholders('{{ask_instruction}}', 'cursor'); + expect(cursorResult).toBe('ask the user directly to clarify what you cannot infer.'); + }); + + test('should replace {{available_commands}} with command list', () => { + const result = replacePlaceholders('Commands: {{available_commands}}', 'claude-code', ['audit', 'polish', 'optimize']); + expect(result).toBe('Commands: /audit, /polish, /optimize'); + }); + + test('should exclude teach-impeccable from {{available_commands}}', () => { + const result = replacePlaceholders('Commands: {{available_commands}}', 'claude-code', ['audit', 'teach-impeccable', 'polish']); + expect(result).toBe('Commands: /audit, /polish'); + }); + + test('should exclude i-teach-impeccable from {{available_commands}}', () => { + const result = replacePlaceholders('Commands: {{available_commands}}', 'claude-code', ['i-audit', 'i-teach-impeccable', 'i-polish']); + expect(result).toBe('Commands: /i-audit, /i-polish'); + }); + + test('should produce empty string for {{available_commands}} with no commands', () => { + const result = replacePlaceholders('Commands: {{available_commands}}.', 'claude-code', []); + expect(result).toBe('Commands: .'); + }); + + test('should replace multiple placeholders in the same string', () => { + const result = replacePlaceholders('{{model}} uses {{config_file}} and {{ask_instruction}}', 'claude-code'); + expect(result).toBe('Claude uses CLAUDE.md and STOP and call the AskUserQuestionTool to clarify.'); + }); + + test('should replace multiple occurrences of the same placeholder', () => { + const result = replacePlaceholders('{{model}} and {{model}} again.', 'gemini'); + expect(result).toBe('Gemini and Gemini again.'); + }); + + test('should fall back to cursor placeholders for unknown provider', () => { + const result = replacePlaceholders('{{model}} {{config_file}}', 'unknown-provider'); + expect(result).toBe('the model .cursorrules'); + }); +}); + +describe('prefixSkillReferences', () => { + test('should prefix /skillname command references', () => { + const result = prefixSkillReferences('Run /audit to check.', 'i-', ['audit', 'polish']); + expect(result).toBe('Run /i-audit to check.'); + }); + + test('should prefix "the skillname skill" references', () => { + const result = prefixSkillReferences('Use the audit skill for checks.', 'i-', ['audit', 'polish']); + expect(result).toBe('Use the i-audit skill for checks.'); + }); + + test('should prefix multiple different references', () => { + const result = prefixSkillReferences('Run /audit then /polish. The audit skill is great.', 'i-', ['audit', 'polish']); + expect(result).toContain('/i-audit'); + expect(result).toContain('/i-polish'); + expect(result).toContain('the i-audit skill'); + }); + + test('should not partially match longer skill names', () => { + const result = prefixSkillReferences('Run /teach-impeccable command.', 'i-', ['teach', 'teach-impeccable']); + expect(result).toBe('Run /i-teach-impeccable command.'); + }); + + test('should handle case-insensitive "the X skill" matching', () => { + const result = prefixSkillReferences('The audit skill is useful.', 'i-', ['audit']); + // The regex replaces case-insensitively, so "The" becomes "the" in the replacement + expect(result).toBe('the i-audit skill is useful.'); + }); + + test('should return content unchanged with empty prefix', () => { + const result = prefixSkillReferences('Run /audit.', '', ['audit']); + expect(result).toBe('Run /audit.'); + }); + + test('should return content unchanged with empty skill names', () => { + const result = prefixSkillReferences('Run /audit.', 'i-', []); + expect(result).toBe('Run /audit.'); + }); + + test('should not match /skillname inside longer words', () => { + const result = prefixSkillReferences('The /auditing process.', 'i-', ['audit']); + // 'auditing' starts with 'audit' but has trailing letters — should NOT match + expect(result).toBe('The /auditing process.'); + }); + + test('should match /skillname at end of string', () => { + const result = prefixSkillReferences('Run /audit', 'i-', ['audit']); + expect(result).toBe('Run /i-audit'); + }); + + test('should match /skillname before punctuation', () => { + const result = prefixSkillReferences('Try /audit, /polish.', 'i-', ['audit', 'polish']); + expect(result).toContain('/i-audit,'); + expect(result).toContain('/i-polish.'); + }); +});