refactor: merge slashcommand tool into skill tool
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.
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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.`,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.`,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
`
|
||||
|
||||
@@ -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.name}</name>`,
|
||||
` <description>${skill.description}</description>`,
|
||||
]
|
||||
if (skill.compatibility) {
|
||||
lines.push(` <compatibility>${skill.compatibility}</compatibility>`)
|
||||
}
|
||||
lines.push(" </skill>")
|
||||
return lines.join("\n")
|
||||
}).join("\n")
|
||||
if (skills.length === 0 && commands.length === 0) {
|
||||
return TOOL_DESCRIPTION_NO_SKILLS
|
||||
}
|
||||
|
||||
return `\n\n<available_skills>\n${skillsXml}\n</available_skills>`
|
||||
if (skills.length > 0) {
|
||||
const skillsXml = skills.map(skill => {
|
||||
const parts = [
|
||||
" <skill>",
|
||||
` <name>${skill.name}</name>`,
|
||||
` <description>${skill.description}</description>`,
|
||||
]
|
||||
if (skill.compatibility) {
|
||||
parts.push(` <compatibility>${skill.compatibility}</compatibility>`)
|
||||
}
|
||||
parts.push(" </skill>")
|
||||
return parts.join("\n")
|
||||
}).join("\n")
|
||||
lines.push(`\n<available_skills>\n${skillsXml}\n</available_skills>`)
|
||||
}
|
||||
|
||||
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<available_commands>\n${commandLines}\n</available_commands>`)
|
||||
}
|
||||
|
||||
return TOOL_DESCRIPTION_PREFIX + lines.join("")
|
||||
}
|
||||
|
||||
async function extractSkillBody(skill: LoadedSkill): Promise<string> {
|
||||
@@ -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<LoadedSkill[]> => {
|
||||
@@ -137,23 +156,27 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
|
||||
return cachedSkills
|
||||
}
|
||||
|
||||
const getDescription = async (): Promise<string> => {
|
||||
const getCommands = (): CommandInfo[] => {
|
||||
if (cachedCommands) return cachedCommands
|
||||
cachedCommands = discoverCommandsSync()
|
||||
return cachedCommands
|
||||
}
|
||||
|
||||
const buildDescription = async (): Promise<string> => {
|
||||
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"}`
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user