Skip to content

Commit 7c16f25

Browse files
committed
feat: add support for symbolic links in .roo/commands directory
- Modified scanCommandDirectory to check for both regular files and symbolic links - Added comprehensive test coverage for symbolic link functionality - Ensures symbolic links to markdown files are properly loaded as commands Fixes #7282
1 parent 6fd261d commit 7c16f25

File tree

2 files changed

+217
-1
lines changed

2 files changed

+217
-1
lines changed
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import { describe, it, expect, beforeEach, vi } from "vitest"
2+
import fs from "fs/promises"
3+
import * as path from "path"
4+
import { getCommands, getCommand } from "../commands"
5+
6+
// Mock fs and path modules
7+
vi.mock("fs/promises")
8+
vi.mock("../../roo-config", () => ({
9+
getGlobalRooDirectory: vi.fn(() => "/mock/global"),
10+
getProjectRooDirectoryForCwd: vi.fn(() => "/mock/project"),
11+
}))
12+
13+
const mockFs = vi.mocked(fs)
14+
15+
describe("Symbolic link support for commands", () => {
16+
beforeEach(() => {
17+
vi.clearAllMocks()
18+
})
19+
20+
describe("getCommands with symbolic links", () => {
21+
it("should load commands from symbolic links", async () => {
22+
const setupContent = `---
23+
description: Sets up the development environment
24+
---
25+
26+
# Setup Command
27+
28+
Setup instructions.`
29+
30+
const deployContent = `---
31+
description: Deploys the application
32+
---
33+
34+
# Deploy Command
35+
36+
Deploy instructions.`
37+
38+
mockFs.stat = vi.fn().mockResolvedValue({ isDirectory: () => true })
39+
mockFs.readdir = vi.fn().mockResolvedValue([
40+
{ name: "setup.md", isFile: () => false, isSymbolicLink: () => true }, // Symbolic link
41+
{ name: "deploy.md", isFile: () => true, isSymbolicLink: () => false }, // Regular file
42+
{ name: "build.md", isFile: () => false, isSymbolicLink: () => true }, // Another symbolic link
43+
])
44+
mockFs.readFile = vi
45+
.fn()
46+
.mockResolvedValueOnce(setupContent)
47+
.mockResolvedValueOnce(deployContent)
48+
.mockResolvedValueOnce("# Build Command\n\nBuild instructions.")
49+
50+
const result = await getCommands("/test/cwd")
51+
52+
expect(result).toHaveLength(3)
53+
expect(result).toEqual(
54+
expect.arrayContaining([
55+
expect.objectContaining({
56+
name: "setup",
57+
description: "Sets up the development environment",
58+
}),
59+
expect.objectContaining({
60+
name: "deploy",
61+
description: "Deploys the application",
62+
}),
63+
expect.objectContaining({
64+
name: "build",
65+
description: undefined,
66+
}),
67+
]),
68+
)
69+
})
70+
71+
it("should handle mix of regular files and symbolic links", async () => {
72+
const testContent = `---
73+
description: Test command
74+
argument-hint: test | debug
75+
---
76+
77+
# Test Command
78+
79+
Test content.`
80+
81+
const linkContent = `---
82+
description: Linked command
83+
---
84+
85+
# Linked Command
86+
87+
This command is accessed via symbolic link.`
88+
89+
mockFs.stat = vi.fn().mockResolvedValue({ isDirectory: () => true })
90+
mockFs.readdir = vi.fn().mockResolvedValue([
91+
{ name: "test.md", isFile: () => true, isSymbolicLink: () => false }, // Regular file
92+
{ name: "linked.md", isFile: () => false, isSymbolicLink: () => true }, // Symbolic link
93+
{ name: "not-markdown.txt", isFile: () => true, isSymbolicLink: () => false }, // Should be ignored
94+
{ name: "symlink.txt", isFile: () => false, isSymbolicLink: () => true }, // Non-markdown symlink, should be ignored
95+
])
96+
mockFs.readFile = vi.fn().mockResolvedValueOnce(testContent).mockResolvedValueOnce(linkContent)
97+
98+
const result = await getCommands("/test/cwd")
99+
100+
expect(result).toHaveLength(2)
101+
expect(result).toEqual(
102+
expect.arrayContaining([
103+
expect.objectContaining({
104+
name: "test",
105+
description: "Test command",
106+
argumentHint: "test | debug",
107+
}),
108+
expect.objectContaining({
109+
name: "linked",
110+
description: "Linked command",
111+
}),
112+
]),
113+
)
114+
})
115+
116+
it("should handle broken symbolic links gracefully", async () => {
117+
const validContent = `# Valid Command
118+
119+
This is a valid command.`
120+
121+
mockFs.stat = vi.fn().mockResolvedValue({ isDirectory: () => true })
122+
mockFs.readdir = vi.fn().mockResolvedValue([
123+
{ name: "valid.md", isFile: () => true, isSymbolicLink: () => false }, // Regular file
124+
{ name: "broken-link.md", isFile: () => false, isSymbolicLink: () => true }, // Broken symbolic link
125+
])
126+
mockFs.readFile = vi
127+
.fn()
128+
.mockResolvedValueOnce(validContent)
129+
.mockRejectedValueOnce(new Error("ENOENT: no such file or directory")) // Broken link
130+
131+
const result = await getCommands("/test/cwd")
132+
133+
// Should only load the valid command, ignoring the broken link
134+
expect(result).toHaveLength(1)
135+
expect(result[0]).toEqual(
136+
expect.objectContaining({
137+
name: "valid",
138+
}),
139+
)
140+
})
141+
142+
it("should prioritize project symbolic links over global commands", async () => {
143+
const projectSymlinkContent = `---
144+
description: Project symbolic link command
145+
---
146+
147+
# Project Symlink
148+
149+
Project-specific command via symlink.`
150+
151+
const globalContent = `---
152+
description: Global command
153+
---
154+
155+
# Global Command
156+
157+
Global command content.`
158+
159+
// Mock both directories
160+
mockFs.stat = vi.fn().mockResolvedValue({ isDirectory: () => true })
161+
162+
// First call for global directory scan, second for project directory scan
163+
mockFs.readdir = vi
164+
.fn()
165+
.mockResolvedValueOnce([
166+
{ name: "override.md", isFile: () => true, isSymbolicLink: () => false }, // Global regular file
167+
])
168+
.mockResolvedValueOnce([
169+
{ name: "override.md", isFile: () => false, isSymbolicLink: () => true }, // Project symlink
170+
])
171+
172+
// First read is for global file, second is for project symlink
173+
mockFs.readFile = vi.fn().mockResolvedValueOnce(globalContent).mockResolvedValueOnce(projectSymlinkContent)
174+
175+
const result = await getCommands("/test/cwd")
176+
177+
// Project symbolic link should override global command
178+
expect(result).toHaveLength(1)
179+
expect(result[0]).toEqual(
180+
expect.objectContaining({
181+
name: "override",
182+
description: "Project symbolic link command",
183+
source: "project",
184+
}),
185+
)
186+
})
187+
})
188+
189+
describe("getCommand with symbolic links", () => {
190+
it("should load a command from a symbolic link", async () => {
191+
const commandContent = `---
192+
description: Command accessed via symbolic link
193+
argument-hint: option1 | option2
194+
---
195+
196+
# Symlinked Command
197+
198+
This command is loaded from a symbolic link.`
199+
200+
mockFs.stat = vi.fn().mockResolvedValue({ isDirectory: () => true })
201+
mockFs.readFile = vi.fn().mockResolvedValue(commandContent)
202+
203+
const result = await getCommand("/test/cwd", "symlinked")
204+
205+
expect(result).toEqual({
206+
name: "symlinked",
207+
content: "# Symlinked Command\n\nThis command is loaded from a symbolic link.",
208+
source: "project",
209+
filePath: path.join("/mock/project", "commands", "symlinked.md"),
210+
description: "Command accessed via symbolic link",
211+
argumentHint: "option1 | option2",
212+
})
213+
})
214+
})
215+
})

src/services/command/commands.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,8 @@ async function scanCommandDirectory(
136136
const entries = await fs.readdir(dirPath, { withFileTypes: true })
137137

138138
for (const entry of entries) {
139-
if (entry.isFile() && isMarkdownFile(entry.name)) {
139+
// Check for both regular files and symbolic links
140+
if ((entry.isFile() || entry.isSymbolicLink()) && isMarkdownFile(entry.name)) {
140141
const filePath = path.join(dirPath, entry.name)
141142
const commandName = getCommandNameFromFile(entry.name)
142143

0 commit comments

Comments
 (0)