diff --git a/src/agents/dynamic-agent-prompt-builder.ts b/src/agents/dynamic-agent-prompt-builder.ts index abb1297f1..163115903 100644 --- a/src/agents/dynamic-agent-prompt-builder.ts +++ b/src/agents/dynamic-agent-prompt-builder.ts @@ -35,7 +35,7 @@ export function categorizeTools(toolNames: string[]): AvailableTool[] { category = "search" } else if (name.startsWith("session_")) { category = "session" - } else if (name === "slashcommand") { + } else if (name === "skill") { category = "command" } return { name, category } diff --git a/src/hooks/auto-slash-command/executor.ts b/src/hooks/auto-slash-command/executor.ts index 185ca4797..ffa96be8b 100644 --- a/src/hooks/auto-slash-command/executor.ts +++ b/src/hooks/auto-slash-command/executor.ts @@ -202,7 +202,9 @@ export async function executeSlashCommand(parsed: ParsedSlashCommand, options?: if (!command) { return { success: false, - error: parsed.command.includes(":") ? `Marketplace plugin commands like "/${parsed.command}" are not supported. Use .claude/commands/ for custom commands.` : `Command "/${parsed.command}" not found. Use the slashcommand tool to list available commands.`, + error: parsed.command.includes(":") + ? `Marketplace plugin commands like "/${parsed.command}" are not supported. Use .claude/commands/ for custom commands.` + : `Command "/${parsed.command}" not found. Use the skill tool to list available skills and commands.`, } } diff --git a/src/plugin/tool-execute-before.ts b/src/plugin/tool-execute-before.ts index 70d023b4c..09ae16818 100644 --- a/src/plugin/tool-execute-before.ts +++ b/src/plugin/tool-execute-before.ts @@ -43,13 +43,13 @@ export function createToolExecuteBeforeHandler(args: { } } - if (hooks.ralphLoop && input.tool === "slashcommand") { - const rawCommand = typeof output.args.command === "string" ? output.args.command : undefined - const command = rawCommand?.replace(/^\//, "").toLowerCase() + if (hooks.ralphLoop && input.tool === "skill") { + const rawName = typeof output.args.name === "string" ? output.args.name : undefined + const command = rawName?.replace(/^\//, "").toLowerCase() const sessionID = input.sessionID || getMainSessionID() if (command === "ralph-loop" && sessionID) { - const rawArgs = rawCommand?.replace(/^\/?(ralph-loop)\s*/i, "") || "" + const rawArgs = rawName?.replace(/^\/?(ralph-loop)\s*/i, "") || "" const taskMatch = rawArgs.match(/^["'](.+?)["']/) const prompt = taskMatch?.[1] || @@ -66,7 +66,7 @@ export function createToolExecuteBeforeHandler(args: { } else if (command === "cancel-ralph" && sessionID) { hooks.ralphLoop.cancelLoop(sessionID) } else if (command === "ulw-loop" && sessionID) { - const rawArgs = rawCommand?.replace(/^\/?(ulw-loop)\s*/i, "") || "" + const rawArgs = rawName?.replace(/^\/?(ulw-loop)\s*/i, "") || "" const taskMatch = rawArgs.match(/^["'](.+?)["']/) const prompt = taskMatch?.[1] || @@ -84,9 +84,9 @@ export function createToolExecuteBeforeHandler(args: { } } - if (input.tool === "slashcommand") { - const rawCommand = typeof output.args.command === "string" ? output.args.command : undefined - const command = rawCommand?.replace(/^\//, "").toLowerCase() + if (input.tool === "skill") { + const rawName = typeof output.args.name === "string" ? output.args.name : undefined + const command = rawName?.replace(/^\//, "").toLowerCase() const sessionID = input.sessionID || getMainSessionID() if (command === "stop-continuation" && sessionID) { diff --git a/src/plugin/tool-registry.ts b/src/plugin/tool-registry.ts index a77aa6ec7..c3105db98 100644 --- a/src/plugin/tool-registry.ts +++ b/src/plugin/tool-registry.ts @@ -12,7 +12,7 @@ import { createCallOmoAgent, createLookAt, createSkillMcpTool, - createSlashcommandTool, + createSkillTool, createGrepTools, createGlobTools, createAstGrepTools, @@ -95,7 +95,7 @@ export function createToolRegistry(args: { }) const commands = discoverCommandsSync(ctx.directory) - const slashcommandTool = createSlashcommandTool({ + const skillTool = createSkillTool({ commands, skills: skillContext.mergedSkills, mcpManager: managers.skillMcpManager, @@ -129,7 +129,7 @@ export function createToolRegistry(args: { ...(lookAt ? { look_at: lookAt } : {}), task: delegateTask, skill_mcp: skillMcpTool, - slashcommand: slashcommandTool, + skill: skillTool, interactive_bash, ...taskToolsRecord, ...hashlineToolsRecord, diff --git a/src/tools/index.ts b/src/tools/index.ts index 09d2b1548..9d9bd9c04 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -13,7 +13,8 @@ export { lspManager } export { createAstGrepTools } from "./ast-grep" export { createGrepTools } from "./grep" export { createGlobTools } from "./glob" -export { createSlashcommandTool, discoverCommandsSync } from "./slashcommand" +export { createSkillTool } from "./skill" +export { discoverCommandsSync } from "./slashcommand" export { createSessionManagerTools } from "./session-manager" export { sessionExists } from "./session-manager/storage" diff --git a/src/tools/skill-mcp/tools.ts b/src/tools/skill-mcp/tools.ts index 776902744..96dddaa75 100644 --- a/src/tools/skill-mcp/tools.ts +++ b/src/tools/skill-mcp/tools.ts @@ -137,7 +137,7 @@ export function createSkillMcpTool(options: SkillMcpToolOptions): ToolDefinition `Available MCP servers in loaded skills:\n` + formatAvailableMcps(skills) + `\n\n` + - `Hint: Load the skill first using the 'slashcommand' tool, then call skill_mcp.`, + `Hint: Load the skill first using the 'skill' tool, then call skill_mcp.`, ) } diff --git a/src/tools/skill/constants.ts b/src/tools/skill/constants.ts index 538dc0981..52429f7bc 100644 --- a/src/tools/skill/constants.ts +++ b/src/tools/skill/constants.ts @@ -1,8 +1,14 @@ export const TOOL_NAME = "skill" as const -export const TOOL_DESCRIPTION_NO_SKILLS = "Load a skill to get detailed instructions for a specific task. No skills are currently available." +export const TOOL_DESCRIPTION_NO_SKILLS = "Load a skill or execute a slash command to get detailed instructions for a specific task. No skills or commands are currently available." -export const TOOL_DESCRIPTION_PREFIX = `Load a skill to get detailed instructions for a specific task. +export const TOOL_DESCRIPTION_PREFIX = `Load a skill or execute a slash command to get detailed instructions for a specific task. -Skills provide specialized knowledge and step-by-step guidance. -Use this when a task matches an available skill's description.` +Skills and commands provide specialized knowledge and step-by-step guidance. +Use this when a task matches an available skill's or command's description. + +**How to use:** +- Call with a skill name: name='code-review' +- Call with a command name (without leading slash): name='publish' +- The tool will return detailed instructions with your context applied. +` diff --git a/src/tools/skill/tools.ts b/src/tools/skill/tools.ts index 006a07b48..5791037c1 100644 --- a/src/tools/skill/tools.ts +++ b/src/tools/skill/tools.ts @@ -7,6 +7,9 @@ import { getAllSkills, extractSkillTemplate } from "../../features/opencode-skil 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 { discoverCommandsSync } from "../slashcommand/command-discovery" +import type { CommandInfo } from "../slashcommand/types" +import { formatLoadedCommand } from "../slashcommand/command-output-formatter" function loadedSkillToInfo(skill: LoadedSkill): SkillInfo { return { @@ -21,23 +24,38 @@ function loadedSkillToInfo(skill: LoadedSkill): SkillInfo { } } -function formatSkillsXml(skills: SkillInfo[]): string { - if (skills.length === 0) return "" +function formatCombinedDescription(skills: SkillInfo[], commands: CommandInfo[]): string { + const lines: string[] = [] - const skillsXml = skills.map(skill => { - const lines = [ - " ", - ` ${skill.name}`, - ` ${skill.description}`, - ] - if (skill.compatibility) { - lines.push(` ${skill.compatibility}`) - } - lines.push(" ") - return lines.join("\n") - }).join("\n") + if (skills.length === 0 && commands.length === 0) { + return TOOL_DESCRIPTION_NO_SKILLS + } - return `\n\n\n${skillsXml}\n` + if (skills.length > 0) { + const skillsXml = skills.map(skill => { + const parts = [ + " ", + ` ${skill.name}`, + ` ${skill.description}`, + ] + if (skill.compatibility) { + parts.push(` ${skill.compatibility}`) + } + parts.push(" ") + return parts.join("\n") + }).join("\n") + lines.push(`\n\n${skillsXml}\n`) + } + + if (commands.length > 0) { + const commandLines = commands.map(cmd => { + const hint = cmd.metadata.argumentHint ? ` ${cmd.metadata.argumentHint}` : "" + return ` - /${cmd.name}${hint}: ${cmd.metadata.description || "(no description)"} (${cmd.scope})` + }).join("\n") + lines.push(`\n\n${commandLines}\n`) + } + + return TOOL_DESCRIPTION_PREFIX + lines.join("") } async function extractSkillBody(skill: LoadedSkill): Promise { @@ -128,6 +146,7 @@ async function formatMcpCapabilities( export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition { let cachedSkills: LoadedSkill[] | null = null + let cachedCommands: CommandInfo[] | null = options.commands ?? null let cachedDescription: string | null = null const getSkills = async (): Promise => { @@ -137,23 +156,27 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition return cachedSkills } - const getDescription = async (): Promise => { + const getCommands = (): CommandInfo[] => { + if (cachedCommands) return cachedCommands + cachedCommands = discoverCommandsSync() + return cachedCommands + } + + const buildDescription = async (): Promise => { if (cachedDescription) return cachedDescription const skills = await getSkills() + const commands = getCommands() const skillInfos = skills.map(loadedSkillToInfo) - cachedDescription = skillInfos.length === 0 - ? TOOL_DESCRIPTION_NO_SKILLS - : TOOL_DESCRIPTION_PREFIX + formatSkillsXml(skillInfos) + cachedDescription = formatCombinedDescription(skillInfos, commands) return cachedDescription } - if (options.skills) { + // Eagerly build description if possible + if (options.skills && options.commands !== undefined) { const skillInfos = options.skills.map(loadedSkillToInfo) - cachedDescription = skillInfos.length === 0 - ? TOOL_DESCRIPTION_NO_SKILLS - : TOOL_DESCRIPTION_PREFIX + formatSkillsXml(skillInfos) + cachedDescription = formatCombinedDescription(skillInfos, options.commands) } else { - getDescription() + void buildDescription() } return tool({ @@ -161,49 +184,79 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition return cachedDescription ?? TOOL_DESCRIPTION_PREFIX }, args: { - name: tool.schema.string().describe("The skill identifier from available_skills (e.g., 'code-review')"), + name: tool.schema.string().describe("The skill or command name (e.g., 'code-review' or 'publish'). Use without leading slash for commands."), }, async execute(args: SkillArgs, ctx?: { agent?: string }) { const skills = await getSkills() - const skill = skills.find(s => s.name === args.name) + const commands = getCommands() - if (!skill) { - const available = skills.map(s => s.name).join(", ") - throw new Error(`Skill "${args.name}" not found. Available skills: ${available || "none"}`) + const requestedName = args.name.replace(/^\//, "") + + // Check skills first (exact match, case-insensitive) + const matchedSkill = skills.find(s => s.name.toLowerCase() === requestedName.toLowerCase()) + + if (matchedSkill) { + if (matchedSkill.definition.agent && (!ctx?.agent || matchedSkill.definition.agent !== ctx.agent)) { + throw new Error(`Skill "${matchedSkill.name}" is restricted to agent "${matchedSkill.definition.agent}"`) + } + + let body = await extractSkillBody(matchedSkill) + + if (matchedSkill.name === "git-master") { + body = injectGitMasterConfig(body, options.gitMasterConfig) + } + + const dir = matchedSkill.path ? dirname(matchedSkill.path) : matchedSkill.resolvedPath || process.cwd() + + const output = [ + `## Skill: ${matchedSkill.name}`, + "", + `**Base directory**: ${dir}`, + "", + body, + ] + + if (options.mcpManager && options.getSessionID && matchedSkill.mcpConfig) { + const mcpInfo = await formatMcpCapabilities( + matchedSkill, + options.mcpManager, + options.getSessionID() + ) + if (mcpInfo) { + output.push(mcpInfo) + } + } + + return output.join("\n") } - if (skill.definition.agent && (!ctx?.agent || skill.definition.agent !== ctx.agent)) { - throw new Error(`Skill "${args.name}" is restricted to agent "${skill.definition.agent}"`) + // Check commands (exact match, case-insensitive) + const matchedCommand = commands.find(c => c.name.toLowerCase() === requestedName.toLowerCase()) + + if (matchedCommand) { + return await formatLoadedCommand(matchedCommand) } - let body = await extractSkillBody(skill) - - if (args.name === "git-master") { - body = injectGitMasterConfig(body, options.gitMasterConfig) - } - - const dir = skill.path ? dirname(skill.path) : skill.resolvedPath || process.cwd() - - const output = [ - `## Skill: ${skill.name}`, - "", - `**Base directory**: ${dir}`, - "", - body, + // No match found — provide helpful error with partial matches + const allNames = [ + ...skills.map(s => s.name), + ...commands.map(c => `/${c.name}`), ] - if (options.mcpManager && options.getSessionID && skill.mcpConfig) { - const mcpInfo = await formatMcpCapabilities( - skill, - options.mcpManager, - options.getSessionID() + const partialMatches = allNames.filter(n => + n.toLowerCase().includes(requestedName.toLowerCase()) + ) + + if (partialMatches.length > 0) { + throw new Error( + `Skill or command "${args.name}" not found. Did you mean: ${partialMatches.join(", ")}?` ) - if (mcpInfo) { - output.push(mcpInfo) - } } - return output.join("\n") + const available = allNames.join(", ") + throw new Error( + `Skill or command "${args.name}" not found. Available: ${available || "none"}` + ) }, }) } diff --git a/src/tools/skill/types.ts b/src/tools/skill/types.ts index 3babfeef6..13551ba94 100644 --- a/src/tools/skill/types.ts +++ b/src/tools/skill/types.ts @@ -1,6 +1,7 @@ import type { SkillScope, LoadedSkill } from "../../features/opencode-skill-loader/types" import type { SkillMcpManager } from "../../features/skill-mcp-manager" import type { GitMasterConfig } from "../../config/schema" +import type { CommandInfo } from "../slashcommand/types" export interface SkillArgs { name: string @@ -22,6 +23,8 @@ export interface SkillLoadOptions { opencodeOnly?: boolean /** Pre-merged skills to use instead of discovering */ skills?: LoadedSkill[] + /** Pre-discovered commands to use instead of discovering */ + commands?: CommandInfo[] /** MCP manager for querying skill-embedded MCP servers */ mcpManager?: SkillMcpManager /** Session ID getter for MCP client identification */