Skip to content

Commit 8abe61a

Browse files
committed
Add built-in slash commands
1 parent a198b6a commit 8abe61a

File tree

11 files changed

+290
-10
lines changed

11 files changed

+290
-10
lines changed

src/.vscodeignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
!assets/vscode-material-icons/**
3232
!assets/icons/**
3333
!assets/images/**
34+
!assets/built-in-commands/**
3435

3536
# Include .env file for telemetry
3637
!.env

src/__tests__/command-integration.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ describe("Command Integration Tests", () => {
1515
commands.forEach((command) => {
1616
expect(command.name).toBeDefined()
1717
expect(typeof command.name).toBe("string")
18-
expect(command.source).toMatch(/^(project|global)$/)
18+
expect(command.source).toMatch(/^(project|global|built-in)$/)
1919
expect(command.content).toBeDefined()
2020
expect(typeof command.content).toBe("string")
2121
})
@@ -43,7 +43,7 @@ describe("Command Integration Tests", () => {
4343

4444
expect(loadedCommand).toBeDefined()
4545
expect(loadedCommand?.name).toBe(firstCommand.name)
46-
expect(loadedCommand?.source).toMatch(/^(project|global)$/)
46+
expect(loadedCommand?.source).toMatch(/^(project|global|built-in)$/)
4747
expect(loadedCommand?.content).toBeDefined()
4848
expect(typeof loadedCommand?.content).toBe("string")
4949
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
description: "Create a custom mode"
3+
---
4+
5+
TBD
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
description: "Set up rules for a project"
3+
---
4+
5+
TBD

src/core/webview/webviewMessageHandler.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2581,6 +2581,7 @@ export const webviewMessageHandler = async (
25812581
source: command.source,
25822582
filePath: command.filePath,
25832583
description: command.description,
2584+
argumentHint: command.argumentHint,
25842585
}))
25852586
await provider.postMessageToWebview({
25862587
type: "commands",

src/esbuild.mjs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,14 @@ async function main() {
5959
srcDir,
6060
buildDir,
6161
)
62+
// Copy built-in commands to dist directory
63+
copyPaths(
64+
[
65+
["assets/built-in-commands", "assets/built-in-commands"],
66+
],
67+
srcDir,
68+
distDir,
69+
)
6270
})
6371
},
6472
},
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { describe, it, expect } from "vitest"
2+
import { getBuiltInCommands, getBuiltInCommand, getBuiltInCommandNames } from "../built-in-commands"
3+
4+
describe("Built-in Commands", () => {
5+
describe("getBuiltInCommands", () => {
6+
it("should return all built-in commands", async () => {
7+
const commands = await getBuiltInCommands()
8+
9+
expect(commands).toHaveLength(2)
10+
expect(commands.map((cmd) => cmd.name)).toEqual(expect.arrayContaining(["init", "create-mode"]))
11+
12+
// Verify all commands have required properties
13+
commands.forEach((command) => {
14+
expect(command.name).toBeDefined()
15+
expect(typeof command.name).toBe("string")
16+
expect(command.content).toBeDefined()
17+
expect(typeof command.content).toBe("string")
18+
expect(command.source).toBe("built-in")
19+
expect(command.filePath).toMatch(/^<built-in:.+>$/)
20+
expect(command.description).toBeDefined()
21+
expect(typeof command.description).toBe("string")
22+
})
23+
})
24+
25+
it("should return commands with proper content", async () => {
26+
const commands = await getBuiltInCommands()
27+
28+
const initCommand = commands.find((cmd) => cmd.name === "init")
29+
expect(initCommand).toBeDefined()
30+
expect(initCommand!.content).toContain("Initialize Roo Project")
31+
expect(initCommand!.content).toContain(".roo/rules/")
32+
expect(initCommand!.description).toBe("Initialize a Roo project with recommended rules and configuration")
33+
34+
const createModeCommand = commands.find((cmd) => cmd.name === "create-mode")
35+
expect(createModeCommand).toBeDefined()
36+
expect(createModeCommand!.content).toContain("Create a Custom Mode")
37+
expect(createModeCommand!.content).toContain("YAML")
38+
expect(createModeCommand!.description).toBe("Create a custom mode for specialized AI assistance")
39+
})
40+
})
41+
42+
describe("getBuiltInCommand", () => {
43+
it("should return specific built-in command by name", async () => {
44+
const initCommand = await getBuiltInCommand("init")
45+
46+
expect(initCommand).toBeDefined()
47+
expect(initCommand!.name).toBe("init")
48+
expect(initCommand!.source).toBe("built-in")
49+
expect(initCommand!.filePath).toBe("<built-in:init>")
50+
expect(initCommand!.content).toContain("Initialize Roo Project")
51+
expect(initCommand!.description).toBe("Initialize a Roo project with recommended rules and configuration")
52+
})
53+
54+
it("should return create-mode command", async () => {
55+
const createModeCommand = await getBuiltInCommand("create-mode")
56+
57+
expect(createModeCommand).toBeDefined()
58+
expect(createModeCommand!.name).toBe("create-mode")
59+
expect(createModeCommand!.source).toBe("built-in")
60+
expect(createModeCommand!.filePath).toBe("<built-in:create-mode>")
61+
expect(createModeCommand!.content).toContain("Create a Custom Mode")
62+
expect(createModeCommand!.description).toBe("Create a custom mode for specialized AI assistance")
63+
})
64+
65+
it("should return undefined for non-existent command", async () => {
66+
const nonExistentCommand = await getBuiltInCommand("non-existent")
67+
expect(nonExistentCommand).toBeUndefined()
68+
})
69+
70+
it("should handle empty string command name", async () => {
71+
const emptyCommand = await getBuiltInCommand("")
72+
expect(emptyCommand).toBeUndefined()
73+
})
74+
})
75+
76+
describe("getBuiltInCommandNames", () => {
77+
it("should return all built-in command names", async () => {
78+
const names = await getBuiltInCommandNames()
79+
80+
expect(names).toHaveLength(2)
81+
expect(names).toEqual(expect.arrayContaining(["init", "create-mode"]))
82+
// Order doesn't matter since it's based on filesystem order
83+
expect(names.sort()).toEqual(["create-mode", "init"])
84+
})
85+
86+
it("should return array of strings", async () => {
87+
const names = await getBuiltInCommandNames()
88+
89+
names.forEach((name) => {
90+
expect(typeof name).toBe("string")
91+
expect(name.length).toBeGreaterThan(0)
92+
})
93+
})
94+
})
95+
96+
describe("Command Content Validation", () => {
97+
it("init command should have comprehensive content", async () => {
98+
const command = await getBuiltInCommand("init")
99+
const content = command!.content
100+
101+
// Should contain key sections
102+
expect(content).toContain("What this command does:")
103+
expect(content).toContain("Recommended starter rules:")
104+
expect(content).toContain("Getting Started:")
105+
expect(content).toContain("Example rule file structure:")
106+
107+
// Should mention important concepts
108+
expect(content).toContain("code-style.md")
109+
expect(content).toContain("project-context.md")
110+
expect(content).toContain("testing.md")
111+
expect(content).toContain("@rules")
112+
})
113+
114+
it("create-mode command should have comprehensive content", async () => {
115+
const command = await getBuiltInCommand("create-mode")
116+
const content = command!.content
117+
118+
// Should contain key sections
119+
expect(content).toContain("What are Modes?")
120+
expect(content).toContain("Mode Configuration")
121+
expect(content).toContain("Mode Properties")
122+
expect(content).toContain("Creating Your Mode")
123+
expect(content).toContain("Mode Examples")
124+
expect(content).toContain("Best Practices")
125+
126+
// Should mention important concepts
127+
expect(content).toContain("YAML")
128+
expect(content).toContain("instructions")
129+
expect(content).toContain("file_restrictions")
130+
expect(content).toContain("tools")
131+
})
132+
})
133+
})
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import fs from "fs/promises"
2+
import * as path from "path"
3+
import matter from "gray-matter"
4+
import { Command } from "./commands"
5+
6+
/**
7+
* Get the path to the built-in commands directory
8+
*/
9+
function getBuiltInCommandsDirectory(): string {
10+
// Handle both development and compiled extension environments
11+
// In development: __dirname = /path/to/src/services/command
12+
// In compiled: __dirname = /path/to/src/dist/services/command
13+
14+
if (__dirname.includes("/dist/")) {
15+
// Compiled extension: navigate from dist/services/command to dist/assets/built-in-commands
16+
return path.join(__dirname, "..", "..", "assets", "built-in-commands")
17+
} else {
18+
// Development: navigate from src/services/command to src/assets/built-in-commands
19+
return path.join(__dirname, "..", "assets", "built-in-commands")
20+
}
21+
}
22+
23+
/**
24+
* Load a built-in command from a markdown file
25+
*/
26+
async function loadBuiltInCommandFromFile(filePath: string, name: string): Promise<Command | undefined> {
27+
try {
28+
const content = await fs.readFile(filePath, "utf-8")
29+
30+
let parsed
31+
let description: string | undefined
32+
let argumentHint: string | undefined
33+
let commandContent: string
34+
35+
try {
36+
// Try to parse frontmatter with gray-matter
37+
parsed = matter(content)
38+
description =
39+
typeof parsed.data.description === "string" && parsed.data.description.trim()
40+
? parsed.data.description.trim()
41+
: undefined
42+
argumentHint =
43+
typeof parsed.data["argument-hint"] === "string" && parsed.data["argument-hint"].trim()
44+
? parsed.data["argument-hint"].trim()
45+
: undefined
46+
commandContent = parsed.content.trim()
47+
} catch (frontmatterError) {
48+
// If frontmatter parsing fails, treat the entire content as command content
49+
description = undefined
50+
argumentHint = undefined
51+
commandContent = content.trim()
52+
}
53+
54+
return {
55+
name,
56+
content: commandContent,
57+
source: "built-in",
58+
filePath: `<built-in:${name}>`,
59+
description,
60+
argumentHint,
61+
}
62+
} catch (error) {
63+
// File doesn't exist or can't be read
64+
return undefined
65+
}
66+
}
67+
68+
/**
69+
* Get all built-in commands as Command objects
70+
*/
71+
export async function getBuiltInCommands(): Promise<Command[]> {
72+
const commands: Command[] = []
73+
const builtInCommandsDir = getBuiltInCommandsDirectory()
74+
75+
try {
76+
const entries = await fs.readdir(builtInCommandsDir, { withFileTypes: true })
77+
78+
for (const entry of entries) {
79+
if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) {
80+
const commandName = entry.name.slice(0, -3) // Remove .md extension
81+
const filePath = path.join(builtInCommandsDir, entry.name)
82+
83+
const command = await loadBuiltInCommandFromFile(filePath, commandName)
84+
if (command) {
85+
commands.push(command)
86+
}
87+
}
88+
}
89+
} catch (error) {
90+
// Directory doesn't exist or can't be read - this is fine, just return empty array
91+
console.warn("Built-in commands directory not found or not readable:", error)
92+
}
93+
94+
return commands
95+
}
96+
97+
/**
98+
* Get a specific built-in command by name
99+
*/
100+
export async function getBuiltInCommand(name: string): Promise<Command | undefined> {
101+
const builtInCommandsDir = getBuiltInCommandsDirectory()
102+
const filePath = path.join(builtInCommandsDir, `${name}.md`)
103+
104+
return await loadBuiltInCommandFromFile(filePath, name)
105+
}
106+
107+
/**
108+
* Get names of all built-in commands
109+
*/
110+
export async function getBuiltInCommandNames(): Promise<string[]> {
111+
const commands = await getBuiltInCommands()
112+
return commands.map((cmd) => cmd.name)
113+
}

