- Remove all sync functions from command loader (async now default) - Remove sync load functions from skill loader (async now default) - Add resolveSymlinkAsync to file-utils.ts - Update all callers to use async versions: - config-handler.ts - index.ts - tools/slashcommand/tools.ts - tools/skill/tools.ts - hooks/auto-slash-command/executor.ts - loader.test.ts - All 607 tests pass, build succeeds Generated with assistance of 🤖 [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
198 lines
6.3 KiB
TypeScript
198 lines
6.3 KiB
TypeScript
import { dirname } from "node:path"
|
|
import { readFileSync } from "node:fs"
|
|
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
|
import { TOOL_DESCRIPTION_NO_SKILLS, TOOL_DESCRIPTION_PREFIX } from "./constants"
|
|
import type { SkillArgs, SkillInfo, SkillLoadOptions } from "./types"
|
|
import { discoverSkills, type LoadedSkill } from "../../features/opencode-skill-loader"
|
|
import { parseFrontmatter } from "../../shared/frontmatter"
|
|
import type { SkillMcpManager, SkillMcpClientInfo, SkillMcpServerContext } from "../../features/skill-mcp-manager"
|
|
import type { Tool, Resource, Prompt } from "@modelcontextprotocol/sdk/types.js"
|
|
|
|
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 formatSkillsXml(skills: SkillInfo[]): string {
|
|
if (skills.length === 0) return ""
|
|
|
|
const skillsXml = skills.map(skill => {
|
|
const lines = [
|
|
" <skill>",
|
|
` <name>${skill.name}</name>`,
|
|
` <description>${skill.description}</description>`,
|
|
]
|
|
if (skill.compatibility) {
|
|
lines.push(` <compatibility>${skill.compatibility}</compatibility>`)
|
|
}
|
|
lines.push(" </skill>")
|
|
return lines.join("\n")
|
|
}).join("\n")
|
|
|
|
return `\n\n<available_skills>\n${skillsXml}\n</available_skills>`
|
|
}
|
|
|
|
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.path) {
|
|
const content = readFileSync(skill.path, "utf-8")
|
|
const { body } = parseFrontmatter(content)
|
|
return body.trim()
|
|
}
|
|
|
|
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(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")
|
|
}
|
|
|
|
export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition {
|
|
let cachedSkills: LoadedSkill[] | null = null
|
|
let cachedDescription: string | null = null
|
|
|
|
const getSkills = async (): Promise<LoadedSkill[]> => {
|
|
if (options.skills) return options.skills
|
|
if (cachedSkills) return cachedSkills
|
|
cachedSkills = await discoverSkills({ includeClaudeCodePaths: !options.opencodeOnly })
|
|
return cachedSkills
|
|
}
|
|
|
|
const getDescription = async (): Promise<string> => {
|
|
if (cachedDescription) return cachedDescription
|
|
const skills = await getSkills()
|
|
const skillInfos = skills.map(loadedSkillToInfo)
|
|
cachedDescription = skillInfos.length === 0
|
|
? TOOL_DESCRIPTION_NO_SKILLS
|
|
: TOOL_DESCRIPTION_PREFIX + formatSkillsXml(skillInfos)
|
|
return cachedDescription
|
|
}
|
|
|
|
getDescription()
|
|
|
|
return tool({
|
|
get description() {
|
|
return cachedDescription ?? TOOL_DESCRIPTION_PREFIX
|
|
},
|
|
args: {
|
|
name: tool.schema.string().describe("The skill identifier from available_skills (e.g., 'code-review')"),
|
|
},
|
|
async execute(args: SkillArgs) {
|
|
const skills = await getSkills()
|
|
const skill = skills.find(s => s.name === args.name)
|
|
|
|
if (!skill) {
|
|
const available = skills.map(s => s.name).join(", ")
|
|
throw new Error(`Skill "${args.name}" not found. Available skills: ${available || "none"}`)
|
|
}
|
|
|
|
const body = await extractSkillBody(skill)
|
|
const dir = skill.path ? dirname(skill.path) : skill.resolvedPath || process.cwd()
|
|
|
|
const output = [
|
|
`## Skill: ${skill.name}`,
|
|
"",
|
|
`**Base directory**: ${dir}`,
|
|
"",
|
|
body,
|
|
]
|
|
|
|
if (options.mcpManager && options.getSessionID && skill.mcpConfig) {
|
|
const mcpInfo = await formatMcpCapabilities(
|
|
skill,
|
|
options.mcpManager,
|
|
options.getSessionID()
|
|
)
|
|
if (mcpInfo) {
|
|
output.push(mcpInfo)
|
|
}
|
|
}
|
|
|
|
return output.join("\n")
|
|
},
|
|
})
|
|
}
|
|
|
|
export const skill = createSkillTool()
|