refactor(tools): decompose skill/tools.ts into focused tool creators

This commit is contained in:
YeonGyu-Kim
2026-04-03 19:17:03 +09:00
parent 3ce1f30310
commit 3689ecd5b0
7 changed files with 336 additions and 266 deletions
+61
View File
@@ -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 = [
" <command>",
` <name>/${skill.name}</name>`,
` <description>${skill.description}</description>`,
` <scope>${skill.scope}</scope>`,
]
if (skill.compatibility) {
lines.push(` <compatibility>${skill.compatibility}</compatibility>`)
}
lines.push(" </command>")
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.name}</name>`,
` <description>${command.metadata.description || "(no description)"}</description>`,
` <scope>${command.scope}</scope>`,
]
if (argumentHint) {
lines.push(` <argument>${argumentHint}</argument>`)
}
lines.push(" </command>")
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}
<available_items>
Priority: project > user > opencode > builtin/plugin | Skills listed before commands
Invoke via: skill(name="item-name") — omit leading slash for commands.
${availableItems.join("\n")}
</available_items>`
}
@@ -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<string | null> {
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(", ")}`)
}
+62
View File
@@ -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<TValue>(value: TValue | Promise<TValue>): value is Promise<TValue> {
return typeof value === "object" && value !== null && "then" in value
}
+17
View File
@@ -0,0 +1,17 @@
export const SCOPE_PRIORITY: Record<string, number> = {
project: 4,
user: 3,
opencode: 2,
"opencode-project": 2,
plugin: 1,
config: 1,
builtin: 1,
}
export function sortByScopePriority<TItem extends { scope: string }>(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
})
}
+26
View File
@@ -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 = /<skill-instruction>([\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<string> {
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 || "")
}
+40
View File
@@ -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))
}
+34 -266
View File
@@ -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<string, number> = {
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<T>(value: T | Promise<T>): value is Promise<T> {
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 = [
" <command>",
` <name>/${skill.name}</name>`,
` <description>${skill.description}</description>`,
` <scope>${skill.scope}</scope>`,
]
if (skill.compatibility) {
parts.push(` <compatibility>${skill.compatibility}</compatibility>`)
}
parts.push(" </command>")
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 = [
" <command>",
` <name>/${cmd.name}</name>`,
` <description>${cmd.metadata.description || "(no description)"}</description>`,
` <scope>${cmd.scope}</scope>`,
]
if (hint) {
parts.push(` <argument>${hint.trim()}</argument>`)
}
parts.push(" </command>")
allItems.push(parts.join("\n"))
})
}
if (allItems.length > 0) {
lines.push(`\n<available_items>\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</available_items>`)
}
return TOOL_DESCRIPTION_PREFIX + lines.join("")
}
async function extractSkillBody(skill: LoadedSkill): Promise<string> {
if (skill.lazyContent) {
const fullTemplate = await skill.lazyContent.load()
const templateMatch = fullTemplate.match(/<skill-instruction>([\s\S]*?)<\/skill-instruction>/)
return templateMatch ? templateMatch[1].trim() : fullTemplate
}
if (skill.scope === "config" && skill.definition.template) {
const templateMatch = skill.definition.template.match(/<skill-instruction>([\s\S]*?)<\/skill-instruction>/)
return templateMatch ? templateMatch[1].trim() : skill.definition.template
}
if (skill.path) {
return extractSkillTemplate(skill)
}
const templateMatch = skill.definition.template?.match(/<skill-instruction>([\s\S]*?)<\/skill-instruction>/)
return templateMatch ? templateMatch[1].trim() : skill.definition.template || ""
}
async function formatMcpCapabilities(
skill: LoadedSkill,
manager: SkillMcpManager,
sessionID: string
): Promise<string | null> {
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<LoadedSkill[]> => {
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"}`
)