Per reviewer feedback (code-yeongyu), keep the 'skill' tool as the main tool and merge slashcommand functionality INTO it, rather than the reverse. Changes: - skill/tools.ts: Add command discovery (discoverCommandsSync) support; handle both SKILL.md skills and .omo/commands/ slash commands in a single tool; show combined listing in tool description - skill/types.ts: Add 'commands' option to SkillLoadOptions - skill/constants.ts: Update description to mention both skills and commands - plugin/tool-registry.ts: Replace createSlashcommandTool with createSkillTool; register tool as 'skill' instead of 'slashcommand' - tools/index.ts: Export createSkillTool instead of createSlashcommandTool - plugin/tool-execute-before.ts: Update tool name checks from 'slashcommand' to 'skill'; update arg name from 'command' to 'name' - agents/dynamic-agent-prompt-builder.ts: Categorize 'skill' tool as 'command' - tools/skill-mcp/tools.ts: Update hint message to reference 'skill' tool - hooks/auto-slash-command/executor.ts: Update error message The slashcommand/ module files are kept (they provide shared utilities used by the skill tool), but the slashcommand tool itself is no longer registered.
183 lines
6.4 KiB
TypeScript
183 lines
6.4 KiB
TypeScript
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
|
import { SKILL_MCP_DESCRIPTION } from "./constants"
|
|
import type { SkillMcpArgs } from "./types"
|
|
import type { SkillMcpManager, SkillMcpClientInfo, SkillMcpServerContext } from "../../features/skill-mcp-manager"
|
|
import type { LoadedSkill } from "../../features/opencode-skill-loader/types"
|
|
|
|
interface SkillMcpToolOptions {
|
|
manager: SkillMcpManager
|
|
getLoadedSkills: () => LoadedSkill[]
|
|
getSessionID: () => string
|
|
}
|
|
|
|
type OperationType = { type: "tool" | "resource" | "prompt"; name: string }
|
|
|
|
function validateOperationParams(args: SkillMcpArgs): OperationType {
|
|
const operations: OperationType[] = []
|
|
if (args.tool_name) operations.push({ type: "tool", name: args.tool_name })
|
|
if (args.resource_name) operations.push({ type: "resource", name: args.resource_name })
|
|
if (args.prompt_name) operations.push({ type: "prompt", name: args.prompt_name })
|
|
|
|
if (operations.length === 0) {
|
|
throw new Error(
|
|
`Missing operation. Exactly one of tool_name, resource_name, or prompt_name must be specified.\n\n` +
|
|
`Examples:\n` +
|
|
` skill_mcp(mcp_name="sqlite", tool_name="query", arguments='{"sql": "SELECT * FROM users"}')\n` +
|
|
` skill_mcp(mcp_name="memory", resource_name="memory://notes")\n` +
|
|
` skill_mcp(mcp_name="helper", prompt_name="summarize", arguments='{"text": "..."}')`,
|
|
)
|
|
}
|
|
|
|
if (operations.length > 1) {
|
|
const provided = [
|
|
args.tool_name && `tool_name="${args.tool_name}"`,
|
|
args.resource_name && `resource_name="${args.resource_name}"`,
|
|
args.prompt_name && `prompt_name="${args.prompt_name}"`,
|
|
]
|
|
.filter(Boolean)
|
|
.join(", ")
|
|
|
|
throw new Error(
|
|
`Multiple operations specified. Exactly one of tool_name, resource_name, or prompt_name must be provided.\n\n` +
|
|
`Received: ${provided}\n\n` +
|
|
`Use separate calls for each operation.`,
|
|
)
|
|
}
|
|
|
|
return operations[0]
|
|
}
|
|
|
|
function findMcpServer(
|
|
mcpName: string,
|
|
skills: LoadedSkill[],
|
|
): { skill: LoadedSkill; config: NonNullable<LoadedSkill["mcpConfig"]>[string] } | null {
|
|
for (const skill of skills) {
|
|
if (skill.mcpConfig && mcpName in skill.mcpConfig) {
|
|
return { skill, config: skill.mcpConfig[mcpName] }
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
function formatAvailableMcps(skills: LoadedSkill[]): string {
|
|
const mcps: string[] = []
|
|
for (const skill of skills) {
|
|
if (skill.mcpConfig) {
|
|
for (const serverName of Object.keys(skill.mcpConfig)) {
|
|
mcps.push(` - "${serverName}" from skill "${skill.name}"`)
|
|
}
|
|
}
|
|
}
|
|
return mcps.length > 0 ? mcps.join("\n") : " (none found)"
|
|
}
|
|
|
|
function parseArguments(argsJson: string | Record<string, unknown> | undefined): Record<string, unknown> {
|
|
if (!argsJson) return {}
|
|
if (typeof argsJson === "object" && argsJson !== null) {
|
|
return argsJson
|
|
}
|
|
try {
|
|
// Strip outer single quotes if present (common in LLM output)
|
|
const jsonStr = argsJson.startsWith("'") && argsJson.endsWith("'") ? argsJson.slice(1, -1) : argsJson
|
|
|
|
const parsed = JSON.parse(jsonStr)
|
|
if (typeof parsed !== "object" || parsed === null) {
|
|
throw new Error("Arguments must be a JSON object")
|
|
}
|
|
return parsed as Record<string, unknown>
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
throw new Error(
|
|
`Invalid arguments JSON: ${errorMessage}\n\n` +
|
|
`Expected a valid JSON object, e.g.: '{"key": "value"}'\n` +
|
|
`Received: ${argsJson}`,
|
|
)
|
|
}
|
|
}
|
|
|
|
export function applyGrepFilter(output: string, pattern: string | undefined): string {
|
|
if (!pattern) return output
|
|
try {
|
|
const regex = new RegExp(pattern, "i")
|
|
const lines = output.split("\n")
|
|
const filtered = lines.filter((line) => regex.test(line))
|
|
return filtered.length > 0 ? filtered.join("\n") : `[grep] No lines matched pattern: ${pattern}`
|
|
} catch {
|
|
return output
|
|
}
|
|
}
|
|
|
|
export function createSkillMcpTool(options: SkillMcpToolOptions): ToolDefinition {
|
|
const { manager, getLoadedSkills, getSessionID } = options
|
|
|
|
return tool({
|
|
description: SKILL_MCP_DESCRIPTION,
|
|
args: {
|
|
mcp_name: tool.schema.string().describe("Name of the MCP server from skill config"),
|
|
tool_name: tool.schema.string().optional().describe("MCP tool to call"),
|
|
resource_name: tool.schema.string().optional().describe("MCP resource URI to read"),
|
|
prompt_name: tool.schema.string().optional().describe("MCP prompt to get"),
|
|
arguments: tool.schema
|
|
.union([tool.schema.string(), tool.schema.object({})])
|
|
.optional()
|
|
.describe("JSON string or object of arguments"),
|
|
grep: tool.schema
|
|
.string()
|
|
.optional()
|
|
.describe("Regex pattern to filter output lines (only matching lines returned)"),
|
|
},
|
|
async execute(args: SkillMcpArgs) {
|
|
const operation = validateOperationParams(args)
|
|
const skills = getLoadedSkills()
|
|
const found = findMcpServer(args.mcp_name, skills)
|
|
|
|
if (!found) {
|
|
throw new Error(
|
|
`MCP server "${args.mcp_name}" not found.\n\n` +
|
|
`Available MCP servers in loaded skills:\n` +
|
|
formatAvailableMcps(skills) +
|
|
`\n\n` +
|
|
`Hint: Load the skill first using the 'skill' tool, then call skill_mcp.`,
|
|
)
|
|
}
|
|
|
|
const info: SkillMcpClientInfo = {
|
|
serverName: args.mcp_name,
|
|
skillName: found.skill.name,
|
|
sessionID: getSessionID(),
|
|
}
|
|
|
|
const context: SkillMcpServerContext = {
|
|
config: found.config,
|
|
skillName: found.skill.name,
|
|
}
|
|
|
|
const parsedArgs = parseArguments(args.arguments)
|
|
|
|
let output: string
|
|
switch (operation.type) {
|
|
case "tool": {
|
|
const result = await manager.callTool(info, context, operation.name, parsedArgs)
|
|
output = JSON.stringify(result, null, 2)
|
|
break
|
|
}
|
|
case "resource": {
|
|
const result = await manager.readResource(info, context, operation.name)
|
|
output = JSON.stringify(result, null, 2)
|
|
break
|
|
}
|
|
case "prompt": {
|
|
const stringArgs: Record<string, string> = {}
|
|
for (const [key, value] of Object.entries(parsedArgs)) {
|
|
stringArgs[key] = String(value)
|
|
}
|
|
const result = await manager.getPrompt(info, context, operation.name, stringArgs)
|
|
output = JSON.stringify(result, null, 2)
|
|
break
|
|
}
|
|
}
|
|
return applyGrepFilter(output, args.grep)
|
|
},
|
|
})
|
|
}
|