diff --git a/docs/CONVERSION-GUIDE.md b/docs/CONVERSION-GUIDE.md index 165d08fd..8cf25dc3 100644 --- a/docs/CONVERSION-GUIDE.md +++ b/docs/CONVERSION-GUIDE.md @@ -47,6 +47,14 @@ | `compatibility` | `compatibility` | **Keep** (optional) | | `metadata` | `metadata` | **Keep** (optional) | +**Systematic compatibility note:** Systematic reads skill frontmatter directly for its own tooling. CC-only fields are still accepted for bundled skills to power **systematic_skill** and **skills-as-commands** behavior, but they are not emitted into OpenCode's native skill definitions. + +- `disable-model-invocation`: hides skills from the Systematic skill tool list. +- `user-invocable`: controls whether a skill is exposed as a command (skills-as-commands only). +- `context: fork`: maps to `subtask` when a skill is exposed as a command. +- `agent` / `model`: routed through when a skill is exposed as a command. +- `allowed-tools`: reserved for future use (stored but not enforced). + #### Example Transformation **Before (Claude Code):** diff --git a/src/lib/config-handler.ts b/src/lib/config-handler.ts index 9c3d21a2..dbc01f64 100644 --- a/src/lib/config-handler.ts +++ b/src/lib/config-handler.ts @@ -92,10 +92,16 @@ function loadCommandAsConfig(commandInfo: { } function loadSkillAsCommand(loaded: LoadedSkill): CommandConfig { - return { + const config: CommandConfig = { template: loaded.wrappedTemplate, description: loaded.description, } + + if (loaded.agent !== undefined) config.agent = loaded.agent + if (loaded.model !== undefined) config.model = loaded.model + if (loaded.subtask !== undefined) config.subtask = loaded.subtask + + return config } function collectAgents( @@ -149,6 +155,8 @@ function collectSkillsAsCommands( const loaded = loadSkill(skillInfo) if (loaded) { + if (loaded.userInvocable === false) continue + commands[loaded.prefixedName] = loadSkillAsCommand(loaded) } } diff --git a/src/lib/skill-loader.ts b/src/lib/skill-loader.ts index a30f31fb..97d79d12 100644 --- a/src/lib/skill-loader.ts +++ b/src/lib/skill-loader.ts @@ -13,6 +13,12 @@ export interface LoadedSkill { path: string skillFile: string wrappedTemplate: string + disableModelInvocation?: boolean + userInvocable?: boolean + subtask?: boolean + agent?: string + model?: string + argumentHint?: string } export function formatSkillCommandName(name: string): string { @@ -72,6 +78,12 @@ export function loadSkill(skillInfo: SkillInfo): LoadedSkill | null { path: skillInfo.path, skillFile: skillInfo.skillFile, wrappedTemplate, + disableModelInvocation: skillInfo.disableModelInvocation, + userInvocable: skillInfo.userInvocable, + subtask: skillInfo.subtask, + agent: skillInfo.agent, + model: skillInfo.model, + argumentHint: skillInfo.argumentHint, } } catch { return null diff --git a/src/lib/skill-tool.ts b/src/lib/skill-tool.ts index fef6d7cf..a98132b8 100644 --- a/src/lib/skill-tool.ts +++ b/src/lib/skill-tool.ts @@ -6,13 +6,32 @@ import { type LoadedSkill, loadSkill, } from './skill-loader.js' -import { findSkillsInDir, formatSkillsXml } from './skills.js' +import { findSkillsInDir, type SkillInfo } from './skills.js' export interface SkillToolOptions { bundledSkillsDir: string disabledSkills: string[] } +/** + * Formats skills as XML for tool description. + * Uses indented format matching OpenCode's native skill tool. + */ +export function formatSkillsXml(skills: SkillInfo[]): string { + if (skills.length === 0) return '' + + // Match OpenCode's native skill tool format exactly: + // Uses space-delimited join with indented XML structure + const skillLines = skills.flatMap((skill) => [ + ' ', + ` systematic:${skill.name}`, + ` ${skill.description}`, + ' ', + ]) + + return ['', ...skillLines, ''].join(' ') +} + export function createSkillTool(options: SkillToolOptions): ToolDefinition { const { bundledSkillsDir, disabledSkills } = options @@ -21,11 +40,17 @@ export function createSkillTool(options: SkillToolOptions): ToolDefinition { .filter((s) => !disabledSkills.includes(s.name)) .map((skillInfo) => loadSkill(skillInfo)) .filter((s): s is LoadedSkill => s !== null) + .filter((s) => s.disableModelInvocation !== true) .sort((a, b) => a.name.localeCompare(b.name)) } const buildDescription = (): string => { const skills = getSystematicSkills() + + if (skills.length === 0) { + return 'Load a skill to get detailed instructions for a specific task. No skills are currently available.' + } + const skillInfos = skills.map((s) => ({ name: s.name, description: s.description, @@ -34,15 +59,27 @@ export function createSkillTool(options: SkillToolOptions): ToolDefinition { })) const systematicXml = formatSkillsXml(skillInfos) - const baseDescription = `Load a skill to get detailed instructions for a specific task. - -Skills provide specialized knowledge and step-by-step guidance. -Use this when a task matches an available skill's description.` + return [ + 'Load a skill to get detailed instructions for a specific task.', + 'Skills provide specialized knowledge and step-by-step guidance.', + "Use this when a task matches an available skill's description.", + 'Only the skills listed here are available:', + systematicXml, + ].join(' ') + } - return `${baseDescription}\n\n${systematicXml}` + const buildParameterHint = (): string => { + const skills = getSystematicSkills() + const examples = skills + .slice(0, 3) + .map((s) => `'systematic:${s.name}'`) + .join(', ') + const hint = examples.length > 0 ? ` (e.g., ${examples}, ...)` : '' + return `The skill identifier from available_skills${hint}` } let cachedDescription: string | null = null + let cachedParameterHint: string | null = null return tool({ get description() { @@ -52,13 +89,16 @@ Use this when a task matches an available skill's description.` return cachedDescription }, args: { - name: tool.schema - .string() - .describe( - "The skill identifier from available_skills (e.g., 'systematic:brainstorming')", - ), + name: tool.schema.string().describe( + (() => { + if (cachedParameterHint == null) { + cachedParameterHint = buildParameterHint() + } + return cachedParameterHint + })(), + ), }, - async execute(args: { name: string }): Promise { + async execute(args: { name: string }, context): Promise { const requestedName = args.name const normalizedName = requestedName.startsWith('systematic:') @@ -68,21 +108,38 @@ Use this when a task matches an available skill's description.` const skills = getSystematicSkills() const matchedSkill = skills.find((s) => s.name === normalizedName) - if (matchedSkill) { - const body = extractSkillBody(matchedSkill.wrappedTemplate) - const dir = path.dirname(matchedSkill.skillFile) - - return `## Skill: ${matchedSkill.prefixedName} - -**Base directory**: ${dir} - -${body}` + if (!matchedSkill) { + const availableSystematic = skills.map((s) => s.prefixedName) + throw new Error( + `Skill "${requestedName}" not found. Available systematic skills: ${availableSystematic.join(', ')}`, + ) } - const availableSystematic = skills.map((s) => s.prefixedName) - throw new Error( - `Skill "${requestedName}" not found. Available systematic skills: ${availableSystematic.join(', ')}`, - ) + const body = extractSkillBody(matchedSkill.wrappedTemplate) + const dir = path.dirname(matchedSkill.skillFile) + + await context.ask({ + permission: 'skill', + patterns: [matchedSkill.prefixedName], + always: [matchedSkill.prefixedName], + metadata: {}, + }) + + context.metadata({ + title: `Loaded skill: ${matchedSkill.prefixedName}`, + metadata: { + name: matchedSkill.prefixedName, + dir, + }, + }) + + return [ + `## Skill: ${matchedSkill.prefixedName}`, + '', + `**Base directory**: ${dir}`, + '', + body.trim(), + ].join('\n') }, }) } diff --git a/src/lib/skills.ts b/src/lib/skills.ts index 8253b132..4e6f1b88 100644 --- a/src/lib/skills.ts +++ b/src/lib/skills.ts @@ -1,11 +1,29 @@ import fs from 'node:fs' import path from 'node:path' import { parseFrontmatter } from './frontmatter.js' +import { + extractBoolean, + extractNonEmptyString, + extractString, + isRecord, +} from './validation.js' import { walkDir } from './walk-dir.js' export interface SkillFrontmatter { name: string description: string + // OpenCode SDK fields + license?: string + compatibility?: string + metadata?: Record + // Claude Code converted fields + disableModelInvocation?: boolean // from YAML key: disable-model-invocation + userInvocable?: boolean // from YAML key: user-invocable + subtask?: boolean // derived from context: "fork" + agent?: string // from YAML key: agent + model?: string // from YAML key: model + argumentHint?: string // from YAML key: argument-hint + allowedTools?: string // from YAML key: allowed-tools } export interface SkillInfo { @@ -13,23 +31,56 @@ export interface SkillInfo { skillFile: string name: string description: string + // OpenCode SDK fields + license?: string + compatibility?: string + metadata?: Record + // Claude Code converted fields + disableModelInvocation?: boolean + userInvocable?: boolean + subtask?: boolean + agent?: string + model?: string + argumentHint?: string + allowedTools?: string } export function extractFrontmatter(filePath: string): SkillFrontmatter { try { const content = fs.readFileSync(filePath, 'utf8') - const { data, parseError } = parseFrontmatter<{ - name?: string - description?: string - }>(content) + const { data, parseError } = + parseFrontmatter>(content) if (parseError) { return { name: '', description: '' } } + const metadataRaw = data.metadata + let metadata: Record | undefined + if (isRecord(metadataRaw)) { + const entries = Object.entries(metadataRaw) + if (entries.every(([, v]) => typeof v === 'string')) { + metadata = Object.fromEntries(entries) as Record + } + } + + const argumentHintRaw = extractNonEmptyString(data, 'argument-hint') + const argumentHint = + argumentHintRaw?.replace(/^["']|["']$/g, '') || undefined + return { - name: typeof data.name === 'string' ? data.name : '', - description: typeof data.description === 'string' ? data.description : '', + name: extractString(data, 'name'), + description: extractString(data, 'description'), + license: extractNonEmptyString(data, 'license'), + compatibility: extractNonEmptyString(data, 'compatibility'), + metadata, + disableModelInvocation: extractBoolean(data, 'disable-model-invocation'), + userInvocable: extractBoolean(data, 'user-invocable'), + subtask: data.context === 'fork' ? true : undefined, + agent: extractNonEmptyString(data, 'agent'), + model: extractNonEmptyString(data, 'model'), + argumentHint: argumentHint !== '' ? argumentHint : undefined, + allowedTools: extractNonEmptyString(data, 'allowed-tools'), } } catch { return { name: '', description: '' } @@ -47,33 +98,25 @@ export function findSkillsInDir(dir: string, maxDepth = 3): SkillInfo[] { for (const entry of entries) { const skillFile = path.join(entry.path, 'SKILL.md') if (fs.existsSync(skillFile)) { - const { name, description } = extractFrontmatter(skillFile) + const frontmatter = extractFrontmatter(skillFile) skills.push({ path: entry.path, skillFile, - name: name || entry.name, - description: description || '', + name: frontmatter.name || entry.name, + description: frontmatter.description || '', + license: frontmatter.license, + compatibility: frontmatter.compatibility, + metadata: frontmatter.metadata, + disableModelInvocation: frontmatter.disableModelInvocation, + userInvocable: frontmatter.userInvocable, + subtask: frontmatter.subtask, + agent: frontmatter.agent, + model: frontmatter.model, + argumentHint: frontmatter.argumentHint, + allowedTools: frontmatter.allowedTools, }) } } return skills } - -export function formatSkillsXml(skills: SkillInfo[]): string { - if (skills.length === 0) return '' - - const skillsXml = skills - .map((skill) => { - const lines = [ - ' ', - ` systematic:${skill.name}`, - ` ${skill.description}`, - ] - lines.push(' ') - return lines.join('\n') - }) - .join('\n') - - return `\n${skillsXml}\n` -} diff --git a/tests/unit/config-handler.test.ts b/tests/unit/config-handler.test.ts index 4b8b760f..92cd9e60 100644 --- a/tests/unit/config-handler.test.ts +++ b/tests/unit/config-handler.test.ts @@ -466,5 +466,85 @@ Full command template.`, expect(command?.subtask).toBe(true) expect(command?.template).toContain('Full command template') }) + + test('skills with userInvocable: false are not loaded as commands', async () => { + const skillDir = path.join(bundledDir, 'skills', 'hidden-skill') + fs.mkdirSync(skillDir, { recursive: true }) + fs.writeFileSync( + path.join(skillDir, 'SKILL.md'), + `--- +name: hidden-skill +description: A hidden skill +user-invocable: false +--- +# Hidden Skill Content`, + ) + + const handler = createConfigHandler({ + directory: projectDir, + bundledSkillsDir: path.join(bundledDir, 'skills'), + bundledAgentsDir: path.join(bundledDir, 'agents'), + bundledCommandsDir: path.join(bundledDir, 'commands'), + }) + + const config: Config = {} + await handler(config) + + expect(config.command?.['systematic:hidden-skill']).toBeUndefined() + }) + + test('skills include subtask field in command config', async () => { + const skillDir = path.join(bundledDir, 'skills', 'forked-skill') + fs.mkdirSync(skillDir, { recursive: true }) + fs.writeFileSync( + path.join(skillDir, 'SKILL.md'), + `--- +name: forked-skill +description: A forked skill +context: fork +--- +# Forked Skill Content`, + ) + + const handler = createConfigHandler({ + directory: projectDir, + bundledSkillsDir: path.join(bundledDir, 'skills'), + bundledAgentsDir: path.join(bundledDir, 'agents'), + bundledCommandsDir: path.join(bundledDir, 'commands'), + }) + + const config: Config = {} + await handler(config) + + expect(config.command?.['systematic:forked-skill']?.subtask).toBe(true) + }) + + test('skills include agent and model fields in command config', async () => { + const skillDir = path.join(bundledDir, 'skills', 'routed-skill') + fs.mkdirSync(skillDir, { recursive: true }) + fs.writeFileSync( + path.join(skillDir, 'SKILL.md'), + `--- +name: routed-skill +description: A routed skill +agent: oracle +model: gpt-4 +--- +# Routed Skill Content`, + ) + + const handler = createConfigHandler({ + directory: projectDir, + bundledSkillsDir: path.join(bundledDir, 'skills'), + bundledAgentsDir: path.join(bundledDir, 'agents'), + bundledCommandsDir: path.join(bundledDir, 'commands'), + }) + + const config: Config = {} + await handler(config) + + expect(config.command?.['systematic:routed-skill']?.agent).toBe('oracle') + expect(config.command?.['systematic:routed-skill']?.model).toBe('gpt-4') + }) }) }) diff --git a/tests/unit/skill-tool.test.ts b/tests/unit/skill-tool.test.ts index d385f3b4..f7149293 100644 --- a/tests/unit/skill-tool.test.ts +++ b/tests/unit/skill-tool.test.ts @@ -2,9 +2,12 @@ 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 { createSkillTool } from '../../src/lib/skill-tool.ts' +import { createSkillTool, formatSkillsXml } from '../../src/lib/skill-tool.ts' -const mockContext = {} as never +const mockContext = { + ask: async () => {}, + metadata: () => {}, +} as never describe('skill-tool', () => { let testDir: string @@ -17,6 +20,72 @@ describe('skill-tool', () => { fs.rmSync(testDir, { recursive: true, force: true }) }) + describe('formatSkillsXml', () => { + test('returns empty string for empty skills array', () => { + const result = formatSkillsXml([]) + expect(result).toBe('') + }) + + test('formats single skill with space delimiters and indented structure', () => { + const result = formatSkillsXml([ + { + path: '/test/path', + skillFile: '/test/path/SKILL.md', + name: 'test-skill', + description: 'A test skill', + }, + ]) + expect(result).toBe( + ' systematic:test-skill A test skill ', + ) + }) + + test('formats multiple skills with space delimiters and indented structure', () => { + const result = formatSkillsXml([ + { + path: '/test/path1', + skillFile: '/test/path1/SKILL.md', + name: 'skill-one', + description: 'First skill', + }, + { + path: '/test/path2', + skillFile: '/test/path2/SKILL.md', + name: 'skill-two', + description: 'Second skill', + }, + ]) + expect(result).toContain('') + expect(result).toContain('') + expect(result).toContain('systematic:skill-one') + expect(result).toContain('systematic:skill-two') + expect(result).toContain('First skill') + expect(result).toContain('Second skill') + // Ensure no newlines in output (space-delimited format) + expect(result).not.toContain('\n') + }) + + test('includes skills even when disableModelInvocation is true', () => { + const result = formatSkillsXml([ + { + path: '/test/path1', + skillFile: '/test/path1/SKILL.md', + name: 'skill-one', + description: 'First skill', + }, + { + path: '/test/path2', + skillFile: '/test/path2/SKILL.md', + name: 'skill-two', + description: 'Second skill', + disableModelInvocation: true, + }, + ]) + expect(result).toContain('skill-one') + expect(result).toContain('skill-two') + }) + }) + describe('createSkillTool', () => { test('creates tool with description property', () => { const skillDir = path.join(testDir, 'test-skill') diff --git a/tests/unit/skills.test.ts b/tests/unit/skills.test.ts index c777f824..f34ea04a 100644 --- a/tests/unit/skills.test.ts +++ b/tests/unit/skills.test.ts @@ -54,6 +54,113 @@ description: A test skill expect(result.name).toBe('') expect(result.description).toBe('') }) + + test('extracts OpenCode SDK fields', () => { + const filePath = path.join(testDir, 'test.md') + fs.writeFileSync( + filePath, + `--- +name: test-skill +description: A test skill +license: MIT +compatibility: opencode +--- +# Skill Content`, + ) + const result = skills.extractFrontmatter(filePath) + expect(result.license).toBe('MIT') + expect(result.compatibility).toBe('opencode') + }) + + test('extracts metadata object with string values', () => { + const filePath = path.join(testDir, 'test.md') + fs.writeFileSync( + filePath, + `--- +name: test-skill +description: A test skill +metadata: + author: Test Author + version: '1.0.0' +--- +# Skill Content`, + ) + const result = skills.extractFrontmatter(filePath) + expect(result.metadata).toEqual({ + author: 'Test Author', + version: '1.0.0', + }) + }) + + test('extracts Claude Code converted fields', () => { + const filePath = path.join(testDir, 'test.md') + fs.writeFileSync( + filePath, + `--- +name: test-skill +description: A test skill +disable-model-invocation: true +user-invocable: false +agent: oracle +model: gpt-4 +argument-hint: "[file]" +allowed-tools: read,write +--- +# Skill Content`, + ) + const result = skills.extractFrontmatter(filePath) + expect(result.disableModelInvocation).toBe(true) + expect(result.userInvocable).toBe(false) + expect(result.agent).toBe('oracle') + expect(result.model).toBe('gpt-4') + expect(result.argumentHint).toBe('[file]') + expect(result.allowedTools).toBe('read,write') + }) + + test('derives subtask from context: fork', () => { + const filePath = path.join(testDir, 'test.md') + fs.writeFileSync( + filePath, + `--- +name: test-skill +description: A test skill +context: fork +--- +# Skill Content`, + ) + const result = skills.extractFrontmatter(filePath) + expect(result.subtask).toBe(true) + }) + + test('subtask is undefined when context is not fork', () => { + const filePath = path.join(testDir, 'test.md') + fs.writeFileSync( + filePath, + `--- +name: test-skill +description: A test skill +context: other +--- +# Skill Content`, + ) + const result = skills.extractFrontmatter(filePath) + expect(result.subtask).toBeUndefined() + }) + + test('strips quotes from argument-hint', () => { + const filePath = path.join(testDir, 'test.md') + fs.writeFileSync( + filePath, + `--- +name: test-skill +description: A test skill +argument-hint: '"[pattern]"' +--- +# Skill Content`, + ) + const result = skills.extractFrontmatter(filePath) + expect(result.argumentHint).toBe('[pattern]') + }) }) describe('findSkillsInDir', () => {