Skip to content

Commit af1e12c

Browse files
feat: expose skills as slash commands with skill fallback execution (#11834)
Co-authored-by: Roo Code <roomote@roocode.com>
1 parent ce73d05 commit af1e12c

File tree

12 files changed

+687
-43
lines changed

12 files changed

+687
-43
lines changed

src/__tests__/command-mentions.spec.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,76 @@ describe("Command Mentions", () => {
102102
expect(result.text).not.toContain("Command 'nonexistent' not found")
103103
})
104104

105+
it("should load skill content when command is missing and mode skill exists", async () => {
106+
mockGetCommand.mockResolvedValue(undefined)
107+
108+
const skillsManager = {
109+
getSkillContent: vi.fn().mockResolvedValue({
110+
name: "skill-only",
111+
description: "Skill-generated command",
112+
path: "/mock/.roo/skills/skill-only/SKILL.md",
113+
source: "project" as const,
114+
instructions: "Use skill workflow",
115+
}),
116+
}
117+
118+
const result = await parseMentions(
119+
"/skill-only run",
120+
"/test/cwd",
121+
undefined,
122+
undefined,
123+
false,
124+
true,
125+
50,
126+
skillsManager,
127+
"code",
128+
)
129+
130+
expect(mockGetCommand).toHaveBeenCalledWith("/test/cwd", "skill-only")
131+
expect(skillsManager.getSkillContent).toHaveBeenCalledWith("skill-only", "code")
132+
expect(result.text).toContain("Command 'skill-only' (see below for command content)")
133+
expect(result.slashCommandHelp).toContain("Skill: skill-only")
134+
expect(result.slashCommandHelp).toContain("Description: Skill-generated command")
135+
expect(result.slashCommandHelp).toContain("Source: project")
136+
expect(result.slashCommandHelp).toContain("--- Skill Instructions ---")
137+
expect(result.slashCommandHelp).toContain("Use skill workflow")
138+
})
139+
140+
it("should preserve command precedence over skill fallback", async () => {
141+
mockGetCommand.mockResolvedValue({
142+
name: "setup",
143+
content: "# Command wins",
144+
source: "project",
145+
filePath: "/project/.roo/commands/setup.md",
146+
})
147+
148+
const skillsManager = {
149+
getSkillContent: vi.fn().mockResolvedValue({
150+
name: "setup",
151+
description: "Setup skill",
152+
path: "/mock/.roo/skills/setup/SKILL.md",
153+
source: "project" as const,
154+
instructions: "Skill should not be used",
155+
}),
156+
}
157+
158+
const result = await parseMentions(
159+
"/setup now",
160+
"/test/cwd",
161+
undefined,
162+
undefined,
163+
false,
164+
true,
165+
50,
166+
skillsManager,
167+
"code",
168+
)
169+
170+
expect(skillsManager.getSkillContent).not.toHaveBeenCalled()
171+
expect(result.slashCommandHelp).toContain('<command name="setup">')
172+
expect(result.slashCommandHelp).not.toContain("Skill: setup")
173+
})
174+
105175
it("should handle command loading errors during existence check", async () => {
106176
mockGetCommand.mockReset()
107177
mockGetCommand.mockRejectedValue(new Error("Failed to load command"))

src/core/mentions/__tests__/processUserContentMentions.spec.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,8 @@ describe("processUserContentMentions", () => {
194194
false, // showRooIgnoredFiles should default to false
195195
true, // includeDiagnosticMessages
196196
50, // maxDiagnosticMessages
197+
undefined,
198+
"code",
197199
)
198200
})
199201

@@ -220,6 +222,8 @@ describe("processUserContentMentions", () => {
220222
false,
221223
true, // includeDiagnosticMessages
222224
50, // maxDiagnosticMessages
225+
undefined,
226+
"code",
223227
)
224228
})
225229
})

src/core/mentions/index.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import { FileContextTracker } from "../context-tracking/FileContextTracker"
1717

1818
import { RooIgnoreController } from "../ignore/RooIgnoreController"
1919
import { getCommand, type Command } from "../../services/command/commands"
20+
import { buildSkillResult, resolveSkillContentForMode, type SkillLookup } from "../../services/skills/skillInvocation"
21+
import type { SkillContent } from "../../shared/skills"
2022

