Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/CONVERSION-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):**
Expand Down
10 changes: 9 additions & 1 deletion src/lib/config-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -149,6 +155,8 @@ function collectSkillsAsCommands(

const loaded = loadSkill(skillInfo)
if (loaded) {
if (loaded.userInvocable === false) continue

commands[loaded.prefixedName] = loadSkillAsCommand(loaded)
}
}
Expand Down
12 changes: 12 additions & 0 deletions src/lib/skill-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
107 changes: 82 additions & 25 deletions src/lib/skill-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => [
' <skill>',
` <name>systematic:${skill.name}</name>`,
` <description>${skill.description}</description>`,
' </skill>',
])

return ['<available_skills>', ...skillLines, '</available_skills>'].join(' ')
}

export function createSkillTool(options: SkillToolOptions): ToolDefinition {
const { bundledSkillsDir, disabledSkills } = options

Expand All @@ -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,
Expand All @@ -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() {
Expand All @@ -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<string> {
async execute(args: { name: string }, context): Promise<string> {
const requestedName = args.name

const normalizedName = requestedName.startsWith('systematic:')
Expand All @@ -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')
},
})
}
97 changes: 70 additions & 27 deletions src/lib/skills.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,86 @@
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<string, string>
// 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 {
path: string
skillFile: string
name: string
description: string
// OpenCode SDK fields
license?: string
compatibility?: string
metadata?: Record<string, string>
// 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<Record<string, unknown>>(content)

if (parseError) {
return { name: '', description: '' }
}

const metadataRaw = data.metadata
let metadata: Record<string, string> | undefined
if (isRecord(metadataRaw)) {
const entries = Object.entries(metadataRaw)
if (entries.every(([, v]) => typeof v === 'string')) {
metadata = Object.fromEntries(entries) as Record<string, string>
}
}

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: '' }
Expand All @@ -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 = [
' <skill>',
` <name>systematic:${skill.name}</name>`,
` <description>${skill.description}</description>`,
]
lines.push(' </skill>')
return lines.join('\n')
})
.join('\n')

return `<available_skills>\n${skillsXml}\n</available_skills>`
}
Loading