diff --git a/.roo/commands/release.md b/.roo/commands/release.md index 707844cdc4e..2e09783a58e 100644 --- a/.roo/commands/release.md +++ b/.roo/commands/release.md @@ -1,6 +1,7 @@ --- description: "Create a new release of the Roo Code extension" argument-hint: patch | minor | major +mode: code --- 1. Identify the SHA corresponding to the most recent release using GitHub CLI: `gh release view --json tagName,targetCommitish,publishedAt` diff --git a/src/__tests__/command-mentions.spec.ts b/src/__tests__/command-mentions.spec.ts index 7ddaf3d0928..d309045dc90 100644 --- a/src/__tests__/command-mentions.spec.ts +++ b/src/__tests__/command-mentions.spec.ts @@ -27,7 +27,7 @@ describe("Command Mentions", () => { // Helper function to call parseMentions with required parameters const callParseMentions = async (text: string) => { - return await parseMentions( + const result = await parseMentions( text, "/test/cwd", // cwd mockUrlContentFetcher, // urlContentFetcher @@ -38,6 +38,8 @@ describe("Command Mentions", () => { 50, // maxDiagnosticMessages undefined, // maxReadFileLine ) + // Return just the text for backward compatibility with existing tests + return result.text } describe("parseMentions with command support", () => { diff --git a/src/core/mentions/__tests__/index.spec.ts b/src/core/mentions/__tests__/index.spec.ts index 2cb24b4502e..8f229c28b87 100644 --- a/src/core/mentions/__tests__/index.spec.ts +++ b/src/core/mentions/__tests__/index.spec.ts @@ -40,7 +40,7 @@ describe("parseMentions - URL error handling", () => { expect(consoleErrorSpy).toHaveBeenCalledWith("Error fetching URL https://example.com:", timeoutError) expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.url_fetch_error_with_url") - expect(result).toContain("Error fetching content: Navigation timeout of 30000 ms exceeded") + expect(result.text).toContain("Error fetching content: Navigation timeout of 30000 ms exceeded") }) it("should handle DNS resolution errors", async () => { @@ -50,7 +50,7 @@ describe("parseMentions - URL error handling", () => { const result = await parseMentions("Check @https://nonexistent.example", "/test", mockUrlContentFetcher) expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.url_fetch_error_with_url") - expect(result).toContain("Error fetching content: net::ERR_NAME_NOT_RESOLVED") + expect(result.text).toContain("Error fetching content: net::ERR_NAME_NOT_RESOLVED") }) it("should handle network disconnection errors", async () => { @@ -60,7 +60,7 @@ describe("parseMentions - URL error handling", () => { const result = await parseMentions("Check @https://example.com", "/test", mockUrlContentFetcher) expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.url_fetch_error_with_url") - expect(result).toContain("Error fetching content: net::ERR_INTERNET_DISCONNECTED") + expect(result.text).toContain("Error fetching content: net::ERR_INTERNET_DISCONNECTED") }) it("should handle 403 Forbidden errors", async () => { @@ -70,7 +70,7 @@ describe("parseMentions - URL error handling", () => { const result = await parseMentions("Check @https://example.com", "/test", mockUrlContentFetcher) expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.url_fetch_error_with_url") - expect(result).toContain("Error fetching content: 403 Forbidden") + expect(result.text).toContain("Error fetching content: 403 Forbidden") }) it("should handle 404 Not Found errors", async () => { @@ -80,7 +80,7 @@ describe("parseMentions - URL error handling", () => { const result = await parseMentions("Check @https://example.com/missing", "/test", mockUrlContentFetcher) expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.url_fetch_error_with_url") - expect(result).toContain("Error fetching content: 404 Not Found") + expect(result.text).toContain("Error fetching content: 404 Not Found") }) it("should handle generic errors with fallback message", async () => { @@ -90,7 +90,7 @@ describe("parseMentions - URL error handling", () => { const result = await parseMentions("Check @https://example.com", "/test", mockUrlContentFetcher) expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.url_fetch_error_with_url") - expect(result).toContain("Error fetching content: Some unexpected error") + expect(result.text).toContain("Error fetching content: Some unexpected error") }) it("should handle non-Error objects thrown", async () => { @@ -100,7 +100,7 @@ describe("parseMentions - URL error handling", () => { const result = await parseMentions("Check @https://example.com", "/test", mockUrlContentFetcher) expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.url_fetch_error_with_url") - expect(result).toContain("Error fetching content:") + expect(result.text).toContain("Error fetching content:") }) it("should handle browser launch errors correctly", async () => { @@ -112,7 +112,7 @@ describe("parseMentions - URL error handling", () => { expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( "Error fetching content for https://example.com: Failed to launch browser", ) - expect(result).toContain("Error fetching content: Failed to launch browser") + expect(result.text).toContain("Error fetching content: Failed to launch browser") // Should not attempt to fetch URL if browser launch failed expect(mockUrlContentFetcher.urlToMarkdown).not.toHaveBeenCalled() }) @@ -126,7 +126,7 @@ describe("parseMentions - URL error handling", () => { expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( "Error fetching content for https://example.com: String error", ) - expect(result).toContain("Error fetching content: String error") + expect(result.text).toContain("Error fetching content: String error") }) it("should successfully fetch URL content when no errors occur", async () => { @@ -135,9 +135,9 @@ describe("parseMentions - URL error handling", () => { const result = await parseMentions("Check @https://example.com", "/test", mockUrlContentFetcher) expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() - expect(result).toContain('') - expect(result).toContain("# Example Content\n\nThis is the content.") - expect(result).toContain("") + expect(result.text).toContain('') + expect(result.text).toContain("# Example Content\n\nThis is the content.") + expect(result.text).toContain("") }) it("should handle multiple URLs with mixed success and failure", async () => { @@ -151,9 +151,9 @@ describe("parseMentions - URL error handling", () => { mockUrlContentFetcher, ) - expect(result).toContain('') - expect(result).toContain("# First Site") - expect(result).toContain('') - expect(result).toContain("Error fetching content: timeout") + expect(result.text).toContain('') + expect(result.text).toContain("# First Site") + expect(result.text).toContain('') + expect(result.text).toContain("Error fetching content: timeout") }) }) diff --git a/src/core/mentions/__tests__/processUserContentMentions.spec.ts b/src/core/mentions/__tests__/processUserContentMentions.spec.ts index 13c225042d7..ec2e08f92ae 100644 --- a/src/core/mentions/__tests__/processUserContentMentions.spec.ts +++ b/src/core/mentions/__tests__/processUserContentMentions.spec.ts @@ -22,8 +22,11 @@ describe("processUserContentMentions", () => { mockFileContextTracker = {} as FileContextTracker mockRooIgnoreController = {} - // Default mock implementation - vi.mocked(parseMentions).mockImplementation(async (text) => `parsed: ${text}`) + // Default mock implementation - returns ParseMentionsResult object + vi.mocked(parseMentions).mockImplementation(async (text) => ({ + text: `parsed: ${text}`, + mode: undefined, + })) }) describe("maxReadFileLine parameter", () => { @@ -134,10 +137,11 @@ describe("processUserContentMentions", () => { }) expect(parseMentions).toHaveBeenCalled() - expect(result[0]).toEqual({ + expect(result.content[0]).toEqual({ type: "text", text: "parsed: Do something", }) + expect(result.mode).toBeUndefined() }) it("should process text blocks with tags", async () => { @@ -156,10 +160,11 @@ describe("processUserContentMentions", () => { }) expect(parseMentions).toHaveBeenCalled() - expect(result[0]).toEqual({ + expect(result.content[0]).toEqual({ type: "text", text: "parsed: Fix this issue", }) + expect(result.mode).toBeUndefined() }) it("should not process text blocks without task or feedback tags", async () => { @@ -178,7 +183,8 @@ describe("processUserContentMentions", () => { }) expect(parseMentions).not.toHaveBeenCalled() - expect(result[0]).toEqual(userContent[0]) + expect(result.content[0]).toEqual(userContent[0]) + expect(result.mode).toBeUndefined() }) it("should process tool_result blocks with string content", async () => { @@ -198,11 +204,12 @@ describe("processUserContentMentions", () => { }) expect(parseMentions).toHaveBeenCalled() - expect(result[0]).toEqual({ + expect(result.content[0]).toEqual({ type: "tool_result", tool_use_id: "123", content: "parsed: Tool feedback", }) + expect(result.mode).toBeUndefined() }) it("should process tool_result blocks with array content", async () => { @@ -231,7 +238,7 @@ describe("processUserContentMentions", () => { }) expect(parseMentions).toHaveBeenCalledTimes(1) - expect(result[0]).toEqual({ + expect(result.content[0]).toEqual({ type: "tool_result", tool_use_id: "123", content: [ @@ -245,6 +252,7 @@ describe("processUserContentMentions", () => { }, ], }) + expect(result.mode).toBeUndefined() }) it("should handle mixed content types", async () => { @@ -277,17 +285,18 @@ describe("processUserContentMentions", () => { }) expect(parseMentions).toHaveBeenCalledTimes(2) - expect(result).toHaveLength(3) - expect(result[0]).toEqual({ + expect(result.content).toHaveLength(3) + expect(result.content[0]).toEqual({ type: "text", text: "parsed: First task", }) - expect(result[1]).toEqual(userContent[1]) // Image block unchanged - expect(result[2]).toEqual({ + expect(result.content[1]).toEqual(userContent[1]) // Image block unchanged + expect(result.content[2]).toEqual({ type: "tool_result", tool_use_id: "456", content: "parsed: Feedback", }) + expect(result.mode).toBeUndefined() }) }) diff --git a/src/core/mentions/index.ts b/src/core/mentions/index.ts index f038b5b7836..9ee7cece5d0 100644 --- a/src/core/mentions/index.ts +++ b/src/core/mentions/index.ts @@ -71,6 +71,11 @@ export async function openMention(cwd: string, mention?: string): Promise } } +export interface ParseMentionsResult { + text: string + mode?: string // Mode from the first slash command that has one +} + export async function parseMentions( text: string, cwd: string, @@ -81,9 +86,10 @@ export async function parseMentions( includeDiagnosticMessages: boolean = true, maxDiagnosticMessages: number = 50, maxReadFileLine?: number, -): Promise { +): Promise { const mentions: Set = new Set() const validCommands: Map = new Map() + let commandMode: string | undefined // Track mode from the first slash command that has one // First pass: check which command mentions exist and cache the results const commandMatches = Array.from(text.matchAll(commandRegexGlobal)) @@ -101,10 +107,14 @@ export async function parseMentions( }), ) - // Store valid commands for later use + // Store valid commands for later use and capture the first mode found for (const { commandName, command } of commandExistenceChecks) { if (command) { validCommands.set(commandName, command) + // Capture the mode from the first command that has one + if (!commandMode && command.mode) { + commandMode = command.mode + } } } @@ -257,7 +267,7 @@ export async function parseMentions( } } - return parsedText + return { text: parsedText, mode: commandMode } } async function getFileOrFolderContent( @@ -410,3 +420,4 @@ export async function getLatestTerminalOutput(): Promise { // Export processUserContentMentions from its own file export { processUserContentMentions } from "./processUserContentMentions" +export type { ProcessUserContentMentionsResult } from "./processUserContentMentions" diff --git a/src/core/mentions/processUserContentMentions.ts b/src/core/mentions/processUserContentMentions.ts index 4bdb422d48b..5ea78f4dc30 100644 --- a/src/core/mentions/processUserContentMentions.ts +++ b/src/core/mentions/processUserContentMentions.ts @@ -1,8 +1,13 @@ import { Anthropic } from "@anthropic-ai/sdk" -import { parseMentions } from "./index" +import { parseMentions, ParseMentionsResult } from "./index" import { UrlContentFetcher } from "../../services/browser/UrlContentFetcher" import { FileContextTracker } from "../context-tracking/FileContextTracker" +export interface ProcessUserContentMentionsResult { + content: Anthropic.Messages.ContentBlockParam[] + mode?: string // Mode from the first slash command that has one +} + /** * Process mentions in user content, specifically within task and feedback tags */ @@ -26,7 +31,10 @@ export async function processUserContentMentions({ includeDiagnosticMessages?: boolean maxDiagnosticMessages?: number maxReadFileLine?: number -}) { +}): Promise { + // Track the first mode found from slash commands + let commandMode: string | undefined + // Process userContent array, which contains various block types: // TextBlockParam, ImageBlockParam, ToolUseBlockParam, and ToolResultBlockParam. // We need to apply parseMentions() to: @@ -37,7 +45,7 @@ export async function processUserContentMentions({ // (see askFollowupQuestion), we place all user generated content in // these tags so they can effectively be used as markers for when we // should parse mentions). - return Promise.all( + const content = await Promise.all( userContent.map(async (block) => { const shouldProcessMentions = (text: string) => text.includes("") || @@ -47,10 +55,33 @@ export async function processUserContentMentions({ if (block.type === "text") { if (shouldProcessMentions(block.text)) { + const result = await parseMentions( + block.text, + cwd, + urlContentFetcher, + fileContextTracker, + rooIgnoreController, + showRooIgnoredFiles, + includeDiagnosticMessages, + maxDiagnosticMessages, + maxReadFileLine, + ) + // Capture the first mode found + if (!commandMode && result.mode) { + commandMode = result.mode + } return { ...block, - text: await parseMentions( - block.text, + text: result.text, + } + } + + return block + } else if (block.type === "tool_result") { + if (typeof block.content === "string") { + if (shouldProcessMentions(block.content)) { + const result = await parseMentions( + block.content, cwd, urlContentFetcher, fileContextTracker, @@ -59,27 +90,14 @@ export async function processUserContentMentions({ includeDiagnosticMessages, maxDiagnosticMessages, maxReadFileLine, - ), - } - } - - return block - } else if (block.type === "tool_result") { - if (typeof block.content === "string") { - if (shouldProcessMentions(block.content)) { + ) + // Capture the first mode found + if (!commandMode && result.mode) { + commandMode = result.mode + } return { ...block, - content: await parseMentions( - block.content, - cwd, - urlContentFetcher, - fileContextTracker, - rooIgnoreController, - showRooIgnoredFiles, - includeDiagnosticMessages, - maxDiagnosticMessages, - maxReadFileLine, - ), + content: result.text, } } @@ -88,19 +106,24 @@ export async function processUserContentMentions({ const parsedContent = await Promise.all( block.content.map(async (contentBlock) => { if (contentBlock.type === "text" && shouldProcessMentions(contentBlock.text)) { + const result = await parseMentions( + contentBlock.text, + cwd, + urlContentFetcher, + fileContextTracker, + rooIgnoreController, + showRooIgnoredFiles, + includeDiagnosticMessages, + maxDiagnosticMessages, + maxReadFileLine, + ) + // Capture the first mode found + if (!commandMode && result.mode) { + commandMode = result.mode + } return { ...contentBlock, - text: await parseMentions( - contentBlock.text, - cwd, - urlContentFetcher, - fileContextTracker, - rooIgnoreController, - showRooIgnoredFiles, - includeDiagnosticMessages, - maxDiagnosticMessages, - maxReadFileLine, - ), + text: result.text, } } @@ -117,4 +140,6 @@ export async function processUserContentMentions({ return block }), ) + + return { content, mode: commandMode } } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index d721438589e..1bdbb55ba94 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -2357,7 +2357,7 @@ export class Task extends EventEmitter implements TaskLike { maxReadFileLine = -1, } = (await this.providerRef.deref()?.getState()) ?? {} - const parsedUserContent = await processUserContentMentions({ + const { content: parsedUserContent, mode: slashCommandMode } = await processUserContentMentions({ userContent: currentUserContent, cwd: this.cwd, urlContentFetcher: this.urlContentFetcher, @@ -2369,6 +2369,18 @@ export class Task extends EventEmitter implements TaskLike { maxReadFileLine, }) + // Switch mode if specified in a slash command's frontmatter + if (slashCommandMode) { + const provider = this.providerRef.deref() + if (provider) { + const state = await provider.getState() + const targetMode = getModeBySlug(slashCommandMode, state?.customModes) + if (targetMode) { + await provider.handleModeSwitch(slashCommandMode) + } + } + } + const environmentDetails = await getEnvironmentDetails(this, currentIncludeFileDetails) // Remove any existing environment_details blocks before adding fresh ones. diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 74ef1c6a78e..6ae4b1ecb7a 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -132,7 +132,7 @@ vi.mock("vscode", () => { vi.mock("../../mentions", () => ({ parseMentions: vi.fn().mockImplementation((text) => { - return Promise.resolve(`processed: ${text}`) + return Promise.resolve({ text: `processed: ${text}`, mode: undefined }) }), openMention: vi.fn(), getLatestTerminalOutput: vi.fn(), @@ -913,7 +913,7 @@ describe("Cline", () => { } as Anthropic.ToolResultBlockParam, ] - const processedContent = await processUserContentMentions({ + const { content: processedContent } = await processUserContentMentions({ userContent, cwd: cline.cwd, urlContentFetcher: cline.urlContentFetcher, diff --git a/src/core/tools/RunSlashCommandTool.ts b/src/core/tools/RunSlashCommandTool.ts index 8af8bd6e12a..69cb9dde95b 100644 --- a/src/core/tools/RunSlashCommandTool.ts +++ b/src/core/tools/RunSlashCommandTool.ts @@ -4,6 +4,7 @@ import { getCommand, getCommandNames } from "../../services/command/commands" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { BaseTool, ToolCallbacks } from "./BaseTool" import type { ToolUse } from "../../shared/tools" +import { getModeBySlug } from "../../shared/modes" interface RunSlashCommandParams { command: string @@ -74,6 +75,7 @@ export class RunSlashCommandTool extends BaseTool<"run_slash_command"> { args: args, source: command.source, description: command.description, + mode: command.mode, }) const didApprove = await askApproval("tool", toolMessage) @@ -82,6 +84,15 @@ export class RunSlashCommandTool extends BaseTool<"run_slash_command"> { return } + // 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 (targetMode) { + await provider?.handleModeSwitch(command.mode) + } + } + // Build the result message let result = `Command: /${commandName}` @@ -93,6 +104,10 @@ export class RunSlashCommandTool extends BaseTool<"run_slash_command"> { result += `\nArgument hint: ${command.argumentHint}` } + if (command.mode) { + result += `\nMode: ${command.mode}` + } + if (args) { result += `\nProvided arguments: ${args}` } diff --git a/src/core/tools/__tests__/runSlashCommandTool.spec.ts b/src/core/tools/__tests__/runSlashCommandTool.spec.ts index e3c8180e381..eef6259deb5 100644 --- a/src/core/tools/__tests__/runSlashCommandTool.spec.ts +++ b/src/core/tools/__tests__/runSlashCommandTool.spec.ts @@ -307,4 +307,133 @@ Deploy application to production`, expect(mockTask.consecutiveMistakeCount).toBe(0) }) + + it("should switch mode when mode is specified in command", async () => { + const mockHandleModeSwitch = vi.fn() + const block: ToolUse<"run_slash_command"> = { + type: "tool_use" as const, + name: "run_slash_command" as const, + params: { + command: "debug-app", + }, + partial: false, + } + + const mockCommand = { + name: "debug-app", + content: "Start debugging the application", + source: "project" as const, + filePath: ".roo/commands/debug-app.md", + description: "Debug the application", + mode: "debug", + } + + mockTask.providerRef.deref = vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + experiments: { + runSlashCommand: true, + }, + customModes: undefined, + }), + handleModeSwitch: mockHandleModeSwitch, + }) + + vi.mocked(getCommand).mockResolvedValue(mockCommand) + + await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks) + + expect(mockHandleModeSwitch).toHaveBeenCalledWith("debug") + expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith( + `Command: /debug-app +Description: Debug the application +Mode: debug +Source: project + +--- Command Content --- + +Start debugging the application`, + ) + }) + + it("should not switch mode when mode is not specified in command", async () => { + const mockHandleModeSwitch = vi.fn() + const block: ToolUse<"run_slash_command"> = { + type: "tool_use" as const, + name: "run_slash_command" as const, + params: { + command: "test", + }, + partial: false, + } + + const mockCommand = { + name: "test", + content: "Run tests", + source: "project" as const, + filePath: ".roo/commands/test.md", + description: "Run project tests", + } + + mockTask.providerRef.deref = vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + experiments: { + runSlashCommand: true, + }, + customModes: undefined, + }), + handleModeSwitch: mockHandleModeSwitch, + }) + + vi.mocked(getCommand).mockResolvedValue(mockCommand) + + await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks) + + expect(mockHandleModeSwitch).not.toHaveBeenCalled() + }) + + it("should include mode in askApproval message when mode is specified", async () => { + const block: ToolUse<"run_slash_command"> = { + type: "tool_use" as const, + name: "run_slash_command" as const, + params: { + command: "debug-app", + }, + partial: false, + } + + const mockCommand = { + name: "debug-app", + content: "Start debugging", + source: "project" as const, + filePath: ".roo/commands/debug-app.md", + description: "Debug the application", + mode: "debug", + } + + mockTask.providerRef.deref = vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + experiments: { + runSlashCommand: true, + }, + customModes: undefined, + }), + handleModeSwitch: vi.fn(), + }) + + vi.mocked(getCommand).mockResolvedValue(mockCommand) + + await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks) + + expect(mockCallbacks.askApproval).toHaveBeenCalledWith( + "tool", + JSON.stringify({ + tool: "runSlashCommand", + command: "debug-app", + args: undefined, + source: "project", + description: "Debug the application", + mode: "debug", + }), + ) + }) }) diff --git a/src/services/command/__tests__/frontmatter-commands.spec.ts b/src/services/command/__tests__/frontmatter-commands.spec.ts index 40acc8ae848..3f93b55f940 100644 --- a/src/services/command/__tests__/frontmatter-commands.spec.ts +++ b/src/services/command/__tests__/frontmatter-commands.spec.ts @@ -49,6 +49,7 @@ npm run build filePath: path.join("/test/cwd", ".roo", "commands", "setup.md"), description: "Sets up the development environment", argumentHint: undefined, + mode: undefined, }) }) @@ -73,6 +74,7 @@ npm run build filePath: path.join("/test/cwd", ".roo", "commands", "setup.md"), description: undefined, argumentHint: undefined, + mode: undefined, }) }) @@ -116,6 +118,7 @@ Command content here.` filePath: path.join("/test/cwd", ".roo", "commands", "setup.md"), description: undefined, argumentHint: undefined, + mode: undefined, }) }) @@ -151,6 +154,7 @@ Global setup instructions.` filePath: path.join("/test/cwd", ".roo", "commands", "setup.md"), description: "Project-specific setup", argumentHint: undefined, + mode: undefined, }) }) @@ -178,6 +182,7 @@ Global setup instructions.` filePath: expect.stringContaining(path.join(".roo", "commands", "setup.md")), description: "Global setup command", argumentHint: undefined, + mode: undefined, }) }) }) @@ -205,6 +210,7 @@ Create a new release.` filePath: path.join("/test/cwd", ".roo", "commands", "release.md"), description: "Create a new release of the Roo Code extension", argumentHint: "patch | minor | major", + mode: undefined, }) }) @@ -231,6 +237,7 @@ Deploy the application.` filePath: path.join("/test/cwd", ".roo", "commands", "deploy.md"), description: "Deploy application to environment", argumentHint: "staging | production", + mode: undefined, }) }) @@ -287,6 +294,77 @@ Test content.` expect(result?.argumentHint).toBeUndefined() }) + + it("should load command with mode from frontmatter", async () => { + const commandContent = `--- +description: Debug the application +mode: debug +--- + +# Debug Command + +Start debugging.` + + mockFs.stat = vi.fn().mockResolvedValue({ isDirectory: () => true }) + mockFs.readFile = vi.fn().mockResolvedValue(commandContent) + + const result = await getCommand("/test/cwd", "debug-app") + + expect(result).toEqual({ + name: "debug-app", + content: "# Debug Command\n\nStart debugging.", + source: "project", + filePath: path.join("/test/cwd", ".roo", "commands", "debug-app.md"), + description: "Debug the application", + argumentHint: undefined, + mode: "debug", + }) + }) + + it("should handle empty mode in frontmatter", async () => { + const commandContent = `--- +description: Test command +mode: "" +--- + +# Test Command + +Test content.` + + mockFs.stat = vi.fn().mockResolvedValue({ isDirectory: () => true }) + mockFs.readFile = vi.fn().mockResolvedValue(commandContent) + + const result = await getCommand("/test/cwd", "test") + + expect(result?.mode).toBeUndefined() + }) + + it("should handle command with description, argument-hint, and mode", async () => { + const commandContent = `--- +description: Deploy to environment +argument-hint: staging | production +mode: code +--- + +# Deploy Command + +Deploy the application.` + + mockFs.stat = vi.fn().mockResolvedValue({ isDirectory: () => true }) + mockFs.readFile = vi.fn().mockResolvedValue(commandContent) + + const result = await getCommand("/test/cwd", "deploy") + + expect(result).toEqual({ + name: "deploy", + content: "# Deploy Command\n\nDeploy the application.", + source: "project", + filePath: path.join("/test/cwd", ".roo", "commands", "deploy.md"), + description: "Deploy to environment", + argumentHint: "staging | production", + mode: "code", + }) + }) }) describe("getCommands with frontmatter", () => { diff --git a/src/services/command/commands.ts b/src/services/command/commands.ts index 6a5b4c6db1c..4e69558dc14 100644 --- a/src/services/command/commands.ts +++ b/src/services/command/commands.ts @@ -17,6 +17,7 @@ export interface Command { filePath: string description?: string argumentHint?: string + mode?: string } /** @@ -215,6 +216,7 @@ async function tryLoadCommand( let parsed let description: string | undefined let argumentHint: string | undefined + let mode: string | undefined let commandContent: string try { @@ -228,11 +230,13 @@ async function tryLoadCommand( typeof parsed.data["argument-hint"] === "string" && parsed.data["argument-hint"].trim() ? parsed.data["argument-hint"].trim() : undefined + mode = typeof parsed.data.mode === "string" && parsed.data.mode.trim() ? parsed.data.mode.trim() : undefined commandContent = parsed.content.trim() } catch { // If frontmatter parsing fails, treat the entire content as command content description = undefined argumentHint = undefined + mode = undefined commandContent = content.trim() } @@ -243,6 +247,7 @@ async function tryLoadCommand( filePath: resolvedPath, description, argumentHint, + mode, } } catch { // Directory doesn't exist or can't be read @@ -296,6 +301,7 @@ async function scanCommandDirectory( let parsed let description: string | undefined let argumentHint: string | undefined + let mode: string | undefined let commandContent: string try { @@ -309,11 +315,16 @@ async function scanCommandDirectory( typeof parsed.data["argument-hint"] === "string" && parsed.data["argument-hint"].trim() ? parsed.data["argument-hint"].trim() : undefined + mode = + typeof parsed.data.mode === "string" && parsed.data.mode.trim() + ? parsed.data.mode.trim() + : undefined commandContent = parsed.content.trim() } catch { // If frontmatter parsing fails, treat the entire content as command content description = undefined argumentHint = undefined + mode = undefined commandContent = content.trim() } @@ -326,6 +337,7 @@ async function scanCommandDirectory( filePath: resolvedPath, description, argumentHint, + mode, }) } } catch (error) {