feat: add symlink support for slash commands in .roo/commands folder (#9838)

Co-authored-by: Roo Code <roomote@roocode.com>
This commit is contained in:
Matt Rubens 2025-12-04 15:41:10 -05:00 committed by GitHub
parent 61aabe715c
commit 63f6fecb1a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 663 additions and 80 deletions

View file

@ -0,0 +1,13 @@
---
"roo-cline": minor
---
Add symlink support for slash commands in .roo/commands folder
This change adds support for symlinked slash commands, similar to how .roo/rules already handles symlinks:
- Symlinked command files are now resolved and their content is read from the target
- Symlinked directories are recursively scanned for .md command files
- Command names are derived from the symlink name, not the target file name
- Cyclic symlink protection (MAX_DEPTH = 5) prevents infinite loops
- Broken symlinks are handled gracefully and silently skipped

View file

@ -0,0 +1,435 @@
import fs from "fs/promises"
import * as path from "path"
import { getCommand, getCommands } from "../commands"
// Mock fs and path modules
vi.mock("fs/promises")
vi.mock("../roo-config", () => ({
getGlobalRooDirectory: vi.fn(() => "/mock/global/.roo"),
getProjectRooDirectoryForCwd: vi.fn(() => "/mock/project/.roo"),
}))
vi.mock("../built-in-commands", () => ({
getBuiltInCommands: vi.fn(() => Promise.resolve([])),
getBuiltInCommand: vi.fn(() => Promise.resolve(undefined)),
getBuiltInCommandNames: vi.fn(() => Promise.resolve([])),
}))
const mockFs = vi.mocked(fs)
describe("Symlink command support", () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe("getCommand with symlinks", () => {
it("should load command from a symlinked file", async () => {
const commandContent = `---
description: Symlinked command
---
# Symlinked Command Content`
// Mock stat to return directory for commands dir
mockFs.stat = vi.fn().mockResolvedValue({ isDirectory: () => true })
// Mock readFile to fail for direct path
mockFs.readFile = vi.fn().mockRejectedValue(new Error("File not found"))
// Mock lstat to indicate it's a symlink
mockFs.lstat = vi.fn().mockResolvedValue({
isSymbolicLink: () => true,
})
// Mock readlink to return symlink target
mockFs.readlink = vi.fn().mockResolvedValue("../shared/symlinked-command.md")
// Mock stat to return file for the resolved target
mockFs.stat = vi.fn().mockImplementation((filePath: string) => {
if (filePath.includes("commands")) {
return Promise.resolve({ isDirectory: () => true })
}
return Promise.resolve({ isFile: () => true })
})
// Mock readFile to succeed for resolved path
mockFs.readFile = vi.fn().mockImplementation((filePath: string) => {
if (filePath.toString().includes("symlinked-command.md")) {
return Promise.resolve(commandContent)
}
return Promise.reject(new Error("File not found"))
})
const result = await getCommand("/test/cwd", "setup")
expect(result?.content).toContain("Symlinked Command Content")
expect(result?.description).toBe("Symlinked command")
})
it("should use symlink name for command name, not target name", async () => {
const commandContent = `# Target Command`
// Setup mocks for a symlink scenario where symlink name differs from target
mockFs.stat = vi.fn().mockResolvedValue({ isDirectory: () => true })
mockFs.readFile = vi.fn().mockResolvedValue(commandContent)
const result = await getCommand("/test/cwd", "my-alias")
// Command name should be from the requested name (symlink name)
expect(result?.name).toBe("my-alias")
})
})
describe("getCommands with symlinks", () => {
it("should discover commands from symlinked files", async () => {
const regularContent = `# Regular Command`
const symlinkedContent = `# Symlinked Command`
mockFs.stat = vi.fn().mockResolvedValue({ isDirectory: () => true })
// Mock readdir to return both regular file and symlink
mockFs.readdir = vi.fn().mockResolvedValue([
{
name: "regular.md",
isFile: () => true,
isSymbolicLink: () => false,
parentPath: "/mock/project/.roo/commands",
},
{
name: "symlink.md",
isFile: () => false,
isSymbolicLink: () => true,
parentPath: "/mock/project/.roo/commands",
},
])
// Mock readlink for symlink resolution
mockFs.readlink = vi.fn().mockResolvedValue("../shared/actual-command.md")
// Mock lstat for symlink target type checking (lstat doesn't follow symlinks)
mockFs.lstat = vi.fn().mockImplementation((filePath: string) => {
const normalizedPath = filePath.replace(/\\/g, "/")
if (normalizedPath.includes("commands")) {
return Promise.resolve({ isDirectory: () => true })
}
// Return file stats for the resolved symlink target
return Promise.resolve({
isFile: () => true,
isDirectory: () => false,
isSymbolicLink: () => false,
})
})
// Mock stat for directory checking
mockFs.stat = vi.fn().mockImplementation((filePath: string) => {
const normalizedPath = filePath.replace(/\\/g, "/")
if (normalizedPath.includes("commands")) {
return Promise.resolve({ isDirectory: () => true })
}
return Promise.resolve({
isFile: () => true,
isDirectory: () => false,
isSymbolicLink: () => false,
})
})
// Mock readFile for content
mockFs.readFile = vi.fn().mockImplementation((filePath: string) => {
const normalizedPath = filePath.toString().replace(/\\/g, "/")
if (normalizedPath.includes("regular.md")) {
return Promise.resolve(regularContent)
}
if (normalizedPath.includes("actual-command.md")) {
return Promise.resolve(symlinkedContent)
}
return Promise.reject(new Error("File not found"))
})
const result = await getCommands("/test/cwd")
expect(result.length).toBe(2)
const regularCmd = result.find((c) => c.name === "regular")
const symlinkCmd = result.find((c) => c.name === "symlink")
expect(regularCmd?.content).toContain("Regular Command")
expect(symlinkCmd?.content).toContain("Symlinked Command")
})
it.skipIf(process.platform === "win32")("should discover commands from symlinked directories", async () => {
const nestedContent = `# Nested Command from Symlinked Dir`
// Mock lstat for symlink target type checking (lstat doesn't follow symlinks)
mockFs.lstat = vi.fn().mockImplementation((filePath: string) => {
const normalizedPath = filePath.replace(/\\/g, "/")
if (normalizedPath.includes("commands") || normalizedPath.includes("shared-commands")) {
return Promise.resolve({
isDirectory: () => true,
isFile: () => false,
isSymbolicLink: () => false,
})
}
return Promise.resolve({
isFile: () => true,
isDirectory: () => false,
isSymbolicLink: () => false,
})
})
// First stat check for directory
mockFs.stat = vi.fn().mockImplementation((filePath: string) => {
const normalizedPath = filePath.replace(/\\/g, "/")
if (normalizedPath.includes("commands") || normalizedPath.includes("shared-commands")) {
return Promise.resolve({
isDirectory: () => true,
isFile: () => false,
isSymbolicLink: () => false,
})
}
return Promise.resolve({
isFile: () => true,
isDirectory: () => false,
isSymbolicLink: () => false,
})
})
// First readdir returns a symlink to directory
mockFs.readdir = vi.fn().mockImplementation((dirPath: string) => {
const normalizedPath = dirPath.replace(/\\/g, "/")
if (normalizedPath.includes("commands") && !normalizedPath.includes("shared")) {
return Promise.resolve([
{
name: "linked-dir",
isFile: () => false,
isSymbolicLink: () => true,
parentPath: "/mock/project/.roo/commands",
},
])
}
// Return files from the resolved symlink directory
return Promise.resolve([
{
name: "nested.md",
isFile: () => true,
isSymbolicLink: () => false,
parentPath: normalizedPath,
},
])
})
// Mock readlink for symlink to directory
mockFs.readlink = vi.fn().mockResolvedValue("/mock/shared-commands")
// Mock readFile for content
mockFs.readFile = vi.fn().mockImplementation((filePath: string) => {
const normalizedPath = filePath.toString().replace(/\\/g, "/")
if (normalizedPath.includes("nested.md")) {
return Promise.resolve(nestedContent)
}
return Promise.reject(new Error("File not found"))
})
const result = await getCommands("/test/cwd")
// Find a command that was discovered from the symlinked directory
const nestedCmd = result.find((c) => c.name === "nested")
expect(nestedCmd).toBeDefined()
expect(nestedCmd?.content).toContain("Nested Command from Symlinked Dir")
})
// Note: Nested symlinks (symlink -> symlink -> file) are automatically followed by fs.stat,
// so they work transparently. The MAX_DEPTH protection prevents infinite loops.
it("should handle cyclic symlinks gracefully (MAX_DEPTH protection)", async () => {
// Create a cyclic symlink scenario
// Mock lstat to return symlink for all targets (creating infinite loop)
mockFs.lstat = vi.fn().mockImplementation((filePath: string) => {
const normalizedPath = filePath.replace(/\\/g, "/")
if (normalizedPath.includes("commands")) {
return Promise.resolve({
isDirectory: () => true,
isFile: () => false,
isSymbolicLink: () => false,
})
}
// All symlink targets are symlinks (infinite loop)
return Promise.resolve({
isFile: () => false,
isDirectory: () => false,
isSymbolicLink: () => true,
})
})
mockFs.stat = vi.fn().mockImplementation((filePath: string) => {
const normalizedPath = filePath.replace(/\\/g, "/")
if (normalizedPath.includes("commands")) {
return Promise.resolve({
isDirectory: () => true,
isFile: () => false,
isSymbolicLink: () => false,
})
}
// All symlink targets are symlinks (infinite loop)
return Promise.resolve({
isFile: () => false,
isDirectory: () => false,
isSymbolicLink: () => true,
})
})
mockFs.readdir = vi.fn().mockResolvedValue([
{
name: "cyclic.md",
isFile: () => false,
isSymbolicLink: () => true,
parentPath: "/mock/project/.roo/commands",
},
])
// Cyclic symlink - always points to another symlink
mockFs.readlink = vi.fn().mockResolvedValue("../another-link.md")
mockFs.readFile = vi.fn().mockRejectedValue(new Error("File not found"))
// Should not throw, just gracefully handle the cyclic symlink
const result = await getCommands("/test/cwd")
// The cyclic command should not be included (it can't be resolved)
expect(result.find((c) => c.name === "cyclic")).toBeUndefined()
})
it("should handle broken symlinks gracefully", async () => {
const regularContent = `# Regular Command`
// Mock lstat for symlink target type checking
mockFs.lstat = vi.fn().mockImplementation((filePath: string) => {
const normalizedPath = filePath.replace(/\\/g, "/")
if (normalizedPath.includes("commands")) {
return Promise.resolve({
isDirectory: () => true,
isFile: () => false,
isSymbolicLink: () => false,
})
}
if (normalizedPath.includes("nonexistent")) {
return Promise.reject(new Error("ENOENT: no such file or directory"))
}
return Promise.resolve({
isFile: () => true,
isDirectory: () => false,
isSymbolicLink: () => false,
})
})
mockFs.stat = vi.fn().mockImplementation((filePath: string) => {
const normalizedPath = filePath.replace(/\\/g, "/")
if (normalizedPath.includes("commands")) {
return Promise.resolve({
isDirectory: () => true,
isFile: () => false,
isSymbolicLink: () => false,
})
}
if (normalizedPath.includes("nonexistent")) {
return Promise.reject(new Error("ENOENT: no such file or directory"))
}
return Promise.resolve({
isFile: () => true,
isDirectory: () => false,
isSymbolicLink: () => false,
})
})
mockFs.readdir = vi.fn().mockResolvedValue([
{
name: "regular.md",
isFile: () => true,
isSymbolicLink: () => false,
parentPath: "/mock/project/.roo/commands",
},
{
name: "broken.md",
isFile: () => false,
isSymbolicLink: () => true,
parentPath: "/mock/project/.roo/commands",
},
])
// Broken symlink points to nonexistent file
mockFs.readlink = vi.fn().mockResolvedValue("../nonexistent.md")
mockFs.readFile = vi.fn().mockImplementation((filePath: string) => {
const normalizedPath = filePath.toString().replace(/\\/g, "/")
if (normalizedPath.includes("regular.md")) {
return Promise.resolve(regularContent)
}
return Promise.reject(new Error("ENOENT: no such file or directory"))
})
// Should not throw, just skip the broken symlink
const result = await getCommands("/test/cwd")
expect(result.length).toBe(1)
expect(result[0].name).toBe("regular")
})
it("should use symlink name for command name when symlink points to file", async () => {
const targetContent = `# Target File Content`
// Mock lstat for symlink target type checking (lstat doesn't follow symlinks)
mockFs.lstat = vi.fn().mockImplementation((filePath: string) => {
const normalizedPath = filePath.replace(/\\/g, "/")
if (normalizedPath.includes("commands")) {
return Promise.resolve({
isDirectory: () => true,
isFile: () => false,
isSymbolicLink: () => false,
})
}
// Return file stats for the resolved symlink target
return Promise.resolve({
isFile: () => true,
isDirectory: () => false,
isSymbolicLink: () => false,
})
})
mockFs.stat = vi.fn().mockImplementation((filePath: string) => {
const normalizedPath = filePath.replace(/\\/g, "/")
if (normalizedPath.includes("commands")) {
return Promise.resolve({
isDirectory: () => true,
isFile: () => false,
isSymbolicLink: () => false,
})
}
return Promise.resolve({
isFile: () => true,
isDirectory: () => false,
isSymbolicLink: () => false,
})
})
mockFs.readdir = vi.fn().mockResolvedValue([
{
name: "my-alias.md", // Symlink name
isFile: () => false,
isSymbolicLink: () => true,
parentPath: "/mock/project/.roo/commands",
},
])
// Symlink points to file with different name
mockFs.readlink = vi.fn().mockResolvedValue("../shared/actual-target-name.md")
mockFs.readFile = vi.fn().mockResolvedValue(targetContent)
const result = await getCommands("/test/cwd")
expect(result.length).toBe(1)
// Command name should be from symlink, not target
expect(result[0].name).toBe("my-alias")
expect(result[0].content).toContain("Target File Content")
})
})
})