2123
export async function openMention(cwd: string, mention?: string): Promise<void> {
2224
if (!mention) {
@@ -102,9 +104,12 @@ export async function parseMentions(
102104
showRooIgnoredFiles: boolean = false,
103105
includeDiagnosticMessages: boolean = true,
104106
maxDiagnosticMessages: number = 50,
107+
skillsManager?: SkillLookup,
108+
currentMode: string = "code",
105109
): Promise<ParseMentionsResult> {
106110
const mentions: Set<string> = new Set()
107111
const validCommands: Map<string, Command> = new Map()
112+
const validSkills: Map<string, SkillContent> = new Map()
108113
const contentBlocks: MentionContentBlock[] = []
109114
let commandMode: string | undefined // Track mode from the first slash command that has one
110115

@@ -116,29 +121,39 @@ export async function parseMentions(
116121
Array.from(uniqueCommandNames).map(async (commandName) => {
117122
try {
118123
const command = await getCommand(cwd, commandName)
119-
return { commandName, command }
124+
if (command) {
125+
return { commandName, command, skillContent: null }
126+
}
127+
128+
const skillContent = await resolveSkillContentForMode(skillsManager, commandName, currentMode)
129+
return { commandName, command: undefined, skillContent }
120130
} catch (error) {
121131
// If there's an error checking command existence, treat it as non-existent
122-
return { commandName, command: undefined }
132+
return { commandName, command: undefined, skillContent: null }
123133
}
124134
}),
125135
)
126136

127137
// Store valid commands for later use and capture the first mode found
128-
for (const { commandName, command } of commandExistenceChecks) {
138+
for (const { commandName, command, skillContent } of commandExistenceChecks) {
129139
if (command) {
130140
validCommands.set(commandName, command)
131141
// Capture the mode from the first command that has one
132142
if (!commandMode && command.mode) {
133143
commandMode = command.mode
134144
}
145+
continue
146+
}
147+
148+
if (skillContent) {
149+
validSkills.set(commandName, skillContent)
135150
}
136151
}
137152

138153
// Only replace text for commands that actually exist (keep "see below" for commands)
139154
let parsedText = text
140155
for (const [match, commandName] of commandMatches) {
141-
if (validCommands.has(commandName)) {
156+
if (validCommands.has(commandName) || validSkills.has(commandName)) {
142157
parsedText = parsedText.replace(match, `Command '${commandName}' (see below for command content)`)
143158
}
144159
}
@@ -231,6 +246,10 @@ export async function parseMentions(
231246
}
232247
}
233248

249+
for (const [skillName, skillContent] of validSkills) {
250+
slashCommandHelp += `\n\n${buildSkillResult(skillName, undefined, skillContent)}`
251+
}
252+
234253
return {
235254
text: parsedText,
236255
contentBlocks,

src/core/mentions/processUserContentMentions.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import Anthropic from "@anthropic-ai/sdk"
22

33
import { parseMentions, ParseMentionsResult, MentionContentBlock } from "./index"
44
import { FileContextTracker } from "../context-tracking/FileContextTracker"
5+
import type { SkillLookup } from "../../services/skills/skillInvocation"
56

67
// Internal aliases for the Anthropic content block subtypes used during processing.
78
type TextPart = Anthropic.Messages.TextBlockParam
@@ -40,6 +41,8 @@ export async function processUserContentMentions({
4041
showRooIgnoredFiles = false,
4142
includeDiagnosticMessages = true,
4243
maxDiagnosticMessages = 50,
44+
skillsManager,
45+
currentMode = "code",
4346
}: {
4447
userContent: Anthropic.Messages.ContentBlockParam[]
4548
cwd: string
@@ -48,6 +51,8 @@ export async function processUserContentMentions({
4851
showRooIgnoredFiles?: boolean
4952
includeDiagnosticMessages?: boolean
5053
maxDiagnosticMessages?: number
54+
skillsManager?: SkillLookup
55+
currentMode?: string
5156
}): Promise<ProcessUserContentMentionsResult> {
5257
// Track the first mode found from slash commands
5358
let commandMode: string | undefined
@@ -69,6 +74,8 @@ export async function processUserContentMentions({
6974
showRooIgnoredFiles,
7075
includeDiagnosticMessages,
7176
maxDiagnosticMessages,
77+
skillsManager,
78+
currentMode,
7279
)
7380
// Capture the first mode found
7481
if (!commandMode && result.mode) {
@@ -112,6 +119,8 @@ export async function processUserContentMentions({
112119
showRooIgnoredFiles,
113120
includeDiagnosticMessages,
114121
maxDiagnosticMessages,
122+
skillsManager,
123+
currentMode,
115124
)
116125
// Capture the first mode found
117126
if (!commandMode && result.mode) {
@@ -161,6 +170,8 @@ export async function processUserContentMentions({
161170
showRooIgnoredFiles,
162171
includeDiagnosticMessages,
163172
maxDiagnosticMessages,
173+
skillsManager,
174+
currentMode,
164175
)
165176
// Capture the first mode found
166177
if (!commandMode && result.mode) {

src/core/task/Task.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2592,11 +2592,13 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
25922592
}),
25932593
)
25942594

2595-
const {
2596-
showRooIgnoredFiles = false,
2597-
includeDiagnosticMessages = true,
2598-
maxDiagnosticMessages = 50,
2599-
} = (await this.providerRef.deref()?.getState()) ?? {}
2595+
const provider = this.providerRef.deref()
2596+
const state = provider ? await provider.getState() : undefined
2597+
2598+
const showRooIgnoredFiles = state?.showRooIgnoredFiles ?? false
2599+
const includeDiagnosticMessages = state?.includeDiagnosticMessages ?? true
2600+
const maxDiagnosticMessages = state?.maxDiagnosticMessages ?? 50
2601+
const currentMode = state?.mode ?? defaultModeSlug
26002602

26012603
const { content: parsedUserContent, mode: slashCommandMode } = await processUserContentMentions({
26022604
userContent: currentUserContent,
@@ -2606,6 +2608,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
26062608
showRooIgnoredFiles,
26072609
includeDiagnosticMessages,
26082610
maxDiagnosticMessages,
2611+
skillsManager: provider?.getSkillsManager(),
2612+
currentMode,
26092613
})
26102614

26112615
// Switch mode if specified in a slash command's frontmatter

src/core/tools/RunSlashCommandTool.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ import { EXPERIMENT_IDS, experiments } from "../../shared/experiments"
55
import { BaseTool, ToolCallbacks } from "./BaseTool"
66
import type { ToolUse } from "../../shared/tools"
77
import { getModeBySlug } from "../../shared/modes"
8+
import {
9+
buildSkillApprovalMessage,
10+
buildSkillResult,
11+
resolveSkillContentForMode,
12+
} from "../../services/skills/skillInvocation"
813

914
interface RunSlashCommandParams {
1015
command: string
@@ -50,6 +55,22 @@ export class RunSlashCommandTool extends BaseTool<"run_slash_command"> {
5055
const command = await getCommand(task.cwd, commandName)
5156

5257
if (!command) {
58+
const currentMode = state?.mode ?? "code"
59+
const skillsManager = provider?.getSkillsManager()
60+
const skillContent = await resolveSkillContentForMode(skillsManager, commandName, currentMode)
61+
62+
if (skillContent) {
63+
const skillMessage = buildSkillApprovalMessage(commandName, args, skillContent)
64+
const didApprove = await askApproval("tool", skillMessage)
65+
66+
if (!didApprove) {
67+
return
68+
}
69+
70+
pushToolResult(buildSkillResult(commandName, args, skillContent))
71+
return
72+
}
73+
5374
// Get available commands for error message
5475
const availableCommands = await getCommandNames(task.cwd)
5576
task.recordToolError("run_slash_command")

src/core/tools/SkillTool.ts

Lines changed: 8 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import { Task } from "../task/Task"
22
import { formatResponse } from "../prompts/responses"
33
import { BaseTool, ToolCallbacks } from "./BaseTool"
44
import type { ToolUse } from "../../shared/tools"
5+
import {
6+
buildSkillApprovalMessage,
7+
buildSkillResult,
8+
resolveSkillContentForMode,
9+
} from "../../services/skills/skillInvocation"
510

611
interface SkillParams {
712
skill: string
@@ -43,7 +48,7 @@ export class SkillTool extends BaseTool<"skill"> {
4348
const currentMode = state?.mode ?? "code"
4449

4550
// Fetch skill content
46-
const skillContent = await skillsManager.getSkillContent(skillName, currentMode)
51+
const skillContent = await resolveSkillContentForMode(skillsManager, skillName, currentMode)
4752

4853
if (!skillContent) {
4954
// Get available skills for error message
@@ -61,35 +66,15 @@ export class SkillTool extends BaseTool<"skill"> {
6166
}
6267

6368
// Build approval message
64-
const toolMessage = JSON.stringify({
65-
tool: "skill",
66-
skill: skillName,
67-
args: args,
68-
source: skillContent.source,
69-
description: skillContent.description,
70-
})
69+
const toolMessage = buildSkillApprovalMessage(skillName, args, skillContent)
7170

7271
const didApprove = await askApproval("tool", toolMessage)
7372

7473
if (!didApprove) {
7574
return
7675
}
7776

78-
// Build the result message
79-
let result = `Skill: ${skillName}`
80-
81-
if (skillContent.description) {
82-
result += `\nDescription: ${skillContent.description}`
83-
}
84-
85-
if (args) {
86-
result += `\nProvided arguments: ${args}`
87-
}
88-
89-
result += `\nSource: ${skillContent.source}`
90-
result += `\n\n--- Skill Instructions ---\n\n${skillContent.instructions}`
91-
92-
pushToolResult(result)
77+
pushToolResult(buildSkillResult(skillName, args, skillContent))
9378
} catch (error) {
9479
await handleError("executing skill", error as Error)
9580
}

0 commit comments

Comments
 (0)