diff --git a/.gitignore b/.gitignore index a014e003..deacac5d 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,6 @@ tests/tmp/ # Worktrees .worktrees/ + +# Sisyphus (OMO) +.sisyphus/ diff --git a/README.md b/README.md index 39d6941b..97654a3f 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,9 @@ Quick shortcuts to invoke workflows: - `/deepen-plan` - Add detail to existing plans - `/lfg` - Let's go - start working immediately -### Review Agents +### Agents -Specialized code review agents organized by category: +Specialized agents organized by category: **Review:** @@ -98,42 +98,6 @@ Create `~/.config/opencode/systematic.json` or `.opencode/systematic.json` to di } ``` -## Converting CEP Content - -The CLI includes a converter for adapting Claude Code agents, skills, and commands from Compound Engineering Plugin (CEP) to OpenCode. - -### Convert a Skill - -Skills are directories containing `SKILL.md` and supporting files: - -```bash -npx @fro.bot/systematic convert skill /path/to/cep/skills/my-skill -o ./skills/my-skill -``` - -### Convert an Agent - -Agents are markdown files that get OpenCode-compatible YAML frontmatter: - -```bash -npx @fro.bot/systematic convert agent /path/to/cep/agents/review/my-agent.md -o ./agents/review/my-agent.md -``` - -### Convert a Command - -Commands are markdown templates: - -```bash -npx @fro.bot/systematic convert command /path/to/cep/commands/my-command.md -o ./commands/my-command.md -``` - -### Dry Run - -Preview conversion without writing files: - -```bash -npx @fro.bot/systematic convert skill /path/to/skill --dry-run -``` - ## Development ```bash diff --git a/src/cli.ts b/src/cli.ts index dfbfda20..71dc5013 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,7 +1,11 @@ #!/usr/bin/env node import fs from 'node:fs' import path from 'node:path' -import * as converter from './lib/converter.js' +import { + type AgentMode, + type ContentType, + convertContent, +} from './lib/converter.js' import * as skillsCore from './lib/skills-core.js' const VERSION = '0.1.0' @@ -14,23 +18,23 @@ Usage: Commands: list [type] List available skills, agents, or commands - convert [--output ] [--dry-run] - Convert Claude Code content to OpenCode format - Types: skill, agent, command + convert [--mode=primary|subagent] + Convert and inspect a file (outputs to stdout) config [subcommand] Configuration management show Show configuration path Print config file locations Options: - --output, -o Output path for convert command - --dry-run Preview conversion without writing files -h, --help Show this help message -v, --version Show version Examples: systematic list skills - systematic convert skill /path/to/cep/skills/agent-browser -o ./skills/agent-browser - systematic convert agent /path/to/agent.md --dry-run + systematic list agents + systematic convert agent ./agents/my-agent.md + systematic convert agent ./agents/my-agent.md --mode=primary + systematic convert skill ./skills/my-skill/SKILL.md + systematic config show ` function getUserConfigDir(): string { @@ -85,6 +89,40 @@ function listItems(type: string): void { } } +function runConvert(type: string, filePath: string, modeArg?: string): void { + const validTypes = ['skill', 'agent', 'command'] + if (!validTypes.includes(type)) { + console.error( + `Invalid type: ${type}. Must be one of: ${validTypes.join(', ')}`, + ) + process.exit(1) + } + + const resolvedPath = path.resolve(filePath) + if (!fs.existsSync(resolvedPath)) { + console.error(`File not found: ${resolvedPath}`) + process.exit(1) + } + + let agentMode: AgentMode = 'subagent' + if (modeArg) { + const modeMatch = modeArg.match(/^--mode=(primary|subagent)$/) + if (modeMatch) { + agentMode = modeMatch[1] as AgentMode + } else { + console.error( + 'Invalid --mode flag. Use: --mode=primary or --mode=subagent', + ) + process.exit(1) + } + } + + const content = fs.readFileSync(resolvedPath, 'utf8') + const converted = convertContent(content, type as ContentType, { agentMode }) + + console.log(converted) +} + function configShow(): void { const userDir = getUserConfigDir() const projectDir = getProjectConfigDir() @@ -115,61 +153,6 @@ function configPath(): void { console.log(` Project: ${path.join(projectDir, 'systematic.json')}`) } -function runConvert(args: string[]): void { - const typeArg = args[1] - const sourceArg = args[2] - - if (!typeArg || !sourceArg) { - console.error( - 'Usage: systematic convert [--output ] [--dry-run]', - ) - console.error('Types: skill, agent, command') - process.exit(1) - } - - const validTypes = ['skill', 'agent', 'command'] - if (!validTypes.includes(typeArg)) { - console.error( - `Invalid type: ${typeArg}. Must be one of: ${validTypes.join(', ')}`, - ) - process.exit(1) - } - - const sourcePath = path.resolve(sourceArg) - if (!fs.existsSync(sourcePath)) { - console.error(`Source not found: ${sourcePath}`) - process.exit(1) - } - - const outputIndex = args.findIndex((a) => a === '--output' || a === '-o') - const outputPath = - outputIndex !== -1 ? path.resolve(args[outputIndex + 1]) : undefined - const dryRun = args.includes('--dry-run') - - try { - const result = converter.convert( - typeArg as converter.ConvertType, - sourcePath, - { output: outputPath, dryRun }, - ) - - if (dryRun) { - console.log(`[DRY RUN] Would convert ${result.type}:`) - } else { - console.log(`Converted ${result.type}:`) - } - console.log(` Source: ${result.sourcePath}`) - console.log(` Output: ${result.outputPath}`) - console.log(' Files:') - for (const file of result.files) { - console.log(` - ${file}`) - } - } catch (err) { - console.error(`Conversion failed: ${(err as Error).message}`) - process.exit(1) - } -} - const args = process.argv.slice(2) const command = args[0] @@ -178,7 +161,14 @@ switch (command) { listItems(args[1] || 'skills') break case 'convert': - runConvert(args) + if (!args[1] || !args[2]) { + console.error( + 'Usage: systematic convert [--mode=primary|subagent]', + ) + console.error(' type: skill, agent, or command') + process.exit(1) + } + runConvert(args[1], args[2], args[3]) break case 'config': switch (args[1]) { diff --git a/src/lib/config-handler.ts b/src/lib/config-handler.ts index ceed2a3d..86f17374 100644 --- a/src/lib/config-handler.ts +++ b/src/lib/config-handler.ts @@ -1,6 +1,6 @@ -import fs from 'node:fs' import type { AgentConfig, Config } from '@opencode-ai/sdk' import { loadConfig } from './config.js' +import { convertFileWithCache } from './converter.js' import * as skillsCore from './skills-core.js' export interface ConfigHandlerDeps { @@ -16,12 +16,15 @@ function loadAgentAsConfig( agentInfo: { name: string; file: string; sourceType: string; category?: string } ): AgentConfig | null { try { - const content = fs.readFileSync(agentInfo.file, 'utf8') - const { name, description, prompt } = skillsCore.extractAgentFrontmatter(content) + const converted = convertFileWithCache(agentInfo.file, 'agent', { + source: 'bundled', + agentMode: 'subagent', + }) + const { description, prompt } = skillsCore.extractAgentFrontmatter(converted) return { - description: description || `${name || agentInfo.name} agent`, - prompt: prompt || skillsCore.stripFrontmatter(content), + description: description || `${agentInfo.name} agent`, + prompt: prompt || skillsCore.stripFrontmatter(converted), } } catch { return null @@ -32,13 +35,13 @@ function loadCommandAsConfig( commandInfo: { name: string; file: string; sourceType: string; category?: string } ): CommandConfig | null { try { - const content = fs.readFileSync(commandInfo.file, 'utf8') - const { name, description } = skillsCore.extractCommandFrontmatter(content) + const converted = convertFileWithCache(commandInfo.file, 'command', { source: 'bundled' }) + const { name, description } = skillsCore.extractCommandFrontmatter(converted) const cleanName = commandInfo.name.replace(/^\//, '') return { - template: skillsCore.stripFrontmatter(content), + template: skillsCore.stripFrontmatter(converted), description: description || `${name || cleanName} command`, } } catch { @@ -50,10 +53,10 @@ function loadSkillAsCommand( skillInfo: skillsCore.SkillInfo ): CommandConfig | null { try { - const content = fs.readFileSync(skillInfo.skillFile, 'utf8') + const converted = convertFileWithCache(skillInfo.skillFile, 'skill', { source: 'bundled' }) return { - template: skillsCore.stripFrontmatter(content), + template: skillsCore.stripFrontmatter(converted), description: skillInfo.description || `${skillInfo.name} skill`, } } catch { diff --git a/src/lib/converter.ts b/src/lib/converter.ts index ae9a2a42..0b1f0c4f 100644 --- a/src/lib/converter.ts +++ b/src/lib/converter.ts @@ -1,32 +1,31 @@ import fs from 'node:fs' -import path from 'node:path' -export type ConvertType = 'skill' | 'agent' | 'command' +export type ContentType = 'skill' | 'agent' | 'command' +export type SourceType = 'bundled' | 'external' +export type AgentMode = 'primary' | 'subagent' export interface ConvertOptions { - dryRun?: boolean - output?: string + source?: SourceType + agentMode?: AgentMode } -export interface ConvertResult { - type: ConvertType - sourcePath: string - outputPath: string - converted: boolean - files: string[] +interface CacheEntry { + mtimeMs: number + converted: string } -interface FrontmatterData { - description?: string - mode?: string - model?: string - temperature?: number +const cache = new Map() + +interface ParsedFrontmatter { + data: Record + body: string + raw: string } -function parseFrontmatter(raw: string): { data: Record; body: string } { - const lines = raw.split(/\r?\n/) +function parseFrontmatter(content: string): ParsedFrontmatter { + const lines = content.split(/\r?\n/) if (lines.length === 0 || lines[0].trim() !== '---') { - return { data: {}, body: raw } + return { data: {}, body: content, raw: '' } } let endIndex = -1 @@ -38,15 +37,16 @@ function parseFrontmatter(raw: string): { data: Record; body: s } if (endIndex === -1) { - return { data: {}, body: raw } + return { data: {}, body: content, raw: '' } } const yamlLines = lines.slice(1, endIndex) const body = lines.slice(endIndex + 1).join('\n') - const data: Record = {} + const raw = lines.slice(0, endIndex + 1).join('\n') + const data: Record = {} for (const line of yamlLines) { - const match = line.match(/^(\w+):\s*(.*)$/) + const match = line.match(/^([\w-]+):\s*(.*)$/) if (match) { const [, key, value] = match if (value === 'true') data[key] = true @@ -56,23 +56,20 @@ function parseFrontmatter(raw: string): { data: Record; body: s } } - return { data, body } + return { data, body, raw } } -function formatFrontmatter(data: FrontmatterData, body: string): string { - const lines: string[] = [] - if (data.description) lines.push(`description: ${data.description}`) - if (data.mode) lines.push(`mode: ${data.mode}`) - if (data.model) lines.push(`model: ${data.model}`) - if (data.temperature !== undefined) lines.push(`temperature: ${data.temperature}`) - - if (lines.length === 0) return body - - return ['---', ...lines, '---', '', body].join('\n') +function formatFrontmatter(data: Record): string { + const lines: string[] = ['---'] + for (const [key, value] of Object.entries(data)) { + lines.push(`${key}: ${value}`) + } + lines.push('---') + return lines.join('\n') } function inferTemperature(name: string, description?: string): number { - const sample = `${name} ${description || ''}`.toLowerCase() + const sample = `${name} ${description ?? ''}`.toLowerCase() if (/(review|audit|security|sentinel|oracle|lint|verification|guardian)/.test(sample)) { return 0.1 } @@ -90,148 +87,86 @@ function inferTemperature(name: string, description?: string): number { function normalizeModel(model: string): string { if (model.includes('/')) return model + if (model === 'inherit') return model if (/^claude-/.test(model)) return `anthropic/${model}` if (/^(gpt-|o1-|o3-)/.test(model)) return `openai/${model}` if (/^gemini-/.test(model)) return `google/${model}` return `anthropic/${model}` } -export function convertAgent(sourcePath: string, options: ConvertOptions = {}): ConvertResult { - const content = fs.readFileSync(sourcePath, 'utf-8') - const name = path.basename(sourcePath, '.md') - const { data, body } = parseFrontmatter(content) - - const existingDescription = data.description as string | undefined - const existingModel = data.model as string | undefined - - let description = existingDescription - if (!description) { - const firstLine = body.split('\n').find(l => l.trim() && !l.startsWith('#')) - description = firstLine?.slice(0, 100) || `${name} agent` - } +function transformAgentFrontmatter( + data: Record, + agentMode: AgentMode +): Record { + const name = typeof data.name === 'string' ? data.name : '' + const description = typeof data.description === 'string' ? data.description : '' - const frontmatter: FrontmatterData = { - description, - mode: 'subagent', - temperature: inferTemperature(name, description), + const newData: Record = { + description: description || `${name} agent`, + mode: agentMode, } - if (existingModel && existingModel !== 'inherit') { - frontmatter.model = normalizeModel(existingModel) + if (typeof data.model === 'string' && data.model !== 'inherit') { + newData.model = normalizeModel(data.model) } - const converted = formatFrontmatter(frontmatter, body.trim()) - const outputPath = options.output || sourcePath - - if (!options.dryRun) { - fs.mkdirSync(path.dirname(outputPath), { recursive: true }) - fs.writeFileSync(outputPath, converted) - } - - return { - type: 'agent', - sourcePath, - outputPath, - converted: true, - files: [path.basename(outputPath)], - } -} - -export function convertCommand(sourcePath: string, options: ConvertOptions = {}): ConvertResult { - const content = fs.readFileSync(sourcePath, 'utf-8') - const outputPath = options.output || sourcePath - - if (!options.dryRun) { - fs.mkdirSync(path.dirname(outputPath), { recursive: true }) - fs.writeFileSync(outputPath, content) + if (typeof data.temperature === 'number') { + newData.temperature = data.temperature + } else { + newData.temperature = inferTemperature(name, description) } - return { - type: 'command', - sourcePath, - outputPath, - converted: true, - files: [path.basename(outputPath)], - } + return newData } -export function convertSkill(sourcePath: string, options: ConvertOptions = {}): ConvertResult { - const stats = fs.statSync(sourcePath) - if (!stats.isDirectory()) { - throw new Error(`Skill source must be a directory: ${sourcePath}`) - } +export function convertContent( + content: string, + type: ContentType, + options: ConvertOptions = {} +): string { + if (content === '') return '' - const skillName = path.basename(sourcePath) - const outputPath = options.output || sourcePath - const files: string[] = [] + const { data, body, raw } = parseFrontmatter(content) + const hasFrontmatter = raw !== '' - function copyDir(src: string, dest: string): void { - if (!options.dryRun) { - fs.mkdirSync(dest, { recursive: true }) - } - - for (const entry of fs.readdirSync(src, { withFileTypes: true })) { - const srcPath = path.join(src, entry.name) - const destPath = path.join(dest, entry.name) - - if (entry.isDirectory()) { - copyDir(srcPath, destPath) - } else { - files.push(path.relative(outputPath, destPath)) - if (!options.dryRun) { - fs.copyFileSync(srcPath, destPath) - } - } - } + if (!hasFrontmatter) { + return content } - copyDir(sourcePath, outputPath) - - return { - type: 'skill', - sourcePath, - outputPath, - converted: true, - files, + if (type === 'agent') { + const agentMode = options.agentMode ?? 'subagent' + const transformedData = transformAgentFrontmatter(data, agentMode) + return `${formatFrontmatter(transformedData)}\n${body}` } -} -export function convert( - type: ConvertType, - sourcePath: string, - options: ConvertOptions = {}, -): ConvertResult { - switch (type) { - case 'agent': - return convertAgent(sourcePath, options) - case 'command': - return convertCommand(sourcePath, options) - case 'skill': - return convertSkill(sourcePath, options) - default: - throw new Error(`Unknown type: ${type}`) - } + return content } -export function detectType(sourcePath: string): ConvertType | null { - const stats = fs.statSync(sourcePath) - - if (stats.isDirectory()) { - if (fs.existsSync(path.join(sourcePath, 'SKILL.md'))) { - return 'skill' +export function convertFileWithCache( + filePath: string, + type: ContentType, + options: ConvertOptions = {} +): string { + const fd = fs.openSync(filePath, 'r') + try { + const stats = fs.fstatSync(fd) + const cacheKey = `${filePath}:${type}:${options.source ?? 'bundled'}:${options.agentMode ?? 'subagent'}` + const cached = cache.get(cacheKey) + + if (cached != null && cached.mtimeMs === stats.mtimeMs) { + return cached.converted } - return null - } - if (!sourcePath.endsWith('.md')) return null + const content = fs.readFileSync(fd, 'utf8') + const converted = convertContent(content, type, options) - const parentDir = path.basename(path.dirname(sourcePath)) - if (parentDir === 'agents' || ['review', 'research', 'design', 'docs', 'workflow'].includes(parentDir)) { - return 'agent' - } - if (parentDir === 'commands' || parentDir === 'workflows') { - return 'command' + cache.set(cacheKey, { mtimeMs: stats.mtimeMs, converted }) + return converted + } finally { + fs.closeSync(fd) } +} - return null +export function clearConverterCache(): void { + cache.clear() } diff --git a/src/lib/skill-tool.ts b/src/lib/skill-tool.ts index 11efdb8a..a8a72f2e 100644 --- a/src/lib/skill-tool.ts +++ b/src/lib/skill-tool.ts @@ -2,6 +2,7 @@ import fs from 'node:fs' import path from 'node:path' import type { ToolDefinition } from '@opencode-ai/plugin' import { tool } from '@opencode-ai/plugin/tool' +import { convertContent } from './converter.js' import type { SkillInfo } from './skills-core.js' import { findSkillsInDir, stripFrontmatter } from './skills-core.js' @@ -104,14 +105,15 @@ function mergeDescriptions( function wrapSkillContent(skillPath: string, content: string): string { const skillDir = path.dirname(skillPath) - const body = stripFrontmatter(content) + const converted = convertContent(content, 'skill', { source: 'bundled' }) + const body = stripFrontmatter(converted) - return ` + return ` Base directory for this skill: ${skillDir}/ File references (@path) in this skill are relative to this directory. ${body.trim()} -` +` } export interface SkillToolOptions { diff --git a/tests/integration/opencode.test.ts b/tests/integration/opencode.test.ts index 4b31fd30..56540c1d 100644 --- a/tests/integration/opencode.test.ts +++ b/tests/integration/opencode.test.ts @@ -105,7 +105,7 @@ describe.skipIf(!OPENCODE_AVAILABLE)('opencode integration', () => { ) expect(result.stdout).toMatch( - /|brainstorming|systematic/i, + /|brainstorm|systematic|skill loaded/i, ) }, TIMEOUT_MS * MAX_RETRIES, diff --git a/tests/unit/converter.test.ts b/tests/unit/converter.test.ts new file mode 100644 index 00000000..1e080ed3 --- /dev/null +++ b/tests/unit/converter.test.ts @@ -0,0 +1,312 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { + clearConverterCache, + convertContent, + convertFileWithCache, +} from '../../src/lib/converter.ts' + +describe('converter', () => { + describe('convertContent', () => { + describe('Agent frontmatter transformation', () => { + test('removes name field and adds mode field', () => { + const input = `--- +name: security-sentinel +description: Security review agent +--- +Agent content` + const result = convertContent(input, 'agent') + expect(result).not.toContain('name:') + expect(result).toContain('mode: subagent') + expect(result).toContain('description: Security review agent') + }) + + test('uses agentMode option when provided', () => { + const input = `--- +name: my-agent +description: Primary agent +--- +Content` + const result = convertContent(input, 'agent', { agentMode: 'primary' }) + expect(result).toContain('mode: primary') + }) + + test('defaults to subagent mode', () => { + const input = `--- +name: my-agent +description: Some agent +--- +Content` + const result = convertContent(input, 'agent') + expect(result).toContain('mode: subagent') + }) + + test('normalizes unprefixed claude model', () => { + const input = `--- +name: test-agent +description: Test +model: claude-sonnet-4-20250514 +--- +Content` + const result = convertContent(input, 'agent') + expect(result).toContain('model: anthropic/claude-sonnet-4-20250514') + }) + + test('normalizes unprefixed openai model', () => { + const input = `--- +name: test-agent +description: Test +model: gpt-4o +--- +Content` + const result = convertContent(input, 'agent') + expect(result).toContain('model: openai/gpt-4o') + }) + + test('normalizes unprefixed gemini model', () => { + const input = `--- +name: test-agent +description: Test +model: gemini-2.0-flash +--- +Content` + const result = convertContent(input, 'agent') + expect(result).toContain('model: google/gemini-2.0-flash') + }) + + test('preserves already-prefixed model', () => { + const input = `--- +name: test-agent +description: Test +model: anthropic/claude-sonnet-4-20250514 +--- +Content` + const result = convertContent(input, 'agent') + expect(result).toContain('model: anthropic/claude-sonnet-4-20250514') + }) + + test('does not include model: inherit in output', () => { + const input = `--- +name: test-agent +description: Test +model: inherit +--- +Content` + const result = convertContent(input, 'agent') + expect(result).not.toContain('model:') + }) + }) + + describe('Agent temperature inference', () => { + test('infers low temperature for security agents', () => { + const input = `--- +name: security-sentinel +description: Security review agent +--- +Content` + const result = convertContent(input, 'agent') + expect(result).toContain('temperature: 0.1') + }) + + test('infers medium-low temperature for planning agents', () => { + const input = `--- +name: architecture-strategist +description: Architecture planning +--- +Content` + const result = convertContent(input, 'agent') + expect(result).toContain('temperature: 0.2') + }) + + test('infers medium temperature for documentation agents', () => { + const input = `--- +name: readme-writer +description: Documentation writer +--- +Content` + const result = convertContent(input, 'agent') + expect(result).toContain('temperature: 0.3') + }) + + test('infers higher temperature for creative agents', () => { + const input = `--- +name: brainstorm-helper +description: Creative brainstorming +--- +Content` + const result = convertContent(input, 'agent') + expect(result).toContain('temperature: 0.6') + }) + + test('preserves explicit temperature', () => { + const input = `--- +name: security-sentinel +description: Security +temperature: 0.5 +--- +Content` + const result = convertContent(input, 'agent') + expect(result).toContain('temperature: 0.5') + expect(result).not.toContain('temperature: 0.1') + }) + }) + + describe('Skills and commands - no transformation', () => { + test('returns skill content unchanged', () => { + const input = `--- +name: my-skill +description: A skill +--- +Skill content` + const result = convertContent(input, 'skill') + expect(result).toBe(input) + }) + + test('returns command content unchanged', () => { + const input = `--- +name: my-command +description: A command +--- +Command content` + const result = convertContent(input, 'command') + expect(result).toBe(input) + }) + }) + + describe('Body content - no transformation', () => { + test('agent body is not transformed', () => { + const input = `--- +name: test-agent +description: Test +--- +Use TodoWrite to track. Task explorer(find files). Use Skill tool.` + const result = convertContent(input, 'agent') + expect(result).toContain('Use TodoWrite to track') + expect(result).toContain('Task explorer(find files)') + expect(result).toContain('Use Skill tool') + }) + }) + + describe('Content without frontmatter', () => { + test('returns content unchanged if no frontmatter', () => { + const input = `# No Frontmatter +Just plain content` + const result = convertContent(input, 'skill') + expect(result).toBe(input) + }) + + test('handles empty content', () => { + const result = convertContent('', 'skill') + expect(result).toBe('') + }) + }) + + describe('Combined transformations', () => { + test('produces correct output format for agent', () => { + const input = `--- +name: review-agent +description: Code review agent +model: claude-sonnet-4-20250514 +--- +# Review Agent + +Use TodoWrite to track.` + const result = convertContent(input, 'agent') + + expect(result).toContain('description: Code review agent') + expect(result).toContain('mode: subagent') + expect(result).toContain('model: anthropic/claude-sonnet-4-20250514') + expect(result).toContain('temperature: 0.1') + expect(result).not.toContain('name:') + expect(result).toContain('# Review Agent') + expect(result).toContain('Use TodoWrite to track') + }) + }) + }) + + describe('convertFileWithCache', () => { + let testDir: string + + beforeEach(() => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'converter-test-')) + clearConverterCache() + }) + + afterEach(() => { + fs.rmSync(testDir, { recursive: true, force: true }) + clearConverterCache() + }) + + test('converts file content and returns result', () => { + const filePath = path.join(testDir, 'test.md') + fs.writeFileSync( + filePath, + `--- +name: test-agent +description: Test +model: claude-sonnet-4-20250514 +--- +Content`, + ) + + const result = convertFileWithCache(filePath, 'agent') + expect(result).toContain('mode: subagent') + expect(result).toContain('model: anthropic/claude-sonnet-4-20250514') + }) + + test('caches result for same file and mtime', () => { + const filePath = path.join(testDir, 'test.md') + fs.writeFileSync( + filePath, + `--- +name: test-agent +description: Test +--- +Content`, + ) + + const result1 = convertFileWithCache(filePath, 'agent') + const result2 = convertFileWithCache(filePath, 'agent') + expect(result2).toBe(result1) + }) + + test('invalidates cache when file mtime changes', async () => { + const filePath = path.join(testDir, 'test.md') + fs.writeFileSync( + filePath, + `--- +name: test-agent +description: Test +model: claude-sonnet-4-20250514 +--- +Content`, + ) + + const result1 = convertFileWithCache(filePath, 'agent') + expect(result1).toContain('model: anthropic/claude-sonnet-4-20250514') + + await new Promise((resolve) => setTimeout(resolve, 50)) + + fs.writeFileSync( + filePath, + `--- +name: test-agent +description: Test +model: gpt-4o +--- +Content`, + ) + + const result2 = convertFileWithCache(filePath, 'agent') + expect(result2).toContain('model: openai/gpt-4o') + }) + + test('throws for non-existent file', () => { + const filePath = path.join(testDir, 'nonexistent.md') + expect(() => convertFileWithCache(filePath, 'skill')).toThrow() + }) + }) +}) diff --git a/tests/unit/skill-tool.test.ts b/tests/unit/skill-tool.test.ts index bc58c666..6899d937 100644 --- a/tests/unit/skill-tool.test.ts +++ b/tests/unit/skill-tool.test.ts @@ -169,7 +169,7 @@ This is the skill content.`, ) expect(result).toContain('systematic:load-test') - expect(result).toContain('') + expect(result).toContain('') expect(result).toContain('# Load Test Skill') expect(result).toContain('This is the skill content.') }) @@ -269,7 +269,7 @@ No frontmatter visible here.`, expect(result).toContain('# Actual Content') }) - test('wraps content with skill_instruction tags', async () => { + test('wraps content with skill-instruction tags', async () => { const skillDir = path.join(testDir, 'wrap-test') fs.mkdirSync(skillDir) fs.writeFileSync( @@ -288,8 +288,8 @@ description: Test wrapper const result = await tool.execute({ name: 'wrap-test' }, mockContext) - expect(result).toContain('') - expect(result).toContain('') + expect(result).toContain('') + expect(result).toContain('') expect(result).toContain('Base directory for this skill:') }) })