From 3689ecd5b0660d12a141048e982ada209074e661 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 3 Apr 2026 19:17:03 +0900 Subject: [PATCH] refactor(tools): decompose skill/tools.ts into focused tool creators --- src/tools/skill/description-formatter.ts | 61 ++++ src/tools/skill/mcp-capability-formatter.ts | 96 +++++++ src/tools/skill/native-skills.ts | 62 ++++ src/tools/skill/scope-priority.ts | 17 ++ src/tools/skill/skill-body.ts | 26 ++ src/tools/skill/skill-matcher.ts | 40 +++ src/tools/skill/tools.ts | 300 +++----------------- 7 files changed, 336 insertions(+), 266 deletions(-) create mode 100644 src/tools/skill/description-formatter.ts create mode 100644 src/tools/skill/mcp-capability-formatter.ts create mode 100644 src/tools/skill/native-skills.ts create mode 100644 src/tools/skill/scope-priority.ts create mode 100644 src/tools/skill/skill-body.ts create mode 100644 src/tools/skill/skill-matcher.ts 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"}` )