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', () => {