feat: add optional mode field to slash command front matter (#10344)

* feat: add optional mode field to slash command front matter

- Add mode field to Command interface
- Update command parsing to extract mode from frontmatter
- Modify RunSlashCommandTool to automatically switch mode when specified
- Add comprehensive tests for mode field parsing and switching
- Update existing tests to include mode field

* Make it work for manual slash commands too

---------

Co-authored-by: Roo Code <roomote@roocode.com>
Co-authored-by: Matt Rubens <mrubens@users.noreply.github.com>
This commit is contained in:
roomote[bot] 2025-12-26 17:17:54 -08:00 committed by GitHub
parent 2eebf3c2c4
commit 13370a2ad1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 363 additions and 69 deletions

View file

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

View file

@ -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", () => {

View file

@ -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('<url_content url="https://example.com">')
expect(result).toContain("# Example Content\n\nThis is the content.")
expect(result).toContain("</url_content>")
expect(result.text).toContain('<url_content url="https://example.com">')
expect(result.text).toContain("# Example Content\n\nThis is the content.")
expect(result.text).toContain("</url_content>")
})
it("should handle multiple URLs with mixed success and failure", async () => {
@ -151,9 +151,9 @@ describe("parseMentions - URL error handling", () => {
mockUrlContentFetcher,
)
expect(result).toContain('<url_content url="https://example1.com">')
expect(result).toContain("# First Site")
expect(result).toContain('<url_content url="https://example2.com">')
expect(result).toContain("Error fetching content: timeout")
expect(result.text).toContain('<url_content url="https://example1.com">')
expect(result.text).toContain("# First Site")
expect(result.text).toContain('<url_content url="https://example2.com">')
expect(result.text).toContain("Error fetching content: timeout")
})
})

View file

@ -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: <task>Do something</task>",
})
expect(result.mode).toBeUndefined()
})
it("should process text blocks with <feedback> tags", async () => {
@ -156,10 +160,11 @@ describe("processUserContentMentions", () => {
})
expect(parseMentions).toHaveBeenCalled()
expect(result[0]).toEqual({
expect(result.content[0]).toEqual({
type: "text",
text: "parsed: <feedback>Fix this issue</feedback>",
})
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: <feedback>Tool feedback</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: <task>First task</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>Feedback</feedback>",
})
expect(result.mode).toBeUndefined()
})
})

View file

@ -71,6 +71,11 @@ export async function openMention(cwd: string, mention?: string): Promise<void>
}
}
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<string> {
): Promise<ParseMentionsResult> {
const mentions: Set<string> = new Set()
const validCommands: Map<string, Command> = 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<string> {
// Export processUserContentMentions from its own file
export { processUserContentMentions } from "./processUserContentMentions"
export type { ProcessUserContentMentionsResult } from "./processUserContentMentions"

View file

@ -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<ProcessUserContentMentionsResult> {
// 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("<task>") ||
@ -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 }
}

View file

@ -2357,7 +2357,7 @@ export class Task extends EventEmitter<TaskEvents> 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<TaskEvents> 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.

View file

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

View file

@ -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}`
}

View file

@ -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",
}),
)
})
})

View file

@ -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", () => {

View file

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