src/services/command/commands.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,35 @@ import fs from "fs/promises"
22
import * as path from "path"
33
import matter from "gray-matter"
44
import { getGlobalRooDirectory, getProjectRooDirectoryForCwd } from "../roo-config"
5+
import { getBuiltInCommands, getBuiltInCommand } from "./built-in-commands"
56

67
export interface Command {
78
name: string
89
content: string
9-
source: "global" | "project"
10+
source: "global" | "project" | "built-in"
1011
filePath: string
1112
description?: string
1213
argumentHint?: string
1314
}
1415

1516
/**
16-
* Get all available commands from both global and project directories
17+
* Get all available commands from built-in, global, and project directories
18+
* Priority order: project > global > built-in (later sources override earlier ones)
1719
*/
1820
export async function getCommands(cwd: string): Promise<Command[]> {
1921
const commands = new Map<string, Command>()
2022

21-
// Scan global commands first
23+
// Add built-in commands first (lowest priority)
24+
const builtInCommands = await getBuiltInCommands()
25+
for (const command of builtInCommands) {
26+
commands.set(command.name, command)
27+
}
28+
29+
// Scan global commands (override built-in)
2230
const globalDir = path.join(getGlobalRooDirectory(), "commands")
2331
await scanCommandDirectory(globalDir, "global", commands)
2432

25-
// Scan project commands (these override global ones)
33+
// Scan project commands (highest priority - override both global and built-in)
2634
const projectDir = path.join(getProjectRooDirectoryForCwd(cwd), "commands")
2735
await scanCommandDirectory(projectDir, "project", commands)
2836

@@ -31,21 +39,27 @@ export async function getCommands(cwd: string): Promise<Command[]> {
3139

3240
/**
3341
* Get a specific command by name (optimized to avoid scanning all commands)
42+
* Priority order: project > global > built-in
3443
*/
3544
export async function getCommand(cwd: string, name: string): Promise<Command | undefined> {
3645
// Try to find the command directly without scanning all commands
3746
const projectDir = path.join(getProjectRooDirectoryForCwd(cwd), "commands")
3847
const globalDir = path.join(getGlobalRooDirectory(), "commands")
3948

40-
// Check project directory first (project commands override global ones)
49+
// Check project directory first (highest priority)
4150
const projectCommand = await tryLoadCommand(projectDir, name, "project")
4251
if (projectCommand) {
4352
return projectCommand
4453
}
4554

4655
// Check global directory if not found in project
4756
const globalCommand = await tryLoadCommand(globalDir, name, "global")
48-
return globalCommand
57+
if (globalCommand) {
58+
return globalCommand
59+
}
60+
61+
// Check built-in commands if not found in project or global (lowest priority)
62+
return await getBuiltInCommand(name)
4963
}
5064

5165
/**

src/shared/ExtensionMessage.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import type { MarketplaceItem } from "@roo-code/types"
2222
// Command interface for frontend/backend communication
2323
export interface Command {
2424
name: string
25-
source: "global" | "project"
25+
source: "global" | "project" | "built-in"
2626
filePath?: string
2727
description?: string
2828
argumentHint?: string

0 commit comments

Comments
 (0)