Просмотр исходного кода

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

Co-authored-by: Roo Code <[email protected]>
Matt Rubens 4 недель назад
Родитель
Сommit
63f6fecb1a

+ 13 - 0
.changeset/symlink-commands.md

@@ -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

+ 435 - 0
src/services/command/__tests__/symlink-commands.spec.ts

@@ -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")
+		})
+	})
+})

+ 215 - 80
src/services/command/commands.ts

@@ -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)
 
-		try {
-			const content = await fs.readFile(filePath, "utf-8")
+		// Check if this is a regular file first
+		let resolvedPath = filePath
+		let content: string | undefined
 
-			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()
+		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
 			}
+		}
 
-			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
 	}
 }