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
This commit is contained in:
Hannes Rudolph 2026-01-30 09:16:27 -07:00
parent 67e568f6bb
commit 4d097e161d
6 changed files with 362 additions and 161 deletions

View file

@ -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)

View file

@ -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: [] })

View file

@ -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<SkillContent | null> {
// If mode is provided, try to find the best matching skill
let skill: SkillMetadata | undefined

View file

@ -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()

View file

@ -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()
}
})
})

View file

@ -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<WeatherData & {
dt_txt: string;
}>;
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<ForecastData>('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<ForecastData>("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<WeatherData>('weather', {
params: { q: "San Francisco" },
});
server.resource("sf_weather", { uri: "weather://San Francisco/current", list: true }, async (uri) => {
try {
const response = weatherApi.get<WeatherData>("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 exampleyou may use different dependencies, break the implementation up into multiple files, etc.)