diff --git a/src/tools/skill/description-formatter.ts b/src/tools/skill/description-formatter.ts
new file mode 100644
index 000000000..c51c10a23
--- /dev/null
+++ b/src/tools/skill/description-formatter.ts
@@ -0,0 +1,61 @@
+import { TOOL_DESCRIPTION_NO_SKILLS, TOOL_DESCRIPTION_PREFIX } from "./constants"
+import { sortByScopePriority } from "./scope-priority"
+import type { SkillInfo } from "./types"
+import type { CommandInfo } from "../slashcommand/types"
+
+function formatSkillCommand(skill: SkillInfo): string {
+ const lines = [
+ " ",
+ ` /${skill.name}`,
+ ` ${skill.description}`,
+ ` ${skill.scope}`,
+ ]
+
+ if (skill.compatibility) {
+ lines.push(` ${skill.compatibility}`)
+ }
+
+ lines.push(" ")
+ return lines.join("\n")
+}
+
+function formatSlashCommand(command: CommandInfo): string {
+ const argumentHint = typeof command.metadata.argumentHint === "string"
+ ? command.metadata.argumentHint.trim()
+ : undefined
+ const lines = [
+ " ",
+ ` /${command.name}`,
+ ` ${command.metadata.description || "(no description)"}`,
+ ` ${command.scope}`,
+ ]
+
+ if (argumentHint) {
+ lines.push(` ${argumentHint}`)
+ }
+
+ lines.push(" ")
+ return lines.join("\n")
+}
+
+export function formatCombinedDescription(skills: SkillInfo[], commands: CommandInfo[]): string {
+ if (skills.length === 0 && commands.length === 0) {
+ return TOOL_DESCRIPTION_NO_SKILLS
+ }
+
+ const availableItems = [
+ ...sortByScopePriority(skills).map(formatSkillCommand),
+ ...sortByScopePriority(commands).map(formatSlashCommand),
+ ]
+
+ if (availableItems.length === 0) {
+ return TOOL_DESCRIPTION_PREFIX
+ }
+
+ return `${TOOL_DESCRIPTION_PREFIX}
+
+Priority: project > user > opencode > builtin/plugin | Skills listed before commands
+Invoke via: skill(name="item-name") — omit leading slash for commands.
+${availableItems.join("\n")}
+`
+}
diff --git a/src/tools/skill/mcp-capability-formatter.ts b/src/tools/skill/mcp-capability-formatter.ts
new file mode 100644
index 000000000..a7371480f
--- /dev/null
+++ b/src/tools/skill/mcp-capability-formatter.ts
@@ -0,0 +1,96 @@
+import type { Prompt, Resource, Tool } from "@modelcontextprotocol/sdk/types.js"
+import { sanitizeJsonSchema } from "../../plugin/normalize-tool-arg-schemas"
+import type {
+ SkillMcpClientInfo,
+ SkillMcpManager,
+ SkillMcpServerContext,
+} from "../../features/skill-mcp-manager"
+import type { LoadedSkill } from "../../features/opencode-skill-loader"
+
+export async function formatMcpCapabilities(
+ skill: LoadedSkill,
+ manager: SkillMcpManager,
+ sessionID: string
+): Promise {
+ if (!skill.mcpConfig || Object.keys(skill.mcpConfig).length === 0) {
+ return null
+ }
+
+ const sections: string[] = ["", "## Available MCP Servers", ""]
+
+ for (const [serverName, config] of Object.entries(skill.mcpConfig)) {
+ const info: SkillMcpClientInfo = {
+ serverName,
+ skillName: skill.name,
+ sessionID,
+ }
+ const context: SkillMcpServerContext = {
+ config,
+ skillName: skill.name,
+ }
+
+ sections.push(`### ${serverName}`, "")
+
+ try {
+ const [tools, resources, prompts] = await Promise.all([
+ manager.listTools(info, context).catch(() => []),
+ manager.listResources(info, context).catch(() => []),
+ manager.listPrompts(info, context).catch(() => []),
+ ])
+
+ appendToolSections(sections, tools as Tool[])
+ appendResourceSection(sections, resources as Resource[])
+ appendPromptSection(sections, prompts as Prompt[])
+
+ if (tools.length === 0 && resources.length === 0 && prompts.length === 0) {
+ sections.push("*No capabilities discovered*")
+ }
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : String(error)
+ sections.push(`*Failed to connect: ${errorMessage.split("\n")[0]}*`)
+ }
+
+ sections.push("", `Use \`skill_mcp\` tool with \`mcp_name=\"${serverName}\"\` to invoke.`, "")
+ }
+
+ return sections.join("\n")
+}
+
+function appendToolSections(sections: string[], tools: Tool[]): void {
+ if (tools.length === 0) {
+ return
+ }
+
+ sections.push("**Tools:**", "")
+
+ for (const toolDefinition of tools) {
+ sections.push(`#### \`${toolDefinition.name}\``)
+ if (toolDefinition.description) {
+ sections.push(toolDefinition.description)
+ }
+ sections.push(
+ "",
+ "**inputSchema:**",
+ "```json",
+ JSON.stringify(sanitizeJsonSchema(toolDefinition.inputSchema), null, 2),
+ "```",
+ ""
+ )
+ }
+}
+
+function appendResourceSection(sections: string[], resources: Resource[]): void {
+ if (resources.length === 0) {
+ return
+ }
+
+ sections.push(`**Resources**: ${resources.map((resource) => resource.uri).join(", ")}`)
+}
+
+function appendPromptSection(sections: string[], prompts: Prompt[]): void {
+ if (prompts.length === 0) {
+ return
+ }
+
+ sections.push(`**Prompts**: ${prompts.map((prompt) => prompt.name).join(", ")}`)
+}
diff --git a/src/tools/skill/native-skills.ts b/src/tools/skill/native-skills.ts
new file mode 100644
index 000000000..67bc463b3
--- /dev/null
+++ b/src/tools/skill/native-skills.ts
@@ -0,0 +1,62 @@
+import type { SkillInfo } from "./types"
+import type { LoadedSkill } from "../../features/opencode-skill-loader"
+
+export type NativeSkillEntry = {
+ name: string
+ description: string
+ location: string
+ content: string
+}
+
+export function loadedSkillToInfo(skill: LoadedSkill): SkillInfo {
+ return {
+ name: skill.name,
+ description: skill.definition.description || "",
+ location: skill.path,
+ scope: skill.scope,
+ license: skill.license,
+ compatibility: skill.compatibility,
+ metadata: skill.metadata,
+ allowedTools: skill.allowedTools,
+ }
+}
+
+function nativeSkillToLoadedSkill(native: NativeSkillEntry): LoadedSkill {
+ return {
+ name: native.name,
+ path: native.location,
+ definition: {
+ name: native.name,
+ description: native.description,
+ template: native.content,
+ },
+ scope: "config",
+ }
+}
+
+export function mergeNativeSkills(skills: LoadedSkill[], nativeSkills: NativeSkillEntry[]): void {
+ const knownNames = new Set(skills.map((skill) => skill.name))
+ for (const native of nativeSkills) {
+ if (knownNames.has(native.name)) continue
+ skills.push(nativeSkillToLoadedSkill(native))
+ knownNames.add(native.name)
+ }
+}
+
+export function mergeNativeSkillInfos(skillInfos: SkillInfo[], nativeSkills: NativeSkillEntry[]): void {
+ const knownNames = new Set(skillInfos.map((skill) => skill.name))
+ for (const native of nativeSkills) {
+ if (knownNames.has(native.name)) continue
+ skillInfos.push({
+ name: native.name,
+ description: native.description,
+ location: native.location,
+ scope: "config",
+ })
+ knownNames.add(native.name)
+ }
+}
+
+export function isPromiseLike(value: TValue | Promise): value is Promise {
+ return typeof value === "object" && value !== null && "then" in value
+}
diff --git a/src/tools/skill/scope-priority.ts b/src/tools/skill/scope-priority.ts
new file mode 100644
index 000000000..29364d24d
--- /dev/null
+++ b/src/tools/skill/scope-priority.ts
@@ -0,0 +1,17 @@
+export const SCOPE_PRIORITY: Record = {
+ project: 4,
+ user: 3,
+ opencode: 2,
+ "opencode-project": 2,
+ plugin: 1,
+ config: 1,
+ builtin: 1,
+}
+
+export function sortByScopePriority(items: TItem[]): TItem[] {
+ return [...items].sort((left, right) => {
+ const leftPriority = SCOPE_PRIORITY[left.scope] || 0
+ const rightPriority = SCOPE_PRIORITY[right.scope] || 0
+ return rightPriority - leftPriority
+ })
+}
diff --git a/src/tools/skill/skill-body.ts b/src/tools/skill/skill-body.ts
new file mode 100644
index 000000000..fa05f6c8e
--- /dev/null
+++ b/src/tools/skill/skill-body.ts
@@ -0,0 +1,26 @@
+import type { LoadedSkill } from "../../features/opencode-skill-loader"
+import { extractSkillTemplate } from "../../features/opencode-skill-loader/skill-content"
+
+const SKILL_INSTRUCTION_PATTERN = /([\s\S]*?)<\/skill-instruction>/
+
+function trimSkillInstruction(template: string): string {
+ const templateMatch = template.match(SKILL_INSTRUCTION_PATTERN)
+ return templateMatch ? templateMatch[1].trim() : template
+}
+
+export async function extractSkillBody(skill: LoadedSkill): Promise {
+ if (skill.lazyContent) {
+ const fullTemplate = await skill.lazyContent.load()
+ return trimSkillInstruction(fullTemplate)
+ }
+
+ if (skill.scope === "config" && skill.definition.template) {
+ return trimSkillInstruction(skill.definition.template)
+ }
+
+ if (skill.path) {
+ return extractSkillTemplate(skill)
+ }
+
+ return trimSkillInstruction(skill.definition.template || "")
+}
diff --git a/src/tools/skill/skill-matcher.ts b/src/tools/skill/skill-matcher.ts
new file mode 100644
index 000000000..9634d3c3b
--- /dev/null
+++ b/src/tools/skill/skill-matcher.ts
@@ -0,0 +1,40 @@
+import { sortByScopePriority } from "./scope-priority"
+import type { CommandInfo } from "../slashcommand/types"
+import type { LoadedSkill } from "../../features/opencode-skill-loader"
+
+export function matchSkillByName(skills: LoadedSkill[], requestedName: string): LoadedSkill | undefined {
+ const normalizedName = requestedName.toLowerCase()
+ const exactMatch = skills.find((skill) => skill.name.toLowerCase() === normalizedName)
+ if (exactMatch) {
+ return exactMatch
+ }
+
+ const shortNameMatches = skills.filter((skill) => {
+ const parts = skill.name.split("/")
+ const shortName = parts[parts.length - 1]
+ return parts.length > 1 && shortName?.toLowerCase() === normalizedName
+ })
+
+ if (shortNameMatches.length === 1) {
+ return shortNameMatches[0]
+ }
+
+ return undefined
+}
+
+export function matchCommandByName(commands: CommandInfo[], requestedName: string): CommandInfo | undefined {
+ const normalizedName = requestedName.toLowerCase()
+ return sortByScopePriority(commands).find((command) => command.name.toLowerCase() === normalizedName)
+}
+
+export function findPartialMatches(
+ skills: LoadedSkill[],
+ commands: CommandInfo[],
+ requestedName: string
+): string[] {
+ const normalizedName = requestedName.toLowerCase()
+ return [
+ ...skills.map((skill) => skill.name),
+ ...commands.map((command) => `/${command.name}`),
+ ].filter((name) => name.toLowerCase().includes(normalizedName))
+}
diff --git a/src/tools/skill/tools.ts b/src/tools/skill/tools.ts
index 34d31cb2e..f15e90410 100644
--- a/src/tools/skill/tools.ts
+++ b/src/tools/skill/tools.ts
@@ -1,258 +1,52 @@
import { dirname } from "node:path"
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
import type { ToolContext } from "@opencode-ai/plugin/tool"
-import { TOOL_DESCRIPTION_NO_SKILLS, TOOL_DESCRIPTION_PREFIX } from "./constants"
-import type { SkillArgs, SkillInfo, SkillLoadOptions } from "./types"
+import { TOOL_DESCRIPTION_PREFIX } from "./constants"
+import type { SkillArgs, SkillLoadOptions } from "./types"
import type { LoadedSkill } from "../../features/opencode-skill-loader"
-import { getAllSkills, extractSkillTemplate, clearSkillCache } from "../../features/opencode-skill-loader/skill-content"
+import { getAllSkills, clearSkillCache } from "../../features/opencode-skill-loader/skill-content"
import { injectGitMasterConfig } from "../../features/opencode-skill-loader/skill-content"
-import type { SkillMcpManager, SkillMcpClientInfo, SkillMcpServerContext } from "../../features/skill-mcp-manager"
-import type { Tool, Resource, Prompt } from "@modelcontextprotocol/sdk/types.js"
-import { sanitizeJsonSchema } from "../../plugin/normalize-tool-arg-schemas"
import { discoverCommandsSync } from "../slashcommand/command-discovery"
import type { CommandInfo } from "../slashcommand/types"
import { formatLoadedCommand } from "../slashcommand/command-output-formatter"
-
-type NativeSkillEntry = {
- name: string
- description: string
- location: string
- content: string
-}
-// Priority: project > user > opencode/opencode-project > builtin/config
-const scopePriority: Record = {
- project: 4,
- user: 3,
- opencode: 2,
- "opencode-project": 2,
- plugin: 1,
- config: 1,
- builtin: 1,
-}
-
-function loadedSkillToInfo(skill: LoadedSkill): SkillInfo {
- return {
- name: skill.name,
- description: skill.definition.description || "",
- location: skill.path,
- scope: skill.scope,
- license: skill.license,
- compatibility: skill.compatibility,
- metadata: skill.metadata,
- allowedTools: skill.allowedTools,
- }
-}
-
-function nativeSkillToLoadedSkill(native: NativeSkillEntry): LoadedSkill {
- return {
- name: native.name,
- path: native.location,
- definition: {
- name: native.name,
- description: native.description,
- template: native.content,
- },
- scope: "config",
- }
-}
-
-function mergeNativeSkills(skills: LoadedSkill[], nativeSkills: NativeSkillEntry[]): void {
- const knownNames = new Set(skills.map(skill => skill.name))
- for (const native of nativeSkills) {
- if (knownNames.has(native.name)) continue
- skills.push(nativeSkillToLoadedSkill(native))
- knownNames.add(native.name)
- }
-}
-
-function mergeNativeSkillInfos(skillInfos: SkillInfo[], nativeSkills: NativeSkillEntry[]): void {
- const knownNames = new Set(skillInfos.map(skill => skill.name))
- for (const native of nativeSkills) {
- if (knownNames.has(native.name)) continue
- skillInfos.push({
- name: native.name,
- description: native.description,
- location: native.location,
- scope: "config",
- })
- knownNames.add(native.name)
- }
-}
-
-function isPromiseLike(value: T | Promise): value is Promise {
- return typeof value === "object" && value !== null && "then" in value
-}
-
-function formatCombinedDescription(skills: SkillInfo[], commands: CommandInfo[]): string {
- const lines: string[] = []
-
- if (skills.length === 0 && commands.length === 0) {
- return TOOL_DESCRIPTION_NO_SKILLS
- }
-
- // Uses module-level scopePriority for consistent priority ordering
-
- const allItems: string[] = []
-
- // Skills rendered as command items (skills are also slash-invocable)
- if (skills.length > 0) {
- const sortedSkills = [...skills].sort((a, b) => {
- const priorityA = scopePriority[a.scope] || 0
- const priorityB = scopePriority[b.scope] || 0
- return priorityB - priorityA
- })
- sortedSkills.forEach(skill => {
- const parts = [
- " ",
- ` /${skill.name}`,
- ` ${skill.description}`,
- ` ${skill.scope}`,
- ]
- if (skill.compatibility) {
- parts.push(` ${skill.compatibility}`)
- }
- parts.push(" ")
- allItems.push(parts.join("\n"))
- })
- }
-
- // Sort and add commands second (commands after skills)
- if (commands.length > 0) {
- const sortedCommands = [...commands].sort((a, b) => {
- const priorityA = scopePriority[a.scope] || 0
- const priorityB = scopePriority[b.scope] || 0
- return priorityB - priorityA // Higher priority first
- })
- sortedCommands.forEach(cmd => {
- const hint = cmd.metadata.argumentHint ? ` ${cmd.metadata.argumentHint}` : ""
- const parts = [
- " ",
- ` /${cmd.name}`,
- ` ${cmd.metadata.description || "(no description)"}`,
- ` ${cmd.scope}`,
- ]
- if (hint) {
- parts.push(` ${hint.trim()}`)
- }
- parts.push(" ")
- allItems.push(parts.join("\n"))
- })
- }
-
- if (allItems.length > 0) {
- lines.push(`\n\nPriority: project > user > opencode > builtin/plugin | Skills listed before commands\nInvoke via: skill(name="item-name") — omit leading slash for commands.\n${allItems.join("\n")}\n`)
- }
-
- return TOOL_DESCRIPTION_PREFIX + lines.join("")
-}
-
-async function extractSkillBody(skill: LoadedSkill): Promise {
- if (skill.lazyContent) {
- const fullTemplate = await skill.lazyContent.load()
- const templateMatch = fullTemplate.match(/([\s\S]*?)<\/skill-instruction>/)
- return templateMatch ? templateMatch[1].trim() : fullTemplate
- }
-
- if (skill.scope === "config" && skill.definition.template) {
- const templateMatch = skill.definition.template.match(/([\s\S]*?)<\/skill-instruction>/)
- return templateMatch ? templateMatch[1].trim() : skill.definition.template
- }
-
- if (skill.path) {
- return extractSkillTemplate(skill)
- }
-
- const templateMatch = skill.definition.template?.match(/([\s\S]*?)<\/skill-instruction>/)
- return templateMatch ? templateMatch[1].trim() : skill.definition.template || ""
-}
-
-async function formatMcpCapabilities(
- skill: LoadedSkill,
- manager: SkillMcpManager,
- sessionID: string
-): Promise {
- if (!skill.mcpConfig || Object.keys(skill.mcpConfig).length === 0) {
- return null
- }
-
- const sections: string[] = ["", "## Available MCP Servers", ""]
-
- for (const [serverName, config] of Object.entries(skill.mcpConfig)) {
- const info: SkillMcpClientInfo = {
- serverName,
- skillName: skill.name,
- sessionID,
- }
- const context: SkillMcpServerContext = {
- config,
- skillName: skill.name,
- }
-
- sections.push(`### ${serverName}`)
- sections.push("")
-
- try {
- const [tools, resources, prompts] = await Promise.all([
- manager.listTools(info, context).catch(() => []),
- manager.listResources(info, context).catch(() => []),
- manager.listPrompts(info, context).catch(() => []),
- ])
-
- if (tools.length > 0) {
- sections.push("**Tools:**")
- sections.push("")
- for (const t of tools as Tool[]) {
- sections.push(`#### \`${t.name}\``)
- if (t.description) {
- sections.push(t.description)
- }
- sections.push("")
- sections.push("**inputSchema:**")
- sections.push("```json")
- sections.push(JSON.stringify(sanitizeJsonSchema(t.inputSchema), null, 2))
- sections.push("```")
- sections.push("")
- }
- }
- if (resources.length > 0) {
- sections.push(`**Resources**: ${resources.map((r: Resource) => r.uri).join(", ")}`)
- }
- if (prompts.length > 0) {
- sections.push(`**Prompts**: ${prompts.map((p: Prompt) => p.name).join(", ")}`)
- }
-
- if (tools.length === 0 && resources.length === 0 && prompts.length === 0) {
- sections.push("*No capabilities discovered*")
- }
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : String(error)
- sections.push(`*Failed to connect: ${errorMessage.split("\n")[0]}*`)
- }
-
- sections.push("")
- sections.push(`Use \`skill_mcp\` tool with \`mcp_name="${serverName}"\` to invoke.`)
- sections.push("")
- }
-
- return sections.join("\n")
-}
+import { formatCombinedDescription } from "./description-formatter"
+import { formatMcpCapabilities } from "./mcp-capability-formatter"
+import {
+ findPartialMatches,
+ matchCommandByName,
+ matchSkillByName,
+} from "./skill-matcher"
+import { extractSkillBody } from "./skill-body"
+import {
+ isPromiseLike,
+ loadedSkillToInfo,
+ mergeNativeSkillInfos,
+ mergeNativeSkills,
+} from "./native-skills"
export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition {
let cachedDescription: string | null = null
const getSkills = async (): Promise => {
clearSkillCache()
- const discovered = await getAllSkills({disabledSkills: options?.disabledSkills, browserProvider: options?.browserProvider})
+ const discovered = await getAllSkills({
+ disabledSkills: options?.disabledSkills,
+ browserProvider: options?.browserProvider,
+ })
const allSkills = !options.skills
? discovered
- : [...discovered, ...options.skills.filter(s => !new Set(discovered.map(d => d.name)).has(s.name))]
+ : [
+ ...discovered,
+ ...options.skills.filter(
+ (skill) => !new Set(discovered.map((discoveredSkill) => discoveredSkill.name)).has(skill.name)
+ ),
+ ]
if (options.nativeSkills) {
try {
const nativeAll = await options.nativeSkills.all()
mergeNativeSkills(allSkills, nativeAll)
} catch {
- // Native skill discovery may not be available
}
}
@@ -289,7 +83,6 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
mergeNativeSkillInfos(skillInfos, nativeAll)
}
} catch {
- // Native skill discovery may not be available
}
}
@@ -323,21 +116,7 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
cachedDescription = formatCombinedDescription(skills.map(loadedSkillToInfo), commands)
const requestedName = args.name.replace(/^\//, "")
-
- // Check skills first (exact match, case-insensitive)
- let matchedSkill = skills.find(s => s.name.toLowerCase() === requestedName.toLowerCase())
-
- // Fallback: try matching by short name (basename) for namespaced skills
- // e.g. "systematic-debugging" matches "superpowers/systematic-debugging"
- if (!matchedSkill) {
- const shortNameMatches = skills.filter(s => {
- const parts = s.name.split("/")
- return parts.length > 1 && parts[parts.length - 1].toLowerCase() === requestedName.toLowerCase()
- })
- if (shortNameMatches.length === 1) {
- matchedSkill = shortNameMatches[0]
- }
- }
+ const matchedSkill = matchSkillByName(skills, requestedName)
if (matchedSkill) {
if (matchedSkill.definition.agent && (!ctx?.agent || matchedSkill.definition.agent !== ctx.agent)) {
@@ -380,27 +159,13 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
return output.join("\n")
}
- // Check commands (exact match, case-insensitive) - sort by priority first
- const sortedCommands = [...commands].sort((a, b) => {
- const priorityA = scopePriority[a.scope] || 0
- const priorityB = scopePriority[b.scope] || 0
- return priorityB - priorityA // Higher priority first
- })
- const matchedCommand = sortedCommands.find(c => c.name.toLowerCase() === requestedName.toLowerCase())
+ const matchedCommand = matchCommandByName(commands, requestedName)
if (matchedCommand) {
return await formatLoadedCommand(matchedCommand, args.user_message)
}
- // No match found — provide helpful error with partial matches
- const allNames = [
- ...skills.map(s => s.name),
- ...commands.map(c => `/${c.name}`),
- ]
-
- const partialMatches = allNames.filter(n =>
- n.toLowerCase().includes(requestedName.toLowerCase())
- )
+ const partialMatches = findPartialMatches(skills, commands, requestedName)
if (partialMatches.length > 0) {
throw new Error(
@@ -408,7 +173,10 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
)
}
- const available = allNames.join(", ")
+ const available = [
+ ...skills.map((skill) => skill.name),
+ ...commands.map((command) => `/${command.name}`),
+ ].join(", ")
throw new Error(
`Skill or command "${args.name}" not found. Available: ${available || "none"}`
)