mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-09 22:31:08 +00:00
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:
parent
61aabe715c
commit
63f6fecb1a
3 changed files with 663 additions and 80 deletions
13
.changeset/symlink-commands.md
Normal file
13
.changeset/symlink-commands.md
Normal 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
|
||||
435
src/services/command/__tests__/symlink-commands.spec.ts
Normal file
435
src/services/command/__tests__/symlink-commands.spec.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue