From 4d097e161d897bd9be4e7b3624067153fada7f8e Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Fri, 30 Jan 2026 09:16:27 -0700 Subject: [PATCH] feat: auto-generate slash commands from skills All skills are now automatically available as slash commands by their mere existence - no configuration needed. Skills can be invoked using /skill-name syntax. - Add getSkillsAsCommands() method to SkillsManager - Merge skill commands with regular commands in webviewMessageHandler - Handle skill-based commands in RunSlashCommandTool - Add unit tests for skill-as-command functionality --- src/core/tools/RunSlashCommandTool.ts | 97 +++++- src/core/webview/webviewMessageHandler.ts | 36 ++- src/services/skills/SkillsManager.ts | 27 ++ .../skills/__tests__/SkillsManager.spec.ts | 68 +++++ .../generate-built-in-skills.spec.ts | 12 + src/services/skills/built-in-skills.ts | 283 +++++++++--------- 6 files changed, 362 insertions(+), 161 deletions(-) diff --git a/src/core/tools/RunSlashCommandTool.ts b/src/core/tools/RunSlashCommandTool.ts index 0bcf970226..44116e58d0 100644 --- a/src/core/tools/RunSlashCommandTool.ts +++ b/src/core/tools/RunSlashCommandTool.ts @@ -5,6 +5,7 @@ import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { BaseTool, ToolCallbacks } from "./BaseTool" import type { ToolUse } from "../../shared/tools" import { getModeBySlug } from "../../shared/modes" +import type { SkillContent } from "../../shared/skills" interface RunSlashCommandParams { command: string @@ -49,26 +50,91 @@ export class RunSlashCommandTool extends BaseTool<"run_slash_command"> { // Get the command from the commands service const command = await getCommand(task.cwd, commandName) + // Check if this might be a skill-based command + let skillContent: SkillContent | null = null + let skillName: string | undefined + if (!command) { + // Try to find a skill-based command with this name + const skillsManager = provider?.getSkillsManager?.() + const currentMode = state?.mode ?? "code" + + if (skillsManager) { + const skillCommands = skillsManager.getSkillsAsCommands(currentMode) + const matchingSkillCmd = skillCommands.find((sc) => sc.name === commandName) + + if (matchingSkillCmd) { + // Found a skill-based command - load the skill content + skillName = matchingSkillCmd.skillName + skillContent = await skillsManager.getSkillContent(skillName, currentMode) + } + } + } + + if (!command && !skillContent) { // Get available commands for error message const availableCommands = await getCommandNames(task.cwd) + + // Also include skill-based command names + const skillsManager = provider?.getSkillsManager?.() + const currentMode = state?.mode ?? "code" + const skillCommandNames = skillsManager?.getSkillsAsCommands(currentMode).map((sc) => sc.name) ?? [] + const allCommandNames = [...new Set([...availableCommands, ...skillCommandNames])] + task.recordToolError("run_slash_command") task.didToolFailInCurrentTurn = true pushToolResult( formatResponse.toolError( - `Command '${commandName}' not found. Available commands: ${availableCommands.join(", ") || "(none)"}`, + `Command '${commandName}' not found. Available commands: ${allCommandNames.join(", ") || "(none)"}`, ), ) return } + // Handle skill-based command + if (skillContent) { + const toolMessage = JSON.stringify({ + tool: "runSlashCommand", + command: commandName, + args: args, + source: skillContent.source, + description: skillContent.description, + isSkill: true, + skillName: skillName, + }) + + const didApprove = await askApproval("tool", toolMessage) + + if (!didApprove) { + return + } + + // Build the result message for skill-based command + let result = `Skill Command: /${commandName}` + + if (skillContent.description) { + result += `\nDescription: ${skillContent.description}` + } + + if (args) { + result += `\nProvided arguments: ${args}` + } + + result += `\nSource: ${skillContent.source} (skill: ${skillName})` + result += `\n\n--- Skill Instructions ---\n\n${skillContent.instructions}` + + pushToolResult(result) + return + } + + // Handle regular command const toolMessage = JSON.stringify({ tool: "runSlashCommand", command: commandName, args: args, - source: command.source, - description: command.description, - mode: command.mode, + source: command!.source, + description: command!.description, + mode: command!.mode, }) const didApprove = await askApproval("tool", toolMessage) @@ -78,35 +144,34 @@ export class RunSlashCommandTool extends BaseTool<"run_slash_command"> { } // Switch mode if specified in the command frontmatter - if (command.mode) { - const provider = task.providerRef.deref() - const targetMode = getModeBySlug(command.mode, (await provider?.getState())?.customModes) + if (command!.mode) { + const targetMode = getModeBySlug(command!.mode, (await provider?.getState())?.customModes) if (targetMode) { - await provider?.handleModeSwitch(command.mode) + await provider?.handleModeSwitch(command!.mode) } } // Build the result message let result = `Command: /${commandName}` - if (command.description) { - result += `\nDescription: ${command.description}` + if (command!.description) { + result += `\nDescription: ${command!.description}` } - if (command.argumentHint) { - result += `\nArgument hint: ${command.argumentHint}` + if (command!.argumentHint) { + result += `\nArgument hint: ${command!.argumentHint}` } - if (command.mode) { - result += `\nMode: ${command.mode}` + if (command!.mode) { + result += `\nMode: ${command!.mode}` } if (args) { result += `\nProvided arguments: ${args}` } - result += `\nSource: ${command.source}` - result += `\n\n--- Command Content ---\n\n${command.content}` + result += `\nSource: ${command!.source}` + result += `\n\n--- Command Content ---\n\n${command!.content}` // Return the command content as the tool result pushToolResult(result) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index cc4b1a27e9..f88e501477 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -2951,9 +2951,43 @@ export const webviewMessageHandler = async ( filePath: command.filePath, description: command.description, argumentHint: command.argumentHint, + isSkill: false, })) - await provider.postMessageToWebview({ type: "commands", commands: commandList }) + // Get skill-based commands + const skillsManager = provider.getSkillsManager() + const state = await provider.getState() + const currentMode = state?.mode ?? "code" + const skillCommands = skillsManager?.getSkillsAsCommands(currentMode) ?? [] + + // Add skill-based commands (with isSkill flag for differentiation) + const skillCommandList = skillCommands.map((skill) => ({ + name: skill.name, + source: skill.source, + filePath: skill.skillPath, + description: skill.description, + argumentHint: undefined as string | undefined, + isSkill: true, + skillName: skill.skillName, + })) + + // Merge, with regular commands taking priority over skill commands + const mergedCommands: Array<{ + name: string + source: "global" | "project" | "built-in" + filePath: string + description: string | undefined + argumentHint?: string | undefined + isSkill: boolean + skillName?: string + }> = [...commandList] + for (const skillCmd of skillCommandList) { + if (!mergedCommands.some((c) => c.name === skillCmd.name)) { + mergedCommands.push(skillCmd) + } + } + + await provider.postMessageToWebview({ type: "commands", commands: mergedCommands }) } catch (error) { provider.log(`Error fetching commands: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`) await provider.postMessageToWebview({ type: "commands", commands: [] }) diff --git a/src/services/skills/SkillsManager.ts b/src/services/skills/SkillsManager.ts index 1c61b5b176..78acc84d5c 100644 --- a/src/services/skills/SkillsManager.ts +++ b/src/services/skills/SkillsManager.ts @@ -233,6 +233,33 @@ export class SkillsManager { return Array.from(this.skills.values()) } + /** + * Get all skills as slash commands. + * All skills are automatically available as slash commands. + * Returns them in a format compatible with the Command interface. + * + * @param currentMode - The current mode slug to filter skills by + */ + getSkillsAsCommands(currentMode: string): Array<{ + name: string + description: string + source: "global" | "project" | "built-in" + skillPath: string + skillName: string + mode?: string + }> { + const skills = this.getSkillsForMode(currentMode) + + return skills.map((skill) => ({ + name: skill.name, + description: skill.description, + source: skill.source, + skillPath: skill.path, + skillName: skill.name, // Original skill name for invocation + mode: skill.mode, + })) + } + async getSkillContent(name: string, currentMode?: string): Promise { // If mode is provided, try to find the best matching skill let skill: SkillMetadata | undefined diff --git a/src/services/skills/__tests__/SkillsManager.spec.ts b/src/services/skills/__tests__/SkillsManager.spec.ts index aaf2792626..9f29464a10 100644 --- a/src/services/skills/__tests__/SkillsManager.spec.ts +++ b/src/services/skills/__tests__/SkillsManager.spec.ts @@ -827,6 +827,74 @@ description: A test skill }) }) + describe("getSkillsAsCommands", () => { + it("should return all skills as slash commands automatically", async () => { + const skillDir = p(globalSkillsDir, "my-skill") + const skillMdPath = p(skillDir, "SKILL.md") + + mockDirectoryExists.mockImplementation(async (p: string) => { + if (p === globalSkillsDir) return true + return false + }) + mockFileExists.mockImplementation(async (p: string) => p === skillMdPath) + mockRealpath.mockImplementation(async (p: string) => p) + mockReaddir.mockResolvedValue(["my-skill"]) + mockStat.mockResolvedValue({ isDirectory: () => true }) + + const skillContent = `--- +name: my-skill +description: A test skill +--- + +# My Skill Instructions` + + mockReadFile.mockResolvedValue(skillContent) + + await skillsManager.discoverSkills() + + const commands = skillsManager.getSkillsAsCommands("code") + + expect(commands).toHaveLength(1) + expect(commands[0].name).toBe("my-skill") + expect(commands[0].skillName).toBe("my-skill") + expect(commands[0].description).toBe("A test skill") + expect(commands[0].source).toBe("global") + }) + + it("should filter by mode when getting skills as commands", async () => { + const skillDir = p(globalSkillsCodeDir, "code-skill") + const skillMdPath = p(skillDir, "SKILL.md") + + mockDirectoryExists.mockImplementation(async (p: string) => { + if (p === globalSkillsCodeDir) return true + return false + }) + mockFileExists.mockImplementation(async (p: string) => p === skillMdPath) + mockRealpath.mockImplementation(async (p: string) => p) + mockReaddir.mockResolvedValue(["code-skill"]) + mockStat.mockResolvedValue({ isDirectory: () => true }) + + const skillContent = `--- +name: code-skill +description: A code-specific skill +--- + +# Code Skill` + + mockReadFile.mockResolvedValue(skillContent) + + await skillsManager.discoverSkills() + + // Should appear for code mode + const codeCommands = skillsManager.getSkillsAsCommands("code") + expect(codeCommands).toHaveLength(1) + + // Should not appear for architect mode + const architectCommands = skillsManager.getSkillsAsCommands("architect") + expect(architectCommands).toHaveLength(0) + }) + }) + describe("dispose", () => { it("should clean up resources", async () => { await skillsManager.dispose() diff --git a/src/services/skills/__tests__/generate-built-in-skills.spec.ts b/src/services/skills/__tests__/generate-built-in-skills.spec.ts index 10b44b8716..d32bbfc210 100644 --- a/src/services/skills/__tests__/generate-built-in-skills.spec.ts +++ b/src/services/skills/__tests__/generate-built-in-skills.spec.ts @@ -172,4 +172,16 @@ describe("built-in skills integration", () => { const content = getBuiltInSkillContent("non-existent-skill") expect(content).toBeNull() }) + + it("should have accessible skill content for all built-in skills", async () => { + const { getBuiltInSkills, getBuiltInSkillContent } = await import("../built-in-skills") + + const skills = getBuiltInSkills() + + // All built-in skills should have accessible content + for (const skill of skills) { + const content = getBuiltInSkillContent(skill.name) + expect(content).not.toBeNull() + } + }) }) diff --git a/src/services/skills/built-in-skills.ts b/src/services/skills/built-in-skills.ts index a47092b38b..eacbca0143 100644 --- a/src/services/skills/built-in-skills.ts +++ b/src/services/skills/built-in-skills.ts @@ -5,7 +5,7 @@ * in the built-in/ directory. To modify built-in skills, edit the corresponding * SKILL.md file and run: pnpm generate:skills * - * Generated at: 2026-01-28T23:09:14.137Z + * Generated at: 2026-01-30T00:07:07.768Z */ import { SkillMetadata, SkillContent } from "../../shared/skills" @@ -32,6 +32,7 @@ Unless the user specifies otherwise, new local MCP servers should be created in MCP servers can be configured in two ways in the MCP settings file: 1. Local (Stdio) Server Configuration: + \`\`\`json { "mcpServers": { @@ -47,6 +48,7 @@ MCP servers can be configured in two ways in the MCP settings file: \`\`\` 2. Remote (SSE) Server Configuration: + \`\`\`json { "mcpServers": { @@ -61,6 +63,7 @@ MCP servers can be configured in two ways in the MCP settings file: \`\`\` Common configuration options for both types: + - \`disabled\`: (optional) Set to true to temporarily disable the server - \`timeout\`: (optional) Maximum time in seconds to wait for server responses (default: 60) - \`alwaysAllow\`: (optional) Array of tool names that don't require user confirmation @@ -105,178 +108,170 @@ weather-server/ \`\`\`typescript #!/usr/bin/env node -import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { z } from "zod"; -import axios from 'axios'; +import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js" +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" +import { z } from "zod" +import axios from "axios" -const API_KEY = process.env.OPENWEATHER_API_KEY; // provided by MCP config +const API_KEY = process.env.OPENWEATHER_API_KEY // provided by MCP config if (!API_KEY) { - throw new Error('OPENWEATHER_API_KEY environment variable is required'); + throw new Error("OPENWEATHER_API_KEY environment variable is required") } // Define types for OpenWeather API responses interface WeatherData { - main: { - temp: number; - humidity: number; - }; - weather: Array<{ - description: string; - }>; - wind: { - speed: number; - }; + main: { + temp: number + humidity: number + } + weather: Array<{ + description: string + }> + wind: { + speed: number + } } interface ForecastData { - list: Array; + list: Array< + WeatherData & { + dt_txt: string + } + > } // Create an MCP server const server = new McpServer({ - name: "weather-server", - version: "0.1.0" -}); + name: "weather-server", + version: "0.1.0", +}) // Create axios instance for OpenWeather API const weatherApi = axios.create({ - baseURL: 'http://api.openweathermap.org/data/2.5', - params: { - appid: API_KEY, - units: 'metric', - }, -}); + baseURL: "http://api.openweathermap.org/data/2.5", + params: { + appid: API_KEY, + units: "metric", + }, +}) // Add a tool for getting weather forecasts server.tool( - "get_forecast", - { - city: z.string().describe("City name"), - days: z.number().min(1).max(5).optional().describe("Number of days (1-5)"), - }, - async ({ city, days = 3 }) => { - try { - const response = await weatherApi.get('forecast', { - params: { - q: city, - cnt: Math.min(days, 5) * 8, - }, - }); + "get_forecast", + { + city: z.string().describe("City name"), + days: z.number().min(1).max(5).optional().describe("Number of days (1-5)"), + }, + async ({ city, days = 3 }) => { + try { + const response = await weatherApi.get("forecast", { + params: { + q: city, + cnt: Math.min(days, 5) * 8, + }, + }) - return { - content: [ - { - type: "text", - text: JSON.stringify(response.data.list, null, 2), - }, - ], - }; - } catch (error) { - if (axios.isAxiosError(error)) { - return { - content: [ - { - type: "text", - text: \`Weather API error: \${ - error.response?.data.message ?? error.message - }\`, - }, - ], - isError: true, - }; - } - throw error; - } - } -); + return { + content: [ + { + type: "text", + text: JSON.stringify(response.data.list, null, 2), + }, + ], + } + } catch (error) { + if (axios.isAxiosError(error)) { + return { + content: [ + { + type: "text", + text: \`Weather API error: \${error.response?.data.message ?? error.message}\`, + }, + ], + isError: true, + } + } + throw error + } + }, +) // Add a resource for current weather in San Francisco -server.resource( - "sf_weather", - { uri: "weather://San Francisco/current", list: true }, - async (uri) => { - try { - const response = weatherApi.get('weather', { - params: { q: "San Francisco" }, - }); +server.resource("sf_weather", { uri: "weather://San Francisco/current", list: true }, async (uri) => { + try { + const response = weatherApi.get("weather", { + params: { q: "San Francisco" }, + }) - return { - contents: [ - { - uri: uri.href, - mimeType: "application/json", - text: JSON.stringify( - { - temperature: response.data.main.temp, - conditions: response.data.weather[0].description, - humidity: response.data.main.humidity, - wind_speed: response.data.wind.speed, - timestamp: new Date().toISOString(), - }, - null, - 2 - ), - }, - ], - }; - } catch (error) { - if (axios.isAxiosError(error)) { - throw new Error(\`Weather API error: \${ - error.response?.data.message ?? error.message - }\`); - } - throw error; - } - } -); + return { + contents: [ + { + uri: uri.href, + mimeType: "application/json", + text: JSON.stringify( + { + temperature: response.data.main.temp, + conditions: response.data.weather[0].description, + humidity: response.data.main.humidity, + wind_speed: response.data.wind.speed, + timestamp: new Date().toISOString(), + }, + null, + 2, + ), + }, + ], + } + } catch (error) { + if (axios.isAxiosError(error)) { + throw new Error(\`Weather API error: \${error.response?.data.message ?? error.message}\`) + } + throw error + } +}) // Add a dynamic resource template for current weather by city server.resource( - "current_weather", - new ResourceTemplate("weather://{city}/current", { list: true }), - async (uri, { city }) => { - try { - const response = await weatherApi.get('weather', { - params: { q: city }, - }); + "current_weather", + new ResourceTemplate("weather://{city}/current", { list: true }), + async (uri, { city }) => { + try { + const response = await weatherApi.get("weather", { + params: { q: city }, + }) - return { - contents: [ - { - uri: uri.href, - mimeType: "application/json", - text: JSON.stringify( - { - temperature: response.data.main.temp, - conditions: response.data.weather[0].description, - humidity: response.data.main.humidity, - wind_speed: response.data.wind.speed, - timestamp: new Date().toISOString(), - }, - null, - 2 - ), - }, - ], - }; - } catch (error) { - if (axios.isAxiosError(error)) { - throw new Error(\`Weather API error: \${ - error.response?.data.message ?? error.message - }\`); - } - throw error; - } - } -); + return { + contents: [ + { + uri: uri.href, + mimeType: "application/json", + text: JSON.stringify( + { + temperature: response.data.main.temp, + conditions: response.data.weather[0].description, + humidity: response.data.main.humidity, + wind_speed: response.data.wind.speed, + timestamp: new Date().toISOString(), + }, + null, + 2, + ), + }, + ], + } + } catch (error) { + if (axios.isAxiosError(error)) { + throw new Error(\`Weather API error: \${error.response?.data.message ?? error.message}\`) + } + throw error + } + }, +) // Start receiving messages on stdin and sending messages on stdout -const transport = new StdioServerTransport(); -await server.connect(transport); -console.error('Weather MCP server running on stdio'); +const transport = new StdioServerTransport() +await server.connect(transport) +console.error("Weather MCP server running on stdio") \`\`\` (Remember: This is just an example–you may use different dependencies, break the implementation up into multiple files, etc.)