mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-16 16:16:32 +03:00
Merge main into v2.0: consolidate critique skill with scoring, personas, and detection
Merges 54 commits from main including factory-based build system, Trae support, improved skill descriptions, and security hardening. Consolidates the critique skill to combine v2.0's sub-agent architecture and automated anti-pattern detection with main's Nielsen heuristics scoring, cognitive load assessment, persona-based testing, and structured follow-up workflow. Fixes browser detector build to create target directory after skill sync. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+7
-10
@@ -166,11 +166,8 @@ This is a test skill body.`;
|
||||
const skillContent = `---
|
||||
name: audit
|
||||
description: Run technical quality checks
|
||||
user-invokable: true
|
||||
args:
|
||||
- name: target
|
||||
description: Target element
|
||||
required: false
|
||||
user-invocable: true
|
||||
argument-hint: "[TARGET=<value>]"
|
||||
---
|
||||
|
||||
Please audit {{target}} for technical quality. Ask {{model}} for help.`;
|
||||
@@ -188,31 +185,31 @@ Please audit {{target}} for technical quality. Ask {{model}} for help.`;
|
||||
transformers.transformGemini(skills, DIST_DIR, patterns);
|
||||
transformers.transformCodex(skills, DIST_DIR, patterns);
|
||||
|
||||
// Verify Cursor: full frontmatter with user-invokable
|
||||
// Verify Cursor: full frontmatter with user-invocable
|
||||
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 with user-invokable and args
|
||||
// Verify Claude Code: full frontmatter with user-invocable and argument-hint
|
||||
const claudeContent = fs.readFileSync(path.join(DIST_DIR, 'claude-code/.claude/skills/audit/SKILL.md'), 'utf-8');
|
||||
expect(claudeContent).toContain('---');
|
||||
expect(claudeContent).toContain('name: audit');
|
||||
expect(claudeContent).toContain('user-invokable: true');
|
||||
expect(claudeContent).toContain('user-invocable: true');
|
||||
expect(claudeContent).toContain('{{target}}');
|
||||
expect(claudeContent).toContain('Claude');
|
||||
|
||||
// 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('{{target}}'); // No body transform, placeholder preserved
|
||||
expect(geminiContent).toContain('Gemini');
|
||||
|
||||
// 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('{{target}}'); // No body transform, placeholder preserved
|
||||
expect(codexContent).toContain('GPT');
|
||||
});
|
||||
|
||||
|
||||
@@ -1,330 +0,0 @@
|
||||
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('<target> [FORMAT=<value>]');
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,356 +0,0 @@
|
||||
import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { transformClaudeCode } from '../../../scripts/lib/transformers/claude-code.js';
|
||||
import { parseFrontmatter } from '../../../scripts/lib/utils.js';
|
||||
|
||||
const TEST_DIR = path.join(process.cwd(), 'test-tmp-claude');
|
||||
|
||||
describe('transformClaudeCode', () => {
|
||||
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', () => {
|
||||
const skills = [];
|
||||
|
||||
transformClaudeCode(skills, TEST_DIR);
|
||||
|
||||
expect(fs.existsSync(path.join(TEST_DIR, 'claude-code/.claude/skills'))).toBe(true);
|
||||
});
|
||||
|
||||
test('should create skill with full frontmatter', () => {
|
||||
const skills = [
|
||||
{
|
||||
name: 'test-skill',
|
||||
description: 'A test skill',
|
||||
license: 'MIT',
|
||||
body: 'Skill instructions.'
|
||||
}
|
||||
];
|
||||
|
||||
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 = [
|
||||
{
|
||||
name: 'no-license-skill',
|
||||
description: 'Skill without license',
|
||||
license: '',
|
||||
body: 'Body content.'
|
||||
}
|
||||
];
|
||||
|
||||
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', () => {
|
||||
const skills = [
|
||||
{ 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/.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', () => {
|
||||
// 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 skills = [
|
||||
{
|
||||
name: 'with-placeholder',
|
||||
description: 'Has placeholder',
|
||||
userInvokable: true,
|
||||
args: [{ name: 'target', description: 'Target', required: false }],
|
||||
body: 'Process {{target}} and generate output.'
|
||||
}
|
||||
];
|
||||
|
||||
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 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(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 skillDirs = fs.readdirSync(path.join(TEST_DIR, 'claude-code/.claude/skills'));
|
||||
expect(skillDirs).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('should format frontmatter correctly with args', () => {
|
||||
const skills = [
|
||||
{
|
||||
name: 'test',
|
||||
description: 'Test command',
|
||||
userInvokable: true,
|
||||
args: [
|
||||
{ name: 'arg1', description: 'First arg', required: true },
|
||||
{ name: 'arg2', description: 'Second arg', required: false }
|
||||
],
|
||||
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('---');
|
||||
expect(content).toContain('name: test');
|
||||
expect(content).toContain('description: Test command');
|
||||
expect(content).toContain('args:');
|
||||
expect(content).toContain('- name: arg1');
|
||||
expect(content).toContain('description: First arg');
|
||||
expect(content).toContain('required: true');
|
||||
expect(content).toContain('- name: arg2');
|
||||
expect(content).toContain('required: false');
|
||||
});
|
||||
|
||||
test('should preserve multiline body content', () => {
|
||||
const skills = [
|
||||
{
|
||||
name: 'multiline',
|
||||
description: 'Test',
|
||||
license: '',
|
||||
body: `First paragraph.
|
||||
|
||||
Second paragraph with details.
|
||||
|
||||
- List item 1
|
||||
- List item 2`
|
||||
}
|
||||
];
|
||||
|
||||
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.');
|
||||
});
|
||||
});
|
||||
@@ -1,308 +0,0 @@
|
||||
import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { transformCodex } from '../../../scripts/lib/transformers/codex.js';
|
||||
import { parseFrontmatter } from '../../../scripts/lib/utils.js';
|
||||
|
||||
const TEST_DIR = path.join(process.cwd(), 'test-tmp-codex');
|
||||
|
||||
describe('transformCodex', () => {
|
||||
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', () => {
|
||||
const skills = [];
|
||||
|
||||
transformCodex(skills, TEST_DIR);
|
||||
|
||||
expect(fs.existsSync(path.join(TEST_DIR, 'codex/.codex/skills'))).toBe(true);
|
||||
});
|
||||
|
||||
test('should create skill files with frontmatter and body in .codex/skills/ directory', () => {
|
||||
const skills = [
|
||||
{
|
||||
name: 'test-skill',
|
||||
description: 'A test skill',
|
||||
license: 'MIT',
|
||||
body: 'Skill instructions here.'
|
||||
}
|
||||
];
|
||||
|
||||
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');
|
||||
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 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('<target> <output>');
|
||||
});
|
||||
|
||||
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=<value>]');
|
||||
});
|
||||
|
||||
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('<input> [FORMAT=<value>] <output>');
|
||||
});
|
||||
|
||||
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: 'Design skill',
|
||||
license: 'MIT',
|
||||
body: 'Design instructions.',
|
||||
references: [
|
||||
{ name: 'typography', content: 'Typography reference', filePath: '/fake/path/typography.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 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(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 handle empty arrays', () => {
|
||||
transformCodex([], TEST_DIR);
|
||||
|
||||
const skillDirs = fs.readdirSync(path.join(TEST_DIR, 'codex/.codex/skills'));
|
||||
expect(skillDirs).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('should handle user-invokable skills without args', () => {
|
||||
const skills = [
|
||||
{
|
||||
name: 'no-args',
|
||||
description: 'No args command',
|
||||
userInvokable: true,
|
||||
args: [],
|
||||
body: 'Body content'
|
||||
}
|
||||
];
|
||||
|
||||
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 skills = [
|
||||
{
|
||||
name: 'multiline',
|
||||
description: 'Test',
|
||||
license: '',
|
||||
body: `First line.
|
||||
|
||||
Second line after blank.
|
||||
|
||||
- Bullet 1
|
||||
- Bullet 2`
|
||||
}
|
||||
];
|
||||
|
||||
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 support prefix option', () => {
|
||||
const skills = [
|
||||
{ name: 'audit', description: 'Audit', license: '', userInvokable: true, body: 'Audit body' }
|
||||
];
|
||||
|
||||
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 replace {{model}} placeholder', () => {
|
||||
const skills = [
|
||||
{
|
||||
name: 'test',
|
||||
description: 'Test',
|
||||
license: '',
|
||||
body: 'Ask {{model}} for help.'
|
||||
}
|
||||
];
|
||||
|
||||
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.');
|
||||
});
|
||||
});
|
||||
@@ -1,245 +0,0 @@
|
||||
import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { transformCursor } from '../../../scripts/lib/transformers/cursor.js';
|
||||
|
||||
const TEST_DIR = path.join(process.cwd(), 'test-tmp-cursor');
|
||||
|
||||
describe('transformCursor', () => {
|
||||
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', () => {
|
||||
const skills = [];
|
||||
|
||||
transformCursor(skills, TEST_DIR);
|
||||
|
||||
expect(fs.existsSync(path.join(TEST_DIR, 'cursor/.cursor/skills'))).toBe(true);
|
||||
});
|
||||
|
||||
test('should create skill with frontmatter and body', () => {
|
||||
const skills = [
|
||||
{
|
||||
name: 'test-skill',
|
||||
description: 'A test skill',
|
||||
license: 'MIT',
|
||||
body: 'Skill instructions here.'
|
||||
}
|
||||
];
|
||||
|
||||
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).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 skills', () => {
|
||||
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(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 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' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
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 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(expect.stringContaining('✓ Cursor:'));
|
||||
expect(consoleMock).toHaveBeenCalledWith(expect.stringContaining('2 skills'));
|
||||
expect(consoleMock).toHaveBeenCalledWith(expect.stringContaining('1 user-invokable'));
|
||||
});
|
||||
|
||||
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 skills = [
|
||||
{
|
||||
name: 'formatted',
|
||||
description: 'Test',
|
||||
license: '',
|
||||
body: `Line 1
|
||||
|
||||
Line 3 after blank line
|
||||
|
||||
- Bullet 1
|
||||
- Bullet 2
|
||||
|
||||
End.`
|
||||
}
|
||||
];
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { createTransformer } from '../../../scripts/lib/transformers/factory.js';
|
||||
import { parseFrontmatter } from '../../../scripts/lib/utils.js';
|
||||
|
||||
const TEST_DIR = path.join(process.cwd(), 'test-tmp-factory');
|
||||
|
||||
// Minimal config using 'cursor' as provider (has existing PROVIDER_PLACEHOLDERS)
|
||||
const baseConfig = {
|
||||
provider: 'cursor',
|
||||
configDir: '.test',
|
||||
displayName: 'Test Provider',
|
||||
frontmatterFields: [],
|
||||
};
|
||||
|
||||
describe('createTransformer factory', () => {
|
||||
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', () => {
|
||||
const transform = createTransformer(baseConfig);
|
||||
transform([], TEST_DIR);
|
||||
expect(fs.existsSync(path.join(TEST_DIR, 'cursor/.test/skills'))).toBe(true);
|
||||
});
|
||||
|
||||
test('should always emit name and description', () => {
|
||||
const transform = createTransformer(baseConfig);
|
||||
const skills = [{ name: 'test', description: 'A test skill', body: 'Body.' }];
|
||||
transform(skills, TEST_DIR);
|
||||
|
||||
const content = fs.readFileSync(path.join(TEST_DIR, 'cursor/.test/skills/test/SKILL.md'), 'utf-8');
|
||||
const parsed = parseFrontmatter(content);
|
||||
expect(parsed.frontmatter.name).toBe('test');
|
||||
expect(parsed.frontmatter.description).toBe('A test skill');
|
||||
expect(parsed.body).toBe('Body.');
|
||||
});
|
||||
|
||||
test('should only emit allowlisted fields', () => {
|
||||
const config = { ...baseConfig, frontmatterFields: ['license'] };
|
||||
const transform = createTransformer(config);
|
||||
const skills = [{
|
||||
name: 'test',
|
||||
description: 'Test',
|
||||
license: 'MIT',
|
||||
compatibility: 'all',
|
||||
metadata: 'meta',
|
||||
body: 'Body'
|
||||
}];
|
||||
transform(skills, TEST_DIR);
|
||||
|
||||
const content = fs.readFileSync(path.join(TEST_DIR, 'cursor/.test/skills/test/SKILL.md'), 'utf-8');
|
||||
const parsed = parseFrontmatter(content);
|
||||
expect(parsed.frontmatter.license).toBe('MIT');
|
||||
expect(parsed.frontmatter.compatibility).toBeUndefined();
|
||||
expect(parsed.frontmatter.metadata).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should skip empty optional fields', () => {
|
||||
const config = { ...baseConfig, frontmatterFields: ['license'] };
|
||||
const transform = createTransformer(config);
|
||||
const skills = [{ name: 'test', description: 'Test', license: '', body: 'Body' }];
|
||||
transform(skills, TEST_DIR);
|
||||
|
||||
const content = fs.readFileSync(path.join(TEST_DIR, 'cursor/.test/skills/test/SKILL.md'), 'utf-8');
|
||||
const parsed = parseFrontmatter(content);
|
||||
expect(parsed.frontmatter.license).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should emit user-invocable as true when skill is user-invocable', () => {
|
||||
const config = { ...baseConfig, frontmatterFields: ['user-invocable'] };
|
||||
const transform = createTransformer(config);
|
||||
const skills = [{ name: 'test', description: 'Test', userInvocable: true, body: 'Body' }];
|
||||
transform(skills, TEST_DIR);
|
||||
|
||||
const content = fs.readFileSync(path.join(TEST_DIR, 'cursor/.test/skills/test/SKILL.md'), 'utf-8');
|
||||
const parsed = parseFrontmatter(content);
|
||||
expect(parsed.frontmatter['user-invocable']).toBe(true);
|
||||
});
|
||||
|
||||
test('should not emit user-invocable when skill is not user-invocable', () => {
|
||||
const config = { ...baseConfig, frontmatterFields: ['user-invocable'] };
|
||||
const transform = createTransformer(config);
|
||||
const skills = [{ name: 'test', description: 'Test', userInvocable: false, body: 'Body' }];
|
||||
transform(skills, TEST_DIR);
|
||||
|
||||
const content = fs.readFileSync(path.join(TEST_DIR, 'cursor/.test/skills/test/SKILL.md'), 'utf-8');
|
||||
const parsed = parseFrontmatter(content);
|
||||
expect(parsed.frontmatter['user-invocable']).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should emit argument-hint only when user-invocable', () => {
|
||||
const config = { ...baseConfig, frontmatterFields: ['argument-hint'] };
|
||||
const transform = createTransformer(config);
|
||||
|
||||
// User-invocable with hint
|
||||
const skills1 = [{ name: 'test', description: 'Test', userInvocable: true, argumentHint: '[target]', body: 'Body' }];
|
||||
transform(skills1, TEST_DIR);
|
||||
let content = fs.readFileSync(path.join(TEST_DIR, 'cursor/.test/skills/test/SKILL.md'), 'utf-8');
|
||||
let parsed = parseFrontmatter(content);
|
||||
expect(parsed.frontmatter['argument-hint']).toBe('[target]');
|
||||
|
||||
// Non-user-invocable with hint
|
||||
fs.rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
const skills2 = [{ name: 'test', description: 'Test', userInvocable: false, argumentHint: '[target]', body: 'Body' }];
|
||||
transform(skills2, TEST_DIR);
|
||||
content = fs.readFileSync(path.join(TEST_DIR, 'cursor/.test/skills/test/SKILL.md'), 'utf-8');
|
||||
parsed = parseFrontmatter(content);
|
||||
expect(parsed.frontmatter['argument-hint']).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should apply bodyTransform after placeholder replacement', () => {
|
||||
const config = {
|
||||
...baseConfig,
|
||||
bodyTransform: (body) => body.replace(/PLACEHOLDER/, 'TRANSFORMED'),
|
||||
};
|
||||
const transform = createTransformer(config);
|
||||
const skills = [{ name: 'test', description: 'Test', body: 'PLACEHOLDER content' }];
|
||||
transform(skills, TEST_DIR);
|
||||
|
||||
const content = fs.readFileSync(path.join(TEST_DIR, 'cursor/.test/skills/test/SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('TRANSFORMED content');
|
||||
});
|
||||
|
||||
test('should support prefix option', () => {
|
||||
const transform = createTransformer(baseConfig);
|
||||
const skills = [{ name: 'audit', description: 'Audit', userInvocable: true, body: 'Body' }];
|
||||
transform(skills, TEST_DIR, { prefix: 'i-', outputSuffix: '-prefixed' });
|
||||
|
||||
const outputPath = path.join(TEST_DIR, 'cursor-prefixed/.test/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 copy reference files', () => {
|
||||
const transform = createTransformer(baseConfig);
|
||||
const skills = [{
|
||||
name: 'test',
|
||||
description: 'Test',
|
||||
body: 'Body',
|
||||
references: [
|
||||
{ name: 'ref1', content: 'Reference 1 content', filePath: '/fake/ref1.md' },
|
||||
{ name: 'ref2', content: 'Reference 2 content', filePath: '/fake/ref2.md' },
|
||||
]
|
||||
}];
|
||||
transform(skills, TEST_DIR);
|
||||
|
||||
expect(fs.existsSync(path.join(TEST_DIR, 'cursor/.test/skills/test/reference/ref1.md'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(TEST_DIR, 'cursor/.test/skills/test/reference/ref2.md'))).toBe(true);
|
||||
const ref1 = fs.readFileSync(path.join(TEST_DIR, 'cursor/.test/skills/test/reference/ref1.md'), 'utf-8');
|
||||
expect(ref1).toBe('Reference 1 content');
|
||||
});
|
||||
|
||||
test('should clean existing directory before writing', () => {
|
||||
const transform = createTransformer(baseConfig);
|
||||
const existingDir = path.join(TEST_DIR, 'cursor/.test/skills/old');
|
||||
fs.mkdirSync(existingDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(existingDir, 'SKILL.md'), 'old');
|
||||
|
||||
const skills = [{ name: 'new', description: 'New', body: 'New' }];
|
||||
transform(skills, TEST_DIR);
|
||||
|
||||
expect(fs.existsSync(path.join(TEST_DIR, 'cursor/.test/skills/old/SKILL.md'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(TEST_DIR, 'cursor/.test/skills/new/SKILL.md'))).toBe(true);
|
||||
});
|
||||
|
||||
test('should log correct summary', () => {
|
||||
const consoleMock = mock(() => {});
|
||||
const originalLog = console.log;
|
||||
console.log = consoleMock;
|
||||
|
||||
const transform = createTransformer(baseConfig);
|
||||
const skills = [
|
||||
{ name: 's1', description: 'Test', userInvocable: true, body: 'body' },
|
||||
{ name: 's2', description: 'Test', userInvocable: false, body: 'body' }
|
||||
];
|
||||
transform(skills, TEST_DIR);
|
||||
|
||||
console.log = originalLog;
|
||||
expect(consoleMock).toHaveBeenCalledWith(expect.stringContaining('✓ Test Provider:'));
|
||||
expect(consoleMock).toHaveBeenCalledWith(expect.stringContaining('2 skills'));
|
||||
expect(consoleMock).toHaveBeenCalledWith(expect.stringContaining('1 user-invocable'));
|
||||
});
|
||||
|
||||
test('should handle empty skills array', () => {
|
||||
const transform = createTransformer(baseConfig);
|
||||
transform([], TEST_DIR);
|
||||
|
||||
const skillDirs = fs.readdirSync(path.join(TEST_DIR, 'cursor/.test/skills'));
|
||||
expect(skillDirs).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('should replace {{model}} placeholder', () => {
|
||||
const transform = createTransformer(baseConfig);
|
||||
const skills = [{ name: 'test', description: 'Test', body: 'Ask {{model}} for help.' }];
|
||||
transform(skills, TEST_DIR);
|
||||
|
||||
const content = fs.readFileSync(path.join(TEST_DIR, 'cursor/.test/skills/test/SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('Ask the model for help.');
|
||||
});
|
||||
|
||||
test('should replace {{config_file}} placeholder', () => {
|
||||
const transform = createTransformer(baseConfig);
|
||||
const skills = [{ name: 'test', description: 'Test', body: 'See {{config_file}}.' }];
|
||||
transform(skills, TEST_DIR);
|
||||
|
||||
const content = fs.readFileSync(path.join(TEST_DIR, 'cursor/.test/skills/test/SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('See .cursorrules.');
|
||||
});
|
||||
|
||||
test('should handle multiple skills', () => {
|
||||
const transform = createTransformer(baseConfig);
|
||||
const skills = [
|
||||
{ name: 'skill1', description: 'Skill 1', body: 'Body 1' },
|
||||
{ name: 'skill2', description: 'Skill 2', body: 'Body 2' },
|
||||
];
|
||||
transform(skills, TEST_DIR);
|
||||
|
||||
expect(fs.existsSync(path.join(TEST_DIR, 'cursor/.test/skills/skill1/SKILL.md'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(TEST_DIR, 'cursor/.test/skills/skill2/SKILL.md'))).toBe(true);
|
||||
});
|
||||
|
||||
test('should preserve multiline body content', () => {
|
||||
const transform = createTransformer(baseConfig);
|
||||
const skills = [{
|
||||
name: 'test',
|
||||
description: 'Test',
|
||||
body: `First paragraph.\n\nSecond paragraph.\n\n- List item 1\n- List item 2`
|
||||
}];
|
||||
transform(skills, TEST_DIR);
|
||||
|
||||
const content = fs.readFileSync(path.join(TEST_DIR, 'cursor/.test/skills/test/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 emit all spec fields when configured', () => {
|
||||
const config = {
|
||||
...baseConfig,
|
||||
frontmatterFields: ['user-invocable', 'argument-hint', 'license', 'compatibility', 'metadata', 'allowed-tools'],
|
||||
};
|
||||
const transform = createTransformer(config);
|
||||
const skills = [{
|
||||
name: 'test',
|
||||
description: 'Test',
|
||||
userInvocable: true,
|
||||
argumentHint: '[target]',
|
||||
license: 'MIT',
|
||||
compatibility: 'claude-code',
|
||||
metadata: 'v1',
|
||||
allowedTools: 'Bash,Edit',
|
||||
body: 'Body'
|
||||
}];
|
||||
transform(skills, TEST_DIR);
|
||||
|
||||
const content = fs.readFileSync(path.join(TEST_DIR, 'cursor/.test/skills/test/SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('user-invocable: true');
|
||||
expect(content).toContain('argument-hint:');
|
||||
expect(content).toContain('license: MIT');
|
||||
expect(content).toContain('compatibility: claude-code');
|
||||
expect(content).toContain('metadata: v1');
|
||||
expect(content).toContain('allowed-tools: Bash,Edit');
|
||||
});
|
||||
});
|
||||
@@ -1,210 +0,0 @@
|
||||
import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { transformGemini } from '../../../scripts/lib/transformers/gemini.js';
|
||||
|
||||
const TEST_DIR = path.join(process.cwd(), 'test-tmp-gemini');
|
||||
|
||||
describe('transformGemini', () => {
|
||||
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', () => {
|
||||
const skills = [];
|
||||
|
||||
transformGemini(skills, TEST_DIR);
|
||||
|
||||
expect(fs.existsSync(path.join(TEST_DIR, 'gemini/.gemini/skills'))).toBe(true);
|
||||
});
|
||||
|
||||
test('should create skill files with frontmatter and body in .gemini/skills/ directory', () => {
|
||||
const skills = [
|
||||
{
|
||||
name: 'test-skill',
|
||||
description: 'A test skill',
|
||||
license: 'MIT',
|
||||
body: 'Skill instructions here.'
|
||||
}
|
||||
];
|
||||
|
||||
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).toContain('---');
|
||||
expect(content).toContain('name: test-skill');
|
||||
expect(content).toContain('description: A test skill');
|
||||
expect(content).toContain('Skill instructions here.');
|
||||
});
|
||||
|
||||
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: 'Design skill',
|
||||
license: 'MIT',
|
||||
body: 'Design instructions.',
|
||||
references: [
|
||||
{ name: 'typography', content: 'Typography reference', filePath: '/fake/path/typography.md' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
transformGemini(skills, TEST_DIR);
|
||||
|
||||
expect(fs.existsSync(path.join(TEST_DIR, 'gemini/.gemini/skills/frontend-design/reference/typography.md'))).toBe(true);
|
||||
|
||||
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 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(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 skillDirs = fs.readdirSync(path.join(TEST_DIR, 'gemini/.gemini/skills'));
|
||||
expect(skillDirs).toHaveLength(0);
|
||||
});
|
||||
|
||||
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',
|
||||
license: '',
|
||||
body: 'Ask {{model}} for help.'
|
||||
}
|
||||
];
|
||||
|
||||
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 replace {{config_file}} placeholder', () => {
|
||||
const skills = [
|
||||
{
|
||||
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/skills/test/SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('See GEMINI.md for more.');
|
||||
});
|
||||
});
|
||||
@@ -1,302 +0,0 @@
|
||||
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:');
|
||||
});
|
||||
});
|
||||
@@ -1,395 +0,0 @@
|
||||
import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { transformOpenCode } from '../../../scripts/lib/transformers/opencode.js';
|
||||
import { parseFrontmatter } from '../../../scripts/lib/utils.js';
|
||||
|
||||
const TEST_DIR = path.join(process.cwd(), 'test-tmp-opencode');
|
||||
|
||||
describe('transformOpenCode', () => {
|
||||
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', () => {
|
||||
transformOpenCode([], TEST_DIR);
|
||||
expect(fs.existsSync(path.join(TEST_DIR, 'opencode/.opencode/skills'))).toBe(true);
|
||||
});
|
||||
|
||||
test('should create skill with full frontmatter', () => {
|
||||
const skills = [
|
||||
{
|
||||
name: 'test-skill',
|
||||
description: 'A test skill',
|
||||
license: 'MIT',
|
||||
body: 'Skill instructions.'
|
||||
}
|
||||
];
|
||||
|
||||
transformOpenCode(skills, TEST_DIR);
|
||||
|
||||
const outputPath = path.join(TEST_DIR, 'opencode/.opencode/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.'
|
||||
}
|
||||
];
|
||||
|
||||
transformOpenCode(skills, TEST_DIR);
|
||||
|
||||
const content = fs.readFileSync(path.join(TEST_DIR, 'opencode/.opencode/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.'
|
||||
}
|
||||
];
|
||||
|
||||
transformOpenCode(skills, TEST_DIR);
|
||||
|
||||
const content = fs.readFileSync(path.join(TEST_DIR, 'opencode/.opencode/skills/helper/SKILL.md'), 'utf-8');
|
||||
const parsed = parseFrontmatter(content);
|
||||
|
||||
expect(parsed.frontmatter['user-invokable']).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should include args in frontmatter', () => {
|
||||
const skills = [
|
||||
{
|
||||
name: 'with-args',
|
||||
description: 'Command with args',
|
||||
userInvokable: true,
|
||||
args: [
|
||||
{ name: 'target', description: 'Target element', required: false }
|
||||
],
|
||||
body: 'Process {{target}}.'
|
||||
}
|
||||
];
|
||||
|
||||
transformOpenCode(skills, TEST_DIR);
|
||||
|
||||
const content = fs.readFileSync(path.join(TEST_DIR, 'opencode/.opencode/skills/with-args/SKILL.md'), 'utf-8');
|
||||
const parsed = parseFrontmatter(content);
|
||||
|
||||
expect(parsed.frontmatter.args).toBeArray();
|
||||
expect(parsed.frontmatter.args).toHaveLength(1);
|
||||
expect(parsed.frontmatter.args[0].name).toBe('target');
|
||||
});
|
||||
|
||||
test('should not include args when empty', () => {
|
||||
const skills = [
|
||||
{
|
||||
name: 'no-args',
|
||||
description: 'No args',
|
||||
userInvokable: true,
|
||||
args: [],
|
||||
body: 'Simple body.'
|
||||
}
|
||||
];
|
||||
|
||||
transformOpenCode(skills, TEST_DIR);
|
||||
|
||||
const content = fs.readFileSync(path.join(TEST_DIR, 'opencode/.opencode/skills/no-args/SKILL.md'), 'utf-8');
|
||||
const parsed = parseFrontmatter(content);
|
||||
|
||||
expect(parsed.frontmatter.args).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should include allowed-tools in frontmatter', () => {
|
||||
const skills = [
|
||||
{
|
||||
name: 'test',
|
||||
description: 'Test',
|
||||
allowedTools: 'Bash,Edit',
|
||||
body: 'Body'
|
||||
}
|
||||
];
|
||||
|
||||
transformOpenCode(skills, TEST_DIR);
|
||||
|
||||
const content = fs.readFileSync(path.join(TEST_DIR, 'opencode/.opencode/skills/test/SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('allowed-tools: Bash,Edit');
|
||||
});
|
||||
|
||||
test('should include compatibility in frontmatter', () => {
|
||||
const skills = [
|
||||
{
|
||||
name: 'test',
|
||||
description: 'Test',
|
||||
compatibility: 'opencode',
|
||||
body: 'Body'
|
||||
}
|
||||
];
|
||||
|
||||
transformOpenCode(skills, TEST_DIR);
|
||||
|
||||
const content = fs.readFileSync(path.join(TEST_DIR, 'opencode/.opencode/skills/test/SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('compatibility: opencode');
|
||||
});
|
||||
|
||||
test('should include metadata in frontmatter', () => {
|
||||
const skills = [
|
||||
{
|
||||
name: 'test',
|
||||
description: 'Test',
|
||||
metadata: 'some-metadata',
|
||||
body: 'Body'
|
||||
}
|
||||
];
|
||||
|
||||
transformOpenCode(skills, TEST_DIR);
|
||||
|
||||
const content = fs.readFileSync(path.join(TEST_DIR, 'opencode/.opencode/skills/test/SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('metadata: some-metadata');
|
||||
});
|
||||
|
||||
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' }
|
||||
];
|
||||
|
||||
transformOpenCode(skills, TEST_DIR);
|
||||
|
||||
expect(fs.existsSync(path.join(TEST_DIR, 'opencode/.opencode/skills/skill1/SKILL.md'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(TEST_DIR, 'opencode/.opencode/skills/skill2/SKILL.md'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(TEST_DIR, 'opencode/.opencode/skills/skill3/SKILL.md'))).toBe(true);
|
||||
});
|
||||
|
||||
test('should replace {{model}} placeholder', () => {
|
||||
const skills = [
|
||||
{
|
||||
name: 'test',
|
||||
description: 'Test',
|
||||
body: 'Ask {{model}} for help.'
|
||||
}
|
||||
];
|
||||
|
||||
transformOpenCode(skills, TEST_DIR);
|
||||
|
||||
const content = fs.readFileSync(path.join(TEST_DIR, 'opencode/.opencode/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.'
|
||||
}
|
||||
];
|
||||
|
||||
transformOpenCode(skills, TEST_DIR);
|
||||
|
||||
const content = fs.readFileSync(path.join(TEST_DIR, 'opencode/.opencode/skills/test/SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('See AGENTS.md for details.');
|
||||
});
|
||||
|
||||
test('should replace {{ask_instruction}} placeholder', () => {
|
||||
const skills = [
|
||||
{
|
||||
name: 'test',
|
||||
description: 'Test',
|
||||
body: 'If unclear, {{ask_instruction}}'
|
||||
}
|
||||
];
|
||||
|
||||
transformOpenCode(skills, TEST_DIR);
|
||||
|
||||
const content = fs.readFileSync(path.join(TEST_DIR, 'opencode/.opencode/skills/test/SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('STOP and call the `question` tool to clarify.');
|
||||
});
|
||||
|
||||
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.' }
|
||||
];
|
||||
|
||||
transformOpenCode(skills, TEST_DIR);
|
||||
|
||||
const content = fs.readFileSync(path.join(TEST_DIR, 'opencode/.opencode/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' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
transformOpenCode(skills, TEST_DIR);
|
||||
|
||||
const typoPath = path.join(TEST_DIR, 'opencode/.opencode/skills/frontend-design/reference/typography.md');
|
||||
const colorPath = path.join(TEST_DIR, 'opencode/.opencode/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' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
transformOpenCode(skills, TEST_DIR);
|
||||
|
||||
const refContent = fs.readFileSync(path.join(TEST_DIR, 'opencode/.opencode/skills/test/reference/ref.md'), 'utf-8');
|
||||
expect(refContent).toContain('Use Claude with AGENTS.md.');
|
||||
// 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' }
|
||||
];
|
||||
|
||||
transformOpenCode(skills, TEST_DIR, null, { prefix: 'i-', outputSuffix: '-prefixed' });
|
||||
|
||||
const outputPath = path.join(TEST_DIR, 'opencode-prefixed/.opencode/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.' }
|
||||
];
|
||||
|
||||
transformOpenCode(skills, TEST_DIR, null, { prefix: 'i-', outputSuffix: '-prefixed' });
|
||||
|
||||
const content = fs.readFileSync(path.join(TEST_DIR, 'opencode-prefixed/.opencode/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, 'opencode/.opencode/skills/old');
|
||||
fs.mkdirSync(existingDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(existingDir, 'SKILL.md'), 'old');
|
||||
|
||||
const skills = [{ name: 'new', description: 'New', body: 'New' }];
|
||||
transformOpenCode(skills, TEST_DIR);
|
||||
|
||||
expect(fs.existsSync(path.join(TEST_DIR, 'opencode/.opencode/skills/old/SKILL.md'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(TEST_DIR, 'opencode/.opencode/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' }
|
||||
];
|
||||
|
||||
transformOpenCode(skills, TEST_DIR);
|
||||
|
||||
console.log = originalLog;
|
||||
|
||||
expect(consoleMock).toHaveBeenCalledWith(expect.stringContaining('✓ OpenCode:'));
|
||||
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' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
transformOpenCode(skills, TEST_DIR);
|
||||
|
||||
console.log = originalLog;
|
||||
|
||||
expect(consoleMock).toHaveBeenCalledWith(expect.stringContaining('2 reference files'));
|
||||
});
|
||||
|
||||
test('should handle empty skills array', () => {
|
||||
transformOpenCode([], TEST_DIR);
|
||||
|
||||
const skillDirs = fs.readdirSync(path.join(TEST_DIR, 'opencode/.opencode/skills'));
|
||||
expect(skillDirs).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('should not include license if empty', () => {
|
||||
const skills = [
|
||||
{
|
||||
name: 'test',
|
||||
description: 'Test',
|
||||
license: '',
|
||||
body: 'Body'
|
||||
}
|
||||
];
|
||||
|
||||
transformOpenCode(skills, TEST_DIR);
|
||||
|
||||
const content = fs.readFileSync(path.join(TEST_DIR, 'opencode/.opencode/skills/test/SKILL.md'), 'utf-8');
|
||||
expect(content).not.toContain('license:');
|
||||
});
|
||||
});
|
||||
@@ -1,302 +0,0 @@
|
||||
import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { transformPi } from '../../../scripts/lib/transformers/pi.js';
|
||||
import { parseFrontmatter } from '../../../scripts/lib/utils.js';
|
||||
|
||||
const TEST_DIR = path.join(process.cwd(), 'test-tmp-pi');
|
||||
|
||||
describe('transformPi', () => {
|
||||
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', () => {
|
||||
transformPi([], TEST_DIR);
|
||||
expect(fs.existsSync(path.join(TEST_DIR, 'pi/.pi/skills'))).toBe(true);
|
||||
});
|
||||
|
||||
test('should create skill with full frontmatter', () => {
|
||||
const skills = [
|
||||
{
|
||||
name: 'test-skill',
|
||||
description: 'A test skill',
|
||||
license: 'MIT',
|
||||
body: 'Skill instructions.'
|
||||
}
|
||||
];
|
||||
|
||||
transformPi(skills, TEST_DIR);
|
||||
|
||||
const outputPath = path.join(TEST_DIR, 'pi/.pi/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: 'pi',
|
||||
body: 'Body'
|
||||
}
|
||||
];
|
||||
|
||||
transformPi(skills, TEST_DIR);
|
||||
|
||||
const content = fs.readFileSync(path.join(TEST_DIR, 'pi/.pi/skills/test/SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('compatibility: pi');
|
||||
});
|
||||
|
||||
test('should include metadata in frontmatter', () => {
|
||||
const skills = [
|
||||
{
|
||||
name: 'test',
|
||||
description: 'Test',
|
||||
metadata: 'some-metadata',
|
||||
body: 'Body'
|
||||
}
|
||||
];
|
||||
|
||||
transformPi(skills, TEST_DIR);
|
||||
|
||||
const content = fs.readFileSync(path.join(TEST_DIR, 'pi/.pi/skills/test/SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('metadata: some-metadata');
|
||||
});
|
||||
|
||||
test('should not include user-invokable in frontmatter (Pi does not use it)', () => {
|
||||
const skills = [
|
||||
{
|
||||
name: 'test',
|
||||
description: 'Test',
|
||||
userInvokable: true,
|
||||
body: 'Body'
|
||||
}
|
||||
];
|
||||
|
||||
transformPi(skills, TEST_DIR);
|
||||
|
||||
const content = fs.readFileSync(path.join(TEST_DIR, 'pi/.pi/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' }
|
||||
];
|
||||
|
||||
transformPi(skills, TEST_DIR);
|
||||
|
||||
expect(fs.existsSync(path.join(TEST_DIR, 'pi/.pi/skills/skill1/SKILL.md'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(TEST_DIR, 'pi/.pi/skills/skill2/SKILL.md'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(TEST_DIR, 'pi/.pi/skills/skill3/SKILL.md'))).toBe(true);
|
||||
});
|
||||
|
||||
test('should replace {{model}} placeholder', () => {
|
||||
const skills = [
|
||||
{
|
||||
name: 'test',
|
||||
description: 'Test',
|
||||
body: 'Ask {{model}} for help.'
|
||||
}
|
||||
];
|
||||
|
||||
transformPi(skills, TEST_DIR);
|
||||
|
||||
const content = fs.readFileSync(path.join(TEST_DIR, 'pi/.pi/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.'
|
||||
}
|
||||
];
|
||||
|
||||
transformPi(skills, TEST_DIR);
|
||||
|
||||
const content = fs.readFileSync(path.join(TEST_DIR, 'pi/.pi/skills/test/SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('See AGENTS.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.' }
|
||||
];
|
||||
|
||||
transformPi(skills, TEST_DIR);
|
||||
|
||||
const content = fs.readFileSync(path.join(TEST_DIR, 'pi/.pi/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' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
transformPi(skills, TEST_DIR);
|
||||
|
||||
const typoPath = path.join(TEST_DIR, 'pi/.pi/skills/frontend-design/reference/typography.md');
|
||||
const colorPath = path.join(TEST_DIR, 'pi/.pi/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' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
transformPi(skills, TEST_DIR);
|
||||
|
||||
const refContent = fs.readFileSync(path.join(TEST_DIR, 'pi/.pi/skills/test/reference/ref.md'), 'utf-8');
|
||||
expect(refContent).toContain('Use the model with AGENTS.md.');
|
||||
// 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' }
|
||||
];
|
||||
|
||||
transformPi(skills, TEST_DIR, null, { prefix: 'i-', outputSuffix: '-prefixed' });
|
||||
|
||||
const outputPath = path.join(TEST_DIR, 'pi-prefixed/.pi/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.' }
|
||||
];
|
||||
|
||||
transformPi(skills, TEST_DIR, null, { prefix: 'i-', outputSuffix: '-prefixed' });
|
||||
|
||||
const content = fs.readFileSync(path.join(TEST_DIR, 'pi-prefixed/.pi/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, 'pi/.pi/skills/old');
|
||||
fs.mkdirSync(existingDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(existingDir, 'SKILL.md'), 'old');
|
||||
|
||||
const skills = [{ name: 'new', description: 'New', body: 'New' }];
|
||||
transformPi(skills, TEST_DIR);
|
||||
|
||||
expect(fs.existsSync(path.join(TEST_DIR, 'pi/.pi/skills/old/SKILL.md'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(TEST_DIR, 'pi/.pi/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' }
|
||||
];
|
||||
|
||||
transformPi(skills, TEST_DIR);
|
||||
|
||||
console.log = originalLog;
|
||||
|
||||
expect(consoleMock).toHaveBeenCalledWith(expect.stringContaining('✓ Pi:'));
|
||||
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' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
transformPi(skills, TEST_DIR);
|
||||
|
||||
console.log = originalLog;
|
||||
|
||||
expect(consoleMock).toHaveBeenCalledWith(expect.stringContaining('1 reference files'));
|
||||
});
|
||||
|
||||
test('should handle empty skills array', () => {
|
||||
transformPi([], TEST_DIR);
|
||||
|
||||
const skillDirs = fs.readdirSync(path.join(TEST_DIR, 'pi/.pi/skills'));
|
||||
expect(skillDirs).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('should not include license if empty', () => {
|
||||
const skills = [
|
||||
{
|
||||
name: 'test',
|
||||
description: 'Test',
|
||||
license: '',
|
||||
body: 'Body'
|
||||
}
|
||||
];
|
||||
|
||||
transformPi(skills, TEST_DIR);
|
||||
|
||||
const content = fs.readFileSync(path.join(TEST_DIR, 'pi/.pi/skills/test/SKILL.md'), 'utf-8');
|
||||
expect(content).not.toContain('license:');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { PROVIDERS } from '../../../scripts/lib/transformers/providers.js';
|
||||
import { createTransformer } from '../../../scripts/lib/transformers/factory.js';
|
||||
import { parseFrontmatter, PROVIDER_PLACEHOLDERS } from '../../../scripts/lib/utils.js';
|
||||
|
||||
const TEST_DIR = path.join(process.cwd(), 'test-tmp-providers');
|
||||
|
||||
function providerTestDir(provider, suffix = '') {
|
||||
return path.join(TEST_DIR, `${provider}${suffix}`);
|
||||
}
|
||||
|
||||
function skillPath(config, skillName, suffix = '') {
|
||||
return path.join(TEST_DIR, `${config.provider}${suffix}/${config.configDir}/skills/${skillName}/SKILL.md`);
|
||||
}
|
||||
|
||||
// Test every provider config
|
||||
for (const [key, config] of Object.entries(PROVIDERS)) {
|
||||
describe(`Provider: ${config.displayName} (${key})`, () => {
|
||||
const transform = createTransformer(config);
|
||||
|
||||
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', () => {
|
||||
transform([], TEST_DIR);
|
||||
expect(fs.existsSync(path.join(TEST_DIR, `${config.provider}/${config.configDir}/skills`))).toBe(true);
|
||||
});
|
||||
|
||||
test('should replace {{model}} placeholder correctly', () => {
|
||||
const skills = [{ name: 'test', description: 'Test', body: 'Ask {{model}} for help.' }];
|
||||
transform(skills, TEST_DIR);
|
||||
const content = fs.readFileSync(skillPath(config, 'test'), 'utf-8');
|
||||
const expected = PROVIDER_PLACEHOLDERS[config.placeholderProvider || config.provider].model;
|
||||
expect(content).toContain(`Ask ${expected} for help.`);
|
||||
});
|
||||
|
||||
test('should replace {{config_file}} placeholder correctly', () => {
|
||||
const skills = [{ name: 'test', description: 'Test', body: 'See {{config_file}}.' }];
|
||||
transform(skills, TEST_DIR);
|
||||
const content = fs.readFileSync(skillPath(config, 'test'), 'utf-8');
|
||||
const expected = PROVIDER_PLACEHOLDERS[config.placeholderProvider || config.provider].config_file;
|
||||
expect(content).toContain(`See ${expected}.`);
|
||||
});
|
||||
|
||||
test('should support prefix option', () => {
|
||||
const skills = [{ name: 'audit', description: 'Audit', userInvocable: true, body: 'Body' }];
|
||||
transform(skills, TEST_DIR, { prefix: 'i-', outputSuffix: '-prefixed' });
|
||||
expect(fs.existsSync(skillPath(config, 'i-audit', '-prefixed'))).toBe(true);
|
||||
const content = fs.readFileSync(skillPath(config, 'i-audit', '-prefixed'), 'utf-8');
|
||||
expect(content).toContain('name: i-audit');
|
||||
});
|
||||
|
||||
test('should copy reference files', () => {
|
||||
const skills = [{
|
||||
name: 'test',
|
||||
description: 'Test',
|
||||
body: 'Body',
|
||||
references: [{ name: 'ref', content: 'Ref content', filePath: '/fake/ref.md' }]
|
||||
}];
|
||||
transform(skills, TEST_DIR);
|
||||
const refPath = path.join(TEST_DIR, `${config.provider}/${config.configDir}/skills/test/reference/ref.md`);
|
||||
expect(fs.existsSync(refPath)).toBe(true);
|
||||
});
|
||||
|
||||
// Field-specific tests based on provider config
|
||||
if (config.frontmatterFields.includes('user-invocable')) {
|
||||
test('should emit user-invocable for user-invocable skills', () => {
|
||||
const skills = [{ name: 'test', description: 'Test', userInvocable: true, body: 'Body' }];
|
||||
transform(skills, TEST_DIR);
|
||||
const parsed = parseFrontmatter(fs.readFileSync(skillPath(config, 'test'), 'utf-8'));
|
||||
expect(parsed.frontmatter['user-invocable']).toBe(true);
|
||||
});
|
||||
|
||||
test('should omit user-invocable for non-user-invocable skills', () => {
|
||||
const skills = [{ name: 'test', description: 'Test', userInvocable: false, body: 'Body' }];
|
||||
transform(skills, TEST_DIR);
|
||||
const parsed = parseFrontmatter(fs.readFileSync(skillPath(config, 'test'), 'utf-8'));
|
||||
expect(parsed.frontmatter['user-invocable']).toBeUndefined();
|
||||
});
|
||||
}
|
||||
|
||||
if (config.frontmatterFields.includes('argument-hint')) {
|
||||
test('should emit argument-hint for user-invocable skills', () => {
|
||||
const skills = [{ name: 'test', description: 'Test', userInvocable: true, argumentHint: '[target]', body: 'Body' }];
|
||||
transform(skills, TEST_DIR);
|
||||
const parsed = parseFrontmatter(fs.readFileSync(skillPath(config, 'test'), 'utf-8'));
|
||||
expect(parsed.frontmatter['argument-hint']).toBe('[target]');
|
||||
});
|
||||
|
||||
test('should not emit argument-hint for non-user-invocable skills', () => {
|
||||
const skills = [{ name: 'test', description: 'Test', userInvocable: false, argumentHint: '[target]', body: 'Body' }];
|
||||
transform(skills, TEST_DIR);
|
||||
const parsed = parseFrontmatter(fs.readFileSync(skillPath(config, 'test'), 'utf-8'));
|
||||
expect(parsed.frontmatter['argument-hint']).toBeUndefined();
|
||||
});
|
||||
}
|
||||
|
||||
if (config.frontmatterFields.includes('license')) {
|
||||
test('should emit license when present', () => {
|
||||
const skills = [{ name: 'test', description: 'Test', license: 'MIT', body: 'Body' }];
|
||||
transform(skills, TEST_DIR);
|
||||
const parsed = parseFrontmatter(fs.readFileSync(skillPath(config, 'test'), 'utf-8'));
|
||||
expect(parsed.frontmatter.license).toBe('MIT');
|
||||
});
|
||||
|
||||
test('should omit license when empty', () => {
|
||||
const skills = [{ name: 'test', description: 'Test', license: '', body: 'Body' }];
|
||||
transform(skills, TEST_DIR);
|
||||
const parsed = parseFrontmatter(fs.readFileSync(skillPath(config, 'test'), 'utf-8'));
|
||||
expect(parsed.frontmatter.license).toBeUndefined();
|
||||
});
|
||||
}
|
||||
|
||||
if (config.frontmatterFields.includes('compatibility')) {
|
||||
test('should emit compatibility when present', () => {
|
||||
const skills = [{ name: 'test', description: 'Test', compatibility: config.provider, body: 'Body' }];
|
||||
transform(skills, TEST_DIR);
|
||||
const content = fs.readFileSync(skillPath(config, 'test'), 'utf-8');
|
||||
expect(content).toContain(`compatibility: ${config.provider}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (config.frontmatterFields.includes('metadata')) {
|
||||
test('should emit metadata when present', () => {
|
||||
const skills = [{ name: 'test', description: 'Test', metadata: 'some-metadata', body: 'Body' }];
|
||||
transform(skills, TEST_DIR);
|
||||
const content = fs.readFileSync(skillPath(config, 'test'), 'utf-8');
|
||||
expect(content).toContain('metadata: some-metadata');
|
||||
});
|
||||
}
|
||||
|
||||
if (config.frontmatterFields.includes('allowed-tools')) {
|
||||
test('should emit allowed-tools when present', () => {
|
||||
const skills = [{ name: 'test', description: 'Test', allowedTools: 'Bash,Edit', body: 'Body' }];
|
||||
transform(skills, TEST_DIR);
|
||||
const content = fs.readFileSync(skillPath(config, 'test'), 'utf-8');
|
||||
expect(content).toContain('allowed-tools: Bash,Edit');
|
||||
});
|
||||
}
|
||||
|
||||
// Fields NOT in this provider's allowlist should not appear
|
||||
const allOptionalFields = ['user-invocable', 'argument-hint', 'license', 'compatibility', 'metadata', 'allowed-tools'];
|
||||
const excludedFields = allOptionalFields.filter(f => !config.frontmatterFields.includes(f));
|
||||
|
||||
if (excludedFields.length > 0) {
|
||||
test('should not emit fields outside allowlist', () => {
|
||||
const skills = [{
|
||||
name: 'test',
|
||||
description: 'Test',
|
||||
userInvocable: true,
|
||||
argumentHint: '[target]',
|
||||
license: 'MIT',
|
||||
compatibility: 'all',
|
||||
metadata: 'meta',
|
||||
allowedTools: 'Bash',
|
||||
body: 'Body'
|
||||
}];
|
||||
transform(skills, TEST_DIR);
|
||||
const content = fs.readFileSync(skillPath(config, 'test'), 'utf-8');
|
||||
|
||||
for (const field of excludedFields) {
|
||||
const yamlKey = field; // field names match yaml keys
|
||||
// Check the raw content doesn't contain the yaml key
|
||||
const lines = content.split('\n');
|
||||
const frontmatterLines = [];
|
||||
let inFrontmatter = false;
|
||||
for (const line of lines) {
|
||||
if (line === '---') {
|
||||
if (inFrontmatter) break;
|
||||
inFrontmatter = true;
|
||||
continue;
|
||||
}
|
||||
if (inFrontmatter) frontmatterLines.push(line);
|
||||
}
|
||||
const hasForbiddenField = frontmatterLines.some(l => l.startsWith(`${yamlKey}:`));
|
||||
expect(hasForbiddenField).toBe(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
+24
-46
@@ -32,29 +32,18 @@ This is the body content.`;
|
||||
expect(result.body).toBe('This is the body content.');
|
||||
});
|
||||
|
||||
test('should parse frontmatter with args array', () => {
|
||||
test('should parse frontmatter with argument-hint', () => {
|
||||
const content = `---
|
||||
name: test-skill
|
||||
description: A test skill
|
||||
args:
|
||||
- name: target
|
||||
description: The target to normalize
|
||||
required: false
|
||||
- name: output
|
||||
description: Output format
|
||||
required: true
|
||||
argument-hint: <output> [TARGET=<value>]
|
||||
---
|
||||
|
||||
Body here.`;
|
||||
|
||||
const result = parseFrontmatter(content);
|
||||
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');
|
||||
expect(result.frontmatter.args[0].description).toBe('The target to normalize');
|
||||
expect(result.frontmatter.args[0].required).toBe(false);
|
||||
expect(result.frontmatter.args[1].required).toBe(true);
|
||||
expect(result.frontmatter['argument-hint']).toBe('<output> [TARGET=<value>]');
|
||||
});
|
||||
|
||||
test('should return empty frontmatter when no frontmatter present', () => {
|
||||
@@ -89,29 +78,29 @@ Skill body.`;
|
||||
expect(result.frontmatter.license).toBe('MIT');
|
||||
});
|
||||
|
||||
test('should parse user-invokable boolean', () => {
|
||||
test('should parse user-invocable boolean', () => {
|
||||
const content = `---
|
||||
name: test-skill
|
||||
user-invokable: true
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
Body.`;
|
||||
|
||||
const result = parseFrontmatter(content);
|
||||
expect(result.frontmatter['user-invokable']).toBe(true);
|
||||
expect(result.frontmatter['user-invocable']).toBe(true);
|
||||
});
|
||||
|
||||
test('should parse user-invokable as string true (code behavior)', () => {
|
||||
test('should parse user-invocable as string true (code behavior)', () => {
|
||||
const content = `---
|
||||
name: test-skill
|
||||
user-invokable: 'true'
|
||||
user-invocable: 'true'
|
||||
---
|
||||
|
||||
Body.`;
|
||||
|
||||
const result = parseFrontmatter(content);
|
||||
// The parseFrontmatter function doesn't strip quotes from YAML string values
|
||||
expect(result.frontmatter['user-invokable']).toBe("'true'");
|
||||
// parseFrontmatter strips YAML quotes, so 'true' becomes boolean true
|
||||
expect(result.frontmatter['user-invocable']).toBe(true);
|
||||
});
|
||||
|
||||
test('should parse allowed-tools field', () => {
|
||||
@@ -140,42 +129,33 @@ describe('generateYamlFrontmatter', () => {
|
||||
expect(result).toContain('description: A test');
|
||||
});
|
||||
|
||||
test('should generate frontmatter with args array', () => {
|
||||
test('should generate frontmatter with argument-hint', () => {
|
||||
const data = {
|
||||
name: 'test',
|
||||
description: 'Test skill',
|
||||
args: [
|
||||
{ name: 'target', description: 'The target', required: false },
|
||||
{ name: 'output', description: 'Output format', required: true }
|
||||
]
|
||||
'argument-hint': '<output> [TARGET=<value>]'
|
||||
};
|
||||
|
||||
const result = generateYamlFrontmatter(data);
|
||||
expect(result).toContain('args:');
|
||||
expect(result).toContain('- name: target');
|
||||
expect(result).toContain('description: The target');
|
||||
expect(result).toContain('required: false');
|
||||
expect(result).toContain('required: true');
|
||||
expect(result).toContain('argument-hint: <output> [TARGET=<value>]');
|
||||
});
|
||||
|
||||
test('should generate frontmatter with boolean', () => {
|
||||
const data = {
|
||||
name: 'test',
|
||||
description: 'Test',
|
||||
'user-invokable': true
|
||||
'user-invocable': true
|
||||
};
|
||||
|
||||
const result = generateYamlFrontmatter(data);
|
||||
expect(result).toContain('user-invokable: true');
|
||||
expect(result).toContain('user-invocable: true');
|
||||
});
|
||||
|
||||
test('should roundtrip: generate and parse back', () => {
|
||||
const original = {
|
||||
name: 'roundtrip-test',
|
||||
description: 'Testing roundtrip',
|
||||
args: [
|
||||
{ name: 'arg1', description: 'First arg', required: true }
|
||||
]
|
||||
'argument-hint': '<arg1>'
|
||||
};
|
||||
|
||||
const yaml = generateYamlFrontmatter(original);
|
||||
@@ -184,8 +164,7 @@ describe('generateYamlFrontmatter', () => {
|
||||
|
||||
expect(parsed.frontmatter.name).toBe(original.name);
|
||||
expect(parsed.frontmatter.description).toBe(original.description);
|
||||
expect(parsed.frontmatter.args).toBeArray();
|
||||
expect(parsed.frontmatter.args[0].name).toBe('arg1');
|
||||
expect(parsed.frontmatter['argument-hint']).toBe('<arg1>');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -372,11 +351,11 @@ Skill instructions here.`;
|
||||
expect(skills[0].body).toBe('Skill instructions here.');
|
||||
});
|
||||
|
||||
test('should read skill with user-invokable flag', () => {
|
||||
test('should read skill with user-invocable flag', () => {
|
||||
const skillContent = `---
|
||||
name: audit
|
||||
description: Run technical quality checks
|
||||
user-invokable: true
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
Audit the code.`;
|
||||
@@ -388,7 +367,7 @@ Audit the code.`;
|
||||
const { skills } = readSourceFiles(testRootDir);
|
||||
|
||||
expect(skills).toHaveLength(1);
|
||||
expect(skills[0].userInvokable).toBe(true);
|
||||
expect(skills[0].userInvocable).toBe(true);
|
||||
});
|
||||
|
||||
test('should read skill with reference files', () => {
|
||||
@@ -478,7 +457,7 @@ name: test-skill
|
||||
description: A comprehensive test skill
|
||||
license: Apache-2.0
|
||||
compatibility: claude-code
|
||||
user-invokable: true
|
||||
user-invocable: true
|
||||
allowed-tools: Bash,Edit
|
||||
---
|
||||
|
||||
@@ -494,7 +473,7 @@ Body content.`;
|
||||
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].userInvocable).toBe(true);
|
||||
expect(skills[0].allowedTools).toBe('Bash,Edit');
|
||||
});
|
||||
});
|
||||
@@ -677,7 +656,7 @@ describe('prefixSkillReferences', () => {
|
||||
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');
|
||||
expect(result).toContain('The i-audit skill');
|
||||
});
|
||||
|
||||
test('should not partially match longer skill names', () => {
|
||||
@@ -687,8 +666,7 @@ describe('prefixSkillReferences', () => {
|
||||
|
||||
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.');
|
||||
expect(result).toBe('The i-audit skill is useful.');
|
||||
});
|
||||
|
||||
test('should return content unchanged with empty prefix', () => {
|
||||
|
||||
Reference in New Issue
Block a user