mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
* 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>
367 lines
11 KiB
TypeScript
367 lines
11 KiB
TypeScript
import fs from "fs/promises"
|
|
import * as path from "path"
|
|
import { Dirent } from "fs"
|
|
import matter from "gray-matter"
|
|
import { getGlobalRooDirectory, getProjectRooDirectoryForCwd } from "../roo-config"
|
|
import { getBuiltInCommands, getBuiltInCommand } from "./built-in-commands"
|
|
|
|
/**
|
|
* Maximum depth for resolving symlinks to prevent cyclic symlink loops
|
|
*/
|
|
const MAX_DEPTH = 5
|
|
|
|
export interface Command {
|
|
name: string
|
|
content: string
|
|
source: "global" | "project" | "built-in"
|
|
filePath: string
|
|
description?: string
|
|
argumentHint?: string
|
|
mode?: string
|
|
}
|
|
|
|
/**
|
|
* Information about a resolved command file
|
|
*/
|
|
interface CommandFileInfo {
|
|
/** Original path (symlink path if symlinked, otherwise the file path) */
|
|
originalPath: string
|
|
/** Resolved path (target of symlink if symlinked, otherwise the file path) */
|
|
resolvedPath: string
|
|
}
|
|
|
|
/**
|
|
* Recursively resolve a symbolic link and collect command file info
|
|
*/
|
|
async function resolveCommandSymLink(symlinkPath: string, fileInfo: CommandFileInfo[], depth: number): Promise<void> {
|
|
// Avoid cyclic symlinks
|
|
if (depth > MAX_DEPTH) {
|
|
return
|
|
}
|
|
try {
|
|
// Get the symlink target
|
|
const linkTarget = await fs.readlink(symlinkPath)
|
|
// Resolve the target path (relative to the symlink location)
|
|
const resolvedTarget = path.resolve(path.dirname(symlinkPath), linkTarget)
|
|
|
|
// Check if the target is a file (use lstat to detect nested symlinks)
|
|
const stats = await fs.lstat(resolvedTarget)
|
|
if (stats.isFile()) {
|
|
// Only include markdown files
|
|
if (isMarkdownFile(resolvedTarget)) {
|
|
// For symlinks to files, store the symlink path as original and target as resolved
|
|
fileInfo.push({ originalPath: symlinkPath, resolvedPath: resolvedTarget })
|
|
}
|
|
} else if (stats.isDirectory()) {
|
|
// Read the target directory and process its entries
|
|
const entries = await fs.readdir(resolvedTarget, { withFileTypes: true })
|
|
const directoryPromises: Promise<void>[] = []
|
|
for (const entry of entries) {
|
|
directoryPromises.push(resolveCommandDirectoryEntry(entry, resolvedTarget, fileInfo, depth + 1))
|
|
}
|
|
await Promise.all(directoryPromises)
|
|
} else if (stats.isSymbolicLink()) {
|
|
// Handle nested symlinks
|
|
await resolveCommandSymLink(resolvedTarget, fileInfo, depth + 1)
|
|
}
|
|
} catch {
|
|
// Skip invalid symlinks
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Recursively resolve directory entries and collect command file paths
|
|
*/
|
|
async function resolveCommandDirectoryEntry(
|
|
entry: Dirent,
|
|
dirPath: string,
|
|
fileInfo: CommandFileInfo[],
|
|
depth: number,
|
|
): Promise<void> {
|
|
// Avoid cyclic symlinks
|
|
if (depth > MAX_DEPTH) {
|
|
return
|
|
}
|
|
|
|
const fullPath = path.resolve(entry.parentPath || dirPath, entry.name)
|
|
if (entry.isFile()) {
|
|
// Only include markdown files
|
|
if (isMarkdownFile(entry.name)) {
|
|
// Regular file - both original and resolved paths are the same
|
|
fileInfo.push({ originalPath: fullPath, resolvedPath: fullPath })
|
|
}
|
|
} else if (entry.isSymbolicLink()) {
|
|
// Await the resolution of the symbolic link
|
|
await resolveCommandSymLink(fullPath, fileInfo, depth + 1)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Try to resolve a symlinked command file
|
|
*/
|
|
async function tryResolveSymlinkedCommand(filePath: string): Promise<string | undefined> {
|
|
try {
|
|
const lstat = await fs.lstat(filePath)
|
|
if (lstat.isSymbolicLink()) {
|
|
// Get the symlink target
|
|
const linkTarget = await fs.readlink(filePath)
|
|
// Resolve the target path (relative to the symlink location)
|
|
const resolvedTarget = path.resolve(path.dirname(filePath), linkTarget)
|
|
|
|
// Check if the target is a file
|
|
const stats = await fs.stat(resolvedTarget)
|
|
if (stats.isFile()) {
|
|
return resolvedTarget
|
|
}
|
|
}
|
|
} catch {
|
|
// Not a symlink or invalid symlink
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
/**
|
|
* Get all available commands from built-in, global, and project directories
|
|
* Priority order: project > global > built-in (later sources override earlier ones)
|
|
*/
|
|
export async function getCommands(cwd: string): Promise<Command[]> {
|
|
const commands = new Map<string, Command>()
|
|
|
|
// Add built-in commands first (lowest priority)
|
|
const builtInCommands = await getBuiltInCommands()
|
|
for (const command of builtInCommands) {
|
|
commands.set(command.name, command)
|
|
}
|
|
|
|
// Scan global commands (override built-in)
|
|
const globalDir = path.join(getGlobalRooDirectory(), "commands")
|
|
await scanCommandDirectory(globalDir, "global", commands)
|
|
|
|
// Scan project commands (highest priority - override both global and built-in)
|
|
const projectDir = path.join(getProjectRooDirectoryForCwd(cwd), "commands")
|
|
await scanCommandDirectory(projectDir, "project", commands)
|
|
|
|
return Array.from(commands.values())
|
|
}
|
|
|
|
/**
|
|
* Get a specific command by name (optimized to avoid scanning all commands)
|
|
* Priority order: project > global > built-in
|
|
*/
|
|
export async function getCommand(cwd: string, name: string): Promise<Command | undefined> {
|
|
// Try to find the command directly without scanning all commands
|
|
const projectDir = path.join(getProjectRooDirectoryForCwd(cwd), "commands")
|
|
const globalDir = path.join(getGlobalRooDirectory(), "commands")
|
|
|
|
// Check project directory first (highest priority)
|
|
const projectCommand = await tryLoadCommand(projectDir, name, "project")
|
|
if (projectCommand) {
|
|
return projectCommand
|
|
}
|
|
|
|
// Check global directory if not found in project
|
|
const globalCommand = await tryLoadCommand(globalDir, name, "global")
|
|
if (globalCommand) {
|
|
return globalCommand
|
|
}
|
|
|
|
// Check built-in commands if not found in project or global (lowest priority)
|
|
return await getBuiltInCommand(name)
|
|
}
|
|
|
|
/**
|
|
* Try to load a specific command from a directory (supports symlinks)
|
|
*/
|
|
async function tryLoadCommand(
|
|
dirPath: string,
|
|
name: string,
|
|
source: "global" | "project",
|
|
): Promise<Command | undefined> {
|
|
try {
|
|
const stats = await fs.stat(dirPath)
|
|
if (!stats.isDirectory()) {
|
|
return undefined
|
|
}
|
|
|
|
// Try to find the command file directly
|
|
const commandFileName = `${name}.md`
|
|
const filePath = path.join(dirPath, commandFileName)
|
|
|
|
// Check if this is a regular file first
|
|
let resolvedPath = filePath
|
|
let content: string | undefined
|
|
|
|
try {
|
|
content = await fs.readFile(filePath, "utf-8")
|
|
} catch {
|
|
// File doesn't exist or can't be read - try resolving as symlink
|
|
const symlinkedPath = await tryResolveSymlinkedCommand(filePath)
|
|
if (symlinkedPath) {
|
|
try {
|
|
content = await fs.readFile(symlinkedPath, "utf-8")
|
|
resolvedPath = symlinkedPath
|
|
} catch {
|
|
// Symlink target can't be read
|
|
return undefined
|
|
}
|
|
} else {
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
if (!content) {
|
|
return undefined
|
|
}
|
|
|
|
let parsed
|
|
let description: string | undefined
|
|
let argumentHint: string | undefined
|
|
let mode: string | undefined
|
|
let commandContent: string
|
|
|
|
try {
|
|
// Try to parse frontmatter with gray-matter
|
|
parsed = matter(content)
|
|
description =
|
|
typeof parsed.data.description === "string" && parsed.data.description.trim()
|
|
? parsed.data.description.trim()
|
|
: undefined
|
|
argumentHint =
|
|
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()
|
|
}
|
|
|
|
return {
|
|
name,
|
|
content: commandContent,
|
|
source,
|
|
filePath: resolvedPath,
|
|
description,
|
|
argumentHint,
|
|
mode,
|
|
}
|
|
} catch {
|
|
// Directory doesn't exist or can't be read
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get command names for autocomplete
|
|
*/
|
|
export async function getCommandNames(cwd: string): Promise<string[]> {
|
|
const commands = await getCommands(cwd)
|
|
return commands.map((cmd) => cmd.name)
|
|
}
|
|
|
|
/**
|
|
* Scan a specific command directory (supports symlinks)
|
|
*/
|
|
async function scanCommandDirectory(
|
|
dirPath: string,
|
|
source: "global" | "project",
|
|
commands: Map<string, Command>,
|
|
): Promise<void> {
|
|
try {
|
|
const stats = await fs.stat(dirPath)
|
|
if (!stats.isDirectory()) {
|
|
return
|
|
}
|
|
|
|
const entries = await fs.readdir(dirPath, { withFileTypes: true })
|
|
|
|
// Collect all command files, including those from symlinks
|
|
const fileInfo: CommandFileInfo[] = []
|
|
const initialPromises: Promise<void>[] = []
|
|
|
|
for (const entry of entries) {
|
|
initialPromises.push(resolveCommandDirectoryEntry(entry, dirPath, fileInfo, 0))
|
|
}
|
|
|
|
// Wait for all files to be resolved
|
|
await Promise.all(initialPromises)
|
|
|
|
// Process each collected file
|
|
for (const { originalPath, resolvedPath } of fileInfo) {
|
|
// Command name comes from the original path (symlink name if symlinked)
|
|
const commandName = getCommandNameFromFile(path.basename(originalPath))
|
|
|
|
try {
|
|
const content = await fs.readFile(resolvedPath, "utf-8")
|
|
|
|
let parsed
|
|
let description: string | undefined
|
|
let argumentHint: string | undefined
|
|
let mode: string | undefined
|
|
let commandContent: string
|
|
|
|
try {
|
|
// Try to parse frontmatter with gray-matter
|
|
parsed = matter(content)
|
|
description =
|
|
typeof parsed.data.description === "string" && parsed.data.description.trim()
|
|
? parsed.data.description.trim()
|
|
: undefined
|
|
argumentHint =
|
|
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()
|
|
}
|
|
|
|
// Project commands override global ones
|
|
if (source === "project" || !commands.has(commandName)) {
|
|
commands.set(commandName, {
|
|
name: commandName,
|
|
content: commandContent,
|
|
source,
|
|
filePath: resolvedPath,
|
|
description,
|
|
argumentHint,
|
|
mode,
|
|
})
|
|
}
|
|
} catch (error) {
|
|
console.warn(`Failed to read command file ${resolvedPath}:`, error)
|
|
}
|
|
}
|
|
} catch {
|
|
// Directory doesn't exist or can't be read - this is fine
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract command name from filename (strip .md extension only)
|
|
*/
|
|
export function getCommandNameFromFile(filename: string): string {
|
|
if (filename.toLowerCase().endsWith(".md")) {
|
|
return filename.slice(0, -3)
|
|
}
|
|
return filename
|
|
}
|
|
|
|
/**
|
|
* Check if a file is a markdown file
|
|
*/
|
|
export function isMarkdownFile(filename: string): boolean {
|
|
return filename.toLowerCase().endsWith(".md")
|
|
}
|