Skip to content

Commit 5d4e381

Browse files
authored
Support for custom slash commands (#6263)
1 parent 43f649b commit 5d4e381

33 files changed

+1173
-51
lines changed
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { describe, it, expect } from "vitest"
2+
import { getCommands, getCommand, getCommandNames } from "../services/command/commands"
3+
import * as path from "path"
4+
5+
describe("Command Integration Tests", () => {
6+
const testWorkspaceDir = path.join(__dirname, "../../")
7+
8+
it("should discover command files in .roo/commands/", async () => {
9+
const commands = await getCommands(testWorkspaceDir)
10+
11+
// Should be able to discover commands (may be empty in test environment)
12+
expect(Array.isArray(commands)).toBe(true)
13+
14+
// If commands exist, verify they have valid properties
15+
commands.forEach((command) => {
16+
expect(command.name).toBeDefined()
17+
expect(typeof command.name).toBe("string")
18+
expect(command.source).toMatch(/^(project|global)$/)
19+
expect(command.content).toBeDefined()
20+
expect(typeof command.content).toBe("string")
21+
})
22+
})
23+
24+
it("should return command names correctly", async () => {
25+
const commandNames = await getCommandNames(testWorkspaceDir)
26+
27+
// Should return an array (may be empty in test environment)
28+
expect(Array.isArray(commandNames)).toBe(true)
29+
30+
// If command names exist, they should be strings
31+
commandNames.forEach((name) => {
32+
expect(typeof name).toBe("string")
33+
expect(name.length).toBeGreaterThan(0)
34+
})
35+
})
36+
37+
it("should load command content if commands exist", async () => {
38+
const commands = await getCommands(testWorkspaceDir)
39+
40+
if (commands.length > 0) {
41+
const firstCommand = commands[0]
42+
const loadedCommand = await getCommand(testWorkspaceDir, firstCommand.name)
43+
44+
expect(loadedCommand).toBeDefined()
45+
expect(loadedCommand?.name).toBe(firstCommand.name)
46+
expect(loadedCommand?.source).toMatch(/^(project|global)$/)
47+
expect(loadedCommand?.content).toBeDefined()
48+
expect(typeof loadedCommand?.content).toBe("string")
49+
}
50+
})
51+
52+
it("should handle non-existent commands gracefully", async () => {
53+
const nonExistentCommand = await getCommand(testWorkspaceDir, "non-existent-command")
54+
expect(nonExistentCommand).toBeUndefined()
55+
})
56+
})
Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
import { describe, it, expect, beforeEach, vi } from "vitest"
2+
import { parseMentions } from "../core/mentions"
3+
import { UrlContentFetcher } from "../services/browser/UrlContentFetcher"
4+
import { getCommand } from "../services/command/commands"
5+
6+
// Mock the dependencies
7+
vi.mock("../services/command/commands")
8+
vi.mock("../services/browser/UrlContentFetcher")
9+
10+
const MockedUrlContentFetcher = vi.mocked(UrlContentFetcher)
11+
const mockGetCommand = vi.mocked(getCommand)
12+
13+
describe("Command Mentions", () => {
14+
let mockUrlContentFetcher: any
15+
16+
beforeEach(() => {
17+
vi.clearAllMocks()
18+
19+
// Create a mock UrlContentFetcher instance
20+
mockUrlContentFetcher = {
21+
launchBrowser: vi.fn(),
22+
urlToMarkdown: vi.fn(),
23+
closeBrowser: vi.fn(),
24+
}
25+
26+
MockedUrlContentFetcher.mockImplementation(() => mockUrlContentFetcher)
27+
})
28+
29+
// Helper function to call parseMentions with required parameters
30+
const callParseMentions = async (text: string) => {
31+
return await parseMentions(
32+
text,
33+
"/test/cwd", // cwd
34+
mockUrlContentFetcher, // urlContentFetcher
35+
undefined, // fileContextTracker
36+
undefined, // rooIgnoreController
37+
true, // showRooIgnoredFiles
38+
true, // includeDiagnosticMessages
39+
50, // maxDiagnosticMessages
40+
undefined, // maxReadFileLine
41+
)
42+
}
43+
44+
describe("parseMentions with command support", () => {
45+
it("should parse command mentions and include content", async () => {
46+
const commandContent = "# Setup Environment\n\nRun the following commands:\n```bash\nnpm install\n```"
47+
mockGetCommand.mockResolvedValue({
48+
name: "setup",
49+
content: commandContent,
50+
source: "project",
51+
filePath: "/project/.roo/commands/setup.md",
52+
})
53+
54+
const input = "/setup Please help me set up the project"
55+
const result = await callParseMentions(input)
56+
57+
expect(mockGetCommand).toHaveBeenCalledWith("/test/cwd", "setup")
58+
expect(result).toContain('<command name="setup">')
59+
expect(result).toContain(commandContent)
60+
expect(result).toContain("</command>")
61+
expect(result).toContain("Please help me set up the project")
62+
})
63+
64+
it("should handle multiple commands in message", async () => {
65+
mockGetCommand
66+
.mockResolvedValueOnce({
67+
name: "setup",
68+
content: "# Setup instructions",
69+
source: "project",
70+
filePath: "/project/.roo/commands/setup.md",
71+
})
72+
.mockResolvedValueOnce({
73+
name: "deploy",
74+
content: "# Deploy instructions",
75+
source: "project",
76+
filePath: "/project/.roo/commands/deploy.md",
77+
})
78+
79+
// Both commands should be recognized
80+
const input = "/setup the project\nThen /deploy later"
81+
const result = await callParseMentions(input)
82+
83+
expect(mockGetCommand).toHaveBeenCalledWith("/test/cwd", "setup")
84+
expect(mockGetCommand).toHaveBeenCalledWith("/test/cwd", "deploy")
85+
expect(mockGetCommand).toHaveBeenCalledTimes(2) // Both commands called
86+
expect(result).toContain('<command name="setup">')
87+
expect(result).toContain("# Setup instructions")
88+
expect(result).toContain('<command name="deploy">')
89+
expect(result).toContain("# Deploy instructions")
90+
})
91+
92+
it("should handle non-existent command gracefully", async () => {
93+
mockGetCommand.mockResolvedValue(undefined)
94+
95+
const input = "/nonexistent command"
96+
const result = await callParseMentions(input)
97+
98+
expect(mockGetCommand).toHaveBeenCalledWith("/test/cwd", "nonexistent")
99+
expect(result).toContain('<command name="nonexistent">')
100+
expect(result).toContain("Command 'nonexistent' not found")
101+
expect(result).toContain("</command>")
102+
})
103+
104+
it("should handle command loading errors", async () => {
105+
mockGetCommand.mockRejectedValue(new Error("Failed to load command"))
106+
107+
const input = "/error-command test"
108+
const result = await callParseMentions(input)
109+
110+
expect(result).toContain('<command name="error-command">')
111+
expect(result).toContain("Error loading command")
112+
expect(result).toContain("</command>")
113+
})
114+
115+
it("should handle command names with hyphens and underscores at start", async () => {
116+
mockGetCommand.mockResolvedValue({
117+
name: "setup-dev",
118+
content: "# Dev setup",
119+
source: "project",
120+
filePath: "/project/.roo/commands/setup-dev.md",
121+
})
122+
123+
const input = "/setup-dev for the project"
124+
const result = await callParseMentions(input)
125+
126+
expect(mockGetCommand).toHaveBeenCalledWith("/test/cwd", "setup-dev")
127+
expect(result).toContain('<command name="setup-dev">')
128+
expect(result).toContain("# Dev setup")
129+
})
130+
131+
it("should preserve command content formatting", async () => {
132+
const commandContent = `# Complex Command
133+
134+
## Step 1
135+
Run this command:
136+
\`\`\`bash
137+
npm install
138+
\`\`\`
139+
140+
## Step 2
141+
- Check file1.js
142+
- Update file2.ts
143+
- Test everything
144+
145+
> **Note**: This is important!`
146+
147+
mockGetCommand.mockResolvedValue({
148+
name: "complex",
149+
content: commandContent,
150+
source: "project",
151+
filePath: "/project/.roo/commands/complex.md",
152+
})
153+
154+
const input = "/complex command"
155+
const result = await callParseMentions(input)
156+
157+
expect(result).toContain('<command name="complex">')
158+
expect(result).toContain("# Complex Command")
159+
expect(result).toContain("```bash")
160+
expect(result).toContain("npm install")
161+
expect(result).toContain("- Check file1.js")
162+
expect(result).toContain("> **Note**: This is important!")
163+
expect(result).toContain("</command>")
164+
})
165+
166+
it("should handle empty command content", async () => {
167+
mockGetCommand.mockResolvedValue({
168+
name: "empty",
169+
content: "",
170+
source: "project",
171+
filePath: "/project/.roo/commands/empty.md",
172+
})
173+
174+
const input = "/empty command"
175+
const result = await callParseMentions(input)
176+
177+
expect(result).toContain('<command name="empty">')
178+
expect(result).toContain("</command>")
179+
// Should still include the command tags even with empty content
180+
})
181+
})
182+
183+
describe("command mention regex patterns", () => {
184+
it("should match valid command mention patterns anywhere", () => {
185+
const commandRegex = /\/([a-zA-Z0-9_\.-]+)(?=\s|$)/g
186+
187+
const validPatterns = ["/setup", "/build-prod", "/test_suite", "/my-command", "/command123"]
188+
189+
validPatterns.forEach((pattern) => {
190+
const match = pattern.match(commandRegex)
191+
expect(match).toBeTruthy()
192+
expect(match![0]).toBe(pattern)
193+
})
194+
})
195+
196+
it("should match command patterns in middle of text", () => {
197+
const commandRegex = /\/([a-zA-Z0-9_\.-]+)(?=\s|$)/g
198+
199+
const validPatterns = ["Please /setup", "Run /build now", "Use /deploy here"]
200+
201+
validPatterns.forEach((pattern) => {
202+
const match = pattern.match(commandRegex)
203+
expect(match).toBeTruthy()
204+
expect(match![0]).toMatch(/^\/[a-zA-Z0-9_\.-]+$/)
205+
})
206+
})
207+
208+
it("should match commands at start of new lines", () => {
209+
const commandRegex = /\/([a-zA-Z0-9_\.-]+)(?=\s|$)/g
210+
211+
const multilineText = "First line\n/setup the project\nAnother line\n/deploy when ready"
212+
const matches = multilineText.match(commandRegex)
213+
214+
// Should match both commands now
215+
expect(matches).toBeTruthy()
216+
expect(matches).toHaveLength(2)
217+
expect(matches![0]).toBe("/setup")
218+
expect(matches![1]).toBe("/deploy")
219+
})
220+
221+
it("should match multiple commands in message", () => {
222+
const commandRegex = /(?:^|\s)\/([a-zA-Z0-9_\.-]+)(?=\s|$)/g
223+
224+
const validText = "/setup the project\nThen /deploy later"
225+
const matches = validText.match(commandRegex)
226+
227+
expect(matches).toBeTruthy()
228+
expect(matches).toHaveLength(2)
229+
expect(matches![0]).toBe("/setup")
230+
expect(matches![1]).toBe(" /deploy") // Note: includes leading space
231+
})
232+
233+
it("should not match invalid command patterns", () => {
234+
const commandRegex = /\/([a-zA-Z0-9_\.-]+)(?=\s|$)/g
235+
236+
const invalidPatterns = ["/ space", "/with space", "/with/slash", "//double", "/with@symbol"]
237+
238+
invalidPatterns.forEach((pattern) => {
239+
const match = pattern.match(commandRegex)
240+
if (match) {
241+
// If it matches, it should not be the full invalid pattern
242+
expect(match[0]).not.toBe(pattern)
243+
}
244+
})
245+
})
246+
})
247+
248+
describe("command mention text transformation", () => {
249+
it("should transform command mentions at start of message", async () => {
250+
const input = "/setup the project"
251+
const result = await callParseMentions(input)
252+
253+
expect(result).toContain("Command 'setup' (see below for command content)")
254+
})
255+
256+
it("should process multiple commands in message", async () => {
257+
mockGetCommand
258+
.mockResolvedValueOnce({
259+
name: "setup",
260+
content: "# Setup instructions",
261+
source: "project",
262+
filePath: "/project/.roo/commands/setup.md",
263+
})
264+
.mockResolvedValueOnce({
265+
name: "deploy",
266+
content: "# Deploy instructions",
267+
source: "project",
268+
filePath: "/project/.roo/commands/deploy.md",
269+
})
270+
271+
const input = "/setup the project\nThen /deploy later"
272+
const result = await callParseMentions(input)
273+
274+
expect(result).toContain("Command 'setup' (see below for command content)")
275+
expect(result).toContain("Command 'deploy' (see below for command content)")
276+
})
277+
278+
it("should match commands anywhere with proper word boundaries", async () => {
279+
mockGetCommand.mockResolvedValue({
280+
name: "build",
281+
content: "# Build instructions",
282+
source: "project",
283+
filePath: "/project/.roo/commands/build.md",
284+
})
285+
286+
// At the beginning - should match
287+
let input = "/build the project"
288+
let result = await callParseMentions(input)
289+
expect(result).toContain("Command 'build'")
290+
291+
// After space - should match
292+
input = "Please /build and test"
293+
result = await callParseMentions(input)
294+
expect(result).toContain("Command 'build'")
295+
296+
// At the end - should match
297+
input = "Run the /build"
298+
result = await callParseMentions(input)
299+
expect(result).toContain("Command 'build'")
300+
301+
// At start of new line - should match
302+
input = "Some text\n/build the project"
303+
result = await callParseMentions(input)
304+
expect(result).toContain("Command 'build'")
305+
})
306+
})
307+
})

0 commit comments

Comments
 (0)