View file

@ -1,9 +1,15 @@
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
@ -13,6 +19,106 @@ export interface Command {
argumentHint?: 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)
@ -63,7 +169,7 @@ export async function getCommand(cwd: string, name: string): Promise<Command | u
}
/**
* Try to load a specific command from a directory
* Try to load a specific command from a directory (supports symlinks)
*/
async function tryLoadCommand(
dirPath: string,
@ -80,46 +186,65 @@ async function tryLoadCommand(
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 {
const content = await fs.readFile(filePath, "utf-8")
let parsed
let description: string | undefined
let argumentHint: 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
commandContent = parsed.content.trim()
} catch (frontmatterError) {
// If frontmatter parsing fails, treat the entire content as command content
description = undefined
argumentHint = undefined
commandContent = content.trim()
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
}
}
return {
name,
content: commandContent,
source,
filePath,
description,
argumentHint,
}
} catch (error) {
// File doesn't exist or can't be read
if (!content) {
return undefined
}
} catch (error) {
let parsed
let description: string | undefined
let argumentHint: 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
commandContent = parsed.content.trim()
} catch {
// If frontmatter parsing fails, treat the entire content as command content
description = undefined
argumentHint = undefined
commandContent = content.trim()
}
return {
name,
content: commandContent,
source,
filePath: resolvedPath,
description,
argumentHint,
}
} catch {
// Directory doesn't exist or can't be read
return undefined
}
@ -134,7 +259,7 @@ export async function getCommandNames(cwd: string): Promise<string[]> {
}
/**
* Scan a specific command directory
* Scan a specific command directory (supports symlinks)
*/
async function scanCommandDirectory(
dirPath: string,
@ -149,55 +274,65 @@ async function scanCommandDirectory(
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) {
if (entry.isFile() && isMarkdownFile(entry.name)) {
const filePath = path.join(dirPath, entry.name)
const commandName = getCommandNameFromFile(entry.name)
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 commandContent: string
try {
const content = await fs.readFile(filePath, "utf-8")
let parsed
let description: string | undefined
let argumentHint: 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
commandContent = parsed.content.trim()
} catch (frontmatterError) {
// If frontmatter parsing fails, treat the entire content as command content
description = undefined
argumentHint = undefined
commandContent = content.trim()
}
// Project commands override global ones
if (source === "project" || !commands.has(commandName)) {
commands.set(commandName, {
name: commandName,
content: commandContent,
source,
filePath,
description,
argumentHint,
})
}
} catch (error) {
console.warn(`Failed to read command file ${filePath}:`, error)
// 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
commandContent = parsed.content.trim()
} catch {
// If frontmatter parsing fails, treat the entire content as command content
description = undefined
argumentHint = 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,
})
}
} catch (error) {
console.warn(`Failed to read command file ${resolvedPath}:`, error)
}
}
} catch (error) {
} catch {
// Directory doesn't exist or can't be read - this is fine
}
}