refactor: remove slashcommand tool implementation
This commit is contained in:
parent
552ad3a09c
commit
ee5df1683e
17
src/tools/slashcommand/index.test.ts
Normal file
17
src/tools/slashcommand/index.test.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { describe, expect, it } from "bun:test"
|
||||||
|
import * as slashcommand from "./index"
|
||||||
|
|
||||||
|
describe("slashcommand module exports", () => {
|
||||||
|
it("exports discovery API only", () => {
|
||||||
|
// given
|
||||||
|
const moduleExports = slashcommand as Record<string, unknown>
|
||||||
|
|
||||||
|
// when
|
||||||
|
const exportNames = Object.keys(moduleExports)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(exportNames).toContain("discoverCommandsSync")
|
||||||
|
expect(exportNames).not.toContain("createSlashcommandTool")
|
||||||
|
expect(exportNames).not.toContain("slashcommand")
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -1,2 +1,2 @@
|
|||||||
export * from "./types"
|
export * from "./types"
|
||||||
export { slashcommand, createSlashcommandTool, discoverCommandsSync } from "./tools"
|
export { discoverCommandsSync } from "./command-discovery"
|
||||||
|
|||||||
@ -1,20 +0,0 @@
|
|||||||
import type { LoadedSkill } from "../../features/opencode-skill-loader"
|
|
||||||
import type { CommandInfo } from "./types"
|
|
||||||
|
|
||||||
export function skillToCommandInfo(skill: LoadedSkill): CommandInfo {
|
|
||||||
return {
|
|
||||||
name: skill.name,
|
|
||||||
path: skill.path,
|
|
||||||
metadata: {
|
|
||||||
name: skill.name,
|
|
||||||
description: skill.definition.description || "",
|
|
||||||
argumentHint: skill.definition.argumentHint,
|
|
||||||
model: skill.definition.model,
|
|
||||||
agent: skill.definition.agent,
|
|
||||||
subtask: skill.definition.subtask,
|
|
||||||
},
|
|
||||||
content: skill.definition.template,
|
|
||||||
scope: skill.scope,
|
|
||||||
lazyContentLoader: skill.lazyContent,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,131 +0,0 @@
|
|||||||
import { dirname } from "node:path"
|
|
||||||
import type { LoadedSkill } from "../../features/opencode-skill-loader"
|
|
||||||
import { extractSkillTemplate } from "../../features/opencode-skill-loader/skill-content"
|
|
||||||
import { injectGitMasterConfig as injectGitMasterConfigOriginal } 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 type { GitMasterConfig } from "../../config/schema/git-master"
|
|
||||||
|
|
||||||
export 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) {
|
|
||||||
return extractSkillTemplate(skill)
|
|
||||||
}
|
|
||||||
|
|
||||||
const templateMatch = skill.definition.template?.match(/<skill-instruction>([\s\S]*?)<\/skill-instruction>/)
|
|
||||||
return templateMatch ? templateMatch[1].trim() : skill.definition.template || ""
|
|
||||||
}
|
|
||||||
|
|
||||||
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}`)
|
|
||||||
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 { injectGitMasterConfigOriginal as injectGitMasterConfig }
|
|
||||||
|
|
||||||
export async function formatSkillOutput(
|
|
||||||
skill: LoadedSkill,
|
|
||||||
mcpManager?: SkillMcpManager,
|
|
||||||
getSessionID?: () => string,
|
|
||||||
gitMasterConfig?: GitMasterConfig
|
|
||||||
): Promise<string> {
|
|
||||||
let body = await extractSkillBody(skill)
|
|
||||||
|
|
||||||
if (skill.name === "git-master") {
|
|
||||||
body = injectGitMasterConfigOriginal(body, gitMasterConfig)
|
|
||||||
}
|
|
||||||
|
|
||||||
const dir = skill.path ? dirname(skill.path) : skill.resolvedPath || process.cwd()
|
|
||||||
|
|
||||||
const output = [
|
|
||||||
`## Skill: ${skill.name}`,
|
|
||||||
"",
|
|
||||||
`**Base directory**: ${dir}`,
|
|
||||||
"",
|
|
||||||
body,
|
|
||||||
]
|
|
||||||
|
|
||||||
if (mcpManager && getSessionID && skill.mcpConfig) {
|
|
||||||
const mcpInfo = await formatMcpCapabilities(
|
|
||||||
skill,
|
|
||||||
mcpManager,
|
|
||||||
getSessionID()
|
|
||||||
)
|
|
||||||
if (mcpInfo) {
|
|
||||||
output.push(mcpInfo)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return output.join("\n")
|
|
||||||
}
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
import type { CommandInfo } from "./types"
|
|
||||||
|
|
||||||
export const TOOL_DESCRIPTION_PREFIX = `Load a skill or execute a command to get detailed instructions for a specific task.
|
|
||||||
|
|
||||||
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 command name only: command='publish'
|
|
||||||
- Call with command and arguments: command='publish' user_message='patch'
|
|
||||||
- The tool will return detailed instructions for the command with your arguments substituted.
|
|
||||||
`
|
|
||||||
|
|
||||||
export function buildDescriptionFromItems(items: CommandInfo[]): string {
|
|
||||||
const commandListForDescription = items
|
|
||||||
.map((command) => {
|
|
||||||
const hint = command.metadata.argumentHint ? ` ${command.metadata.argumentHint}` : ""
|
|
||||||
return `- /${command.name}${hint}: ${command.metadata.description} (${command.scope})`
|
|
||||||
})
|
|
||||||
.join("\n")
|
|
||||||
|
|
||||||
return `${TOOL_DESCRIPTION_PREFIX}
|
|
||||||
<available_skills>
|
|
||||||
${commandListForDescription}
|
|
||||||
</available_skills>`
|
|
||||||
}
|
|
||||||
@ -1,110 +0,0 @@
|
|||||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
|
||||||
import { discoverAllSkills, type LoadedSkill } from "../../features/opencode-skill-loader"
|
|
||||||
import type { CommandInfo, SlashcommandToolOptions } from "./types"
|
|
||||||
import { discoverCommandsSync } from "./command-discovery"
|
|
||||||
import { buildDescriptionFromItems, TOOL_DESCRIPTION_PREFIX } from "./slashcommand-description"
|
|
||||||
import { formatCommandList, formatLoadedCommand } from "./command-output-formatter"
|
|
||||||
import { skillToCommandInfo } from "./skill-command-converter"
|
|
||||||
import { formatSkillOutput } from "./skill-formatter"
|
|
||||||
|
|
||||||
export function createSlashcommandTool(options: SlashcommandToolOptions = {}): ToolDefinition {
|
|
||||||
let cachedCommands: CommandInfo[] | null = options.commands ?? null
|
|
||||||
let cachedSkills: LoadedSkill[] | null = options.skills ?? null
|
|
||||||
let cachedDescription: string | null = null
|
|
||||||
|
|
||||||
const getCommands = (): CommandInfo[] => {
|
|
||||||
if (cachedCommands) return cachedCommands
|
|
||||||
cachedCommands = discoverCommandsSync()
|
|
||||||
return cachedCommands
|
|
||||||
}
|
|
||||||
|
|
||||||
const getSkills = async (): Promise<LoadedSkill[]> => {
|
|
||||||
if (cachedSkills) return cachedSkills
|
|
||||||
cachedSkills = await discoverAllSkills()
|
|
||||||
return cachedSkills
|
|
||||||
}
|
|
||||||
|
|
||||||
const getAllItems = async (): Promise<CommandInfo[]> => {
|
|
||||||
const commands = getCommands()
|
|
||||||
const skills = await getSkills()
|
|
||||||
return [...commands, ...skills.map(skillToCommandInfo)]
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildDescription = async (): Promise<string> => {
|
|
||||||
if (cachedDescription) return cachedDescription
|
|
||||||
const commands = getCommands()
|
|
||||||
cachedDescription = buildDescriptionFromItems(commands)
|
|
||||||
return cachedDescription
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.commands !== undefined) {
|
|
||||||
cachedDescription = buildDescriptionFromItems(options.commands)
|
|
||||||
} else {
|
|
||||||
void buildDescription()
|
|
||||||
}
|
|
||||||
|
|
||||||
return tool({
|
|
||||||
get description() {
|
|
||||||
return cachedDescription ?? TOOL_DESCRIPTION_PREFIX
|
|
||||||
},
|
|
||||||
|
|
||||||
args: {
|
|
||||||
command: tool.schema
|
|
||||||
.string()
|
|
||||||
.describe(
|
|
||||||
"The slash command name (without leading slash). E.g., 'publish', 'commit', 'plan'"
|
|
||||||
),
|
|
||||||
user_message: tool.schema
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
"Optional arguments or context to pass to the command. E.g., for '/publish patch', command='publish' user_message='patch'"
|
|
||||||
),
|
|
||||||
},
|
|
||||||
|
|
||||||
async execute(args) {
|
|
||||||
const allItems = await getAllItems()
|
|
||||||
|
|
||||||
if (!args.command) {
|
|
||||||
return formatCommandList(allItems) + "\n\nProvide a command or skill name to execute."
|
|
||||||
}
|
|
||||||
|
|
||||||
const commandName = args.command.replace(/^\//, "")
|
|
||||||
|
|
||||||
const exactMatch = allItems.find(
|
|
||||||
(command) => command.name.toLowerCase() === commandName.toLowerCase()
|
|
||||||
)
|
|
||||||
|
|
||||||
if (exactMatch) {
|
|
||||||
const skills = await getSkills()
|
|
||||||
const matchedSkill = skills.find(s => s.name === exactMatch.name)
|
|
||||||
|
|
||||||
if (matchedSkill) {
|
|
||||||
return await formatSkillOutput(
|
|
||||||
matchedSkill,
|
|
||||||
options.mcpManager,
|
|
||||||
options.getSessionID,
|
|
||||||
options.gitMasterConfig
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return await formatLoadedCommand(exactMatch, args.user_message)
|
|
||||||
}
|
|
||||||
|
|
||||||
const partialMatches = allItems.filter((command) =>
|
|
||||||
command.name.toLowerCase().includes(commandName.toLowerCase())
|
|
||||||
)
|
|
||||||
|
|
||||||
if (partialMatches.length > 0) {
|
|
||||||
const matchList = partialMatches.map((command) => `/${command.name}`).join(", ")
|
|
||||||
return `No exact match for "/${commandName}". Did you mean: ${matchList}?\n\n${formatCommandList(allItems)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
return commandName.includes(":")
|
|
||||||
? `Marketplace plugin commands like "/${commandName}" are not supported. Use .claude/commands/ for custom commands.\n\n${formatCommandList(allItems)}`
|
|
||||||
: `Command or skill "/${commandName}" not found.\n\n${formatCommandList(allItems)}\n\nTry a different name.`
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const slashcommand: ToolDefinition = createSlashcommandTool()
|
|
||||||
@ -1,88 +0,0 @@
|
|||||||
import { describe, it, expect } from "bun:test"
|
|
||||||
import { createSlashcommandTool } from "./tools"
|
|
||||||
import type { CommandInfo } from "./types"
|
|
||||||
import type { LoadedSkill } from "../../features/opencode-skill-loader"
|
|
||||||
|
|
||||||
function createMockCommand(name: string, description = ""): CommandInfo {
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
metadata: {
|
|
||||||
name,
|
|
||||||
description: description || `Test command ${name}`,
|
|
||||||
},
|
|
||||||
scope: "builtin",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMockSkill(name: string, description = ""): LoadedSkill {
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
path: `/test/skills/${name}/SKILL.md`,
|
|
||||||
resolvedPath: `/test/skills/${name}`,
|
|
||||||
definition: {
|
|
||||||
name,
|
|
||||||
description: description || `Test skill ${name}`,
|
|
||||||
template: "Test template",
|
|
||||||
},
|
|
||||||
scope: "opencode-project",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("slashcommand tool - synchronous description", () => {
|
|
||||||
it("includes only commands in description, not skills", () => {
|
|
||||||
// given
|
|
||||||
const commands = [createMockCommand("commit", "Create a git commit")]
|
|
||||||
const skills = [createMockSkill("playwright", "Browser automation via Playwright MCP")]
|
|
||||||
|
|
||||||
// when
|
|
||||||
const tool = createSlashcommandTool({ commands, skills })
|
|
||||||
|
|
||||||
// then
|
|
||||||
expect(tool.description).toContain("commit")
|
|
||||||
expect(tool.description).not.toContain("playwright")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("lists all commands but excludes skills from description", () => {
|
|
||||||
// given
|
|
||||||
const commands = [
|
|
||||||
createMockCommand("commit", "Git commit"),
|
|
||||||
createMockCommand("plan", "Create plan"),
|
|
||||||
]
|
|
||||||
const skills = [
|
|
||||||
createMockSkill("playwright", "Browser automation"),
|
|
||||||
createMockSkill("frontend-ui-ux", "Frontend design"),
|
|
||||||
createMockSkill("git-master", "Git operations"),
|
|
||||||
]
|
|
||||||
|
|
||||||
// when
|
|
||||||
const tool = createSlashcommandTool({ commands, skills })
|
|
||||||
|
|
||||||
// then
|
|
||||||
expect(tool.description).toContain("commit")
|
|
||||||
expect(tool.description).toContain("plan")
|
|
||||||
expect(tool.description).not.toContain("playwright")
|
|
||||||
expect(tool.description).not.toContain("frontend-ui-ux")
|
|
||||||
expect(tool.description).not.toContain("git-master")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("shows prefix-only description when both commands and skills are empty", () => {
|
|
||||||
// given / #when
|
|
||||||
const tool = createSlashcommandTool({ commands: [], skills: [] })
|
|
||||||
|
|
||||||
// then - even with no items, description should be built synchronously (not just prefix)
|
|
||||||
expect(tool.description).toContain("Load a skill")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("includes user_message parameter documentation in description", () => {
|
|
||||||
// given
|
|
||||||
const commands = [createMockCommand("publish", "Publish package")]
|
|
||||||
const skills: LoadedSkill[] = []
|
|
||||||
|
|
||||||
// when
|
|
||||||
const tool = createSlashcommandTool({ commands, skills })
|
|
||||||
|
|
||||||
// then
|
|
||||||
expect(tool.description).toContain("user_message")
|
|
||||||
expect(tool.description).toContain("command='publish' user_message='patch'")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
export { discoverCommandsSync } from "./command-discovery"
|
|
||||||
export { createSlashcommandTool, slashcommand } from "./slashcommand-tool"
|
|
||||||
@ -1,6 +1,4 @@
|
|||||||
import type { LoadedSkill, LazyContentLoader } from "../../features/opencode-skill-loader"
|
import type { LazyContentLoader } from "../../features/opencode-skill-loader"
|
||||||
import type { SkillMcpManager } from "../../features/skill-mcp-manager"
|
|
||||||
import type { GitMasterConfig } from "../../config/schema/git-master"
|
|
||||||
|
|
||||||
export type CommandScope = "builtin" | "config" | "user" | "project" | "opencode" | "opencode-project"
|
export type CommandScope = "builtin" | "config" | "user" | "project" | "opencode" | "opencode-project"
|
||||||
|
|
||||||
@ -21,16 +19,3 @@ export interface CommandInfo {
|
|||||||
scope: CommandScope
|
scope: CommandScope
|
||||||
lazyContentLoader?: LazyContentLoader
|
lazyContentLoader?: LazyContentLoader
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SlashcommandToolOptions {
|
|
||||||
/** Pre-loaded commands (skip discovery if provided) */
|
|
||||||
commands?: CommandInfo[]
|
|
||||||
/** Pre-loaded skills (skip discovery if provided) */
|
|
||||||
skills?: LoadedSkill[]
|
|
||||||
/** MCP manager for skill MCP capabilities */
|
|
||||||
mcpManager?: SkillMcpManager
|
|
||||||
/** Function to get current session ID */
|
|
||||||
getSessionID?: () => string
|
|
||||||
/** Git master configuration */
|
|
||||||
gitMasterConfig?: GitMasterConfig
|
|
||||||
}
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user