Skip to content

Commit 1c55385

Browse files
authored
feat(command-loader): add recursive subdirectory scanning for commands (#378)
Support organizing commands in subdirectories with colon-separated naming (e.g., myproject/deploy.md becomes myproject:deploy). - Recursively traverse subdirectories and load all .md command files - Prefix nested command names with directory path (colon-separated) - Protect against circular symlinks via visited path tracking - Skip hidden directories (consistent with other loaders) - Graceful error handling with logging for debugging
1 parent f3db564 commit 1c55385

File tree

1 file changed

+41
-5
lines changed
  • src/features/claude-code-command-loader

1 file changed

+41
-5
lines changed

src/features/claude-code-command-loader/loader.ts

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,59 @@
1-
import { existsSync, readdirSync, readFileSync } from "fs"
1+
import { existsSync, readdirSync, readFileSync, realpathSync, type Dirent } from "fs"
22
import { join, basename } from "path"
33
import { parseFrontmatter } from "../../shared/frontmatter"
44
import { sanitizeModelField } from "../../shared/model-sanitizer"
55
import { isMarkdownFile } from "../../shared/file-utils"
66
import { getClaudeConfigDir } from "../../shared"
7+
import { log } from "../../shared/logger"
78
import type { CommandScope, CommandDefinition, CommandFrontmatter, LoadedCommand } from "./types"
89

9-
function loadCommandsFromDir(commandsDir: string, scope: CommandScope): LoadedCommand[] {
10+
function loadCommandsFromDir(
11+
commandsDir: string,
12+
scope: CommandScope,
13+
visited: Set<string> = new Set(),
14+
prefix: string = ""
15+
): LoadedCommand[] {
1016
if (!existsSync(commandsDir)) {
1117
return []
1218
}
1319

14-
const entries = readdirSync(commandsDir, { withFileTypes: true })
20+
let realPath: string
21+
try {
22+
realPath = realpathSync(commandsDir)
23+
} catch (error) {
24+
log(`Failed to resolve command directory: ${commandsDir}`, error)
25+
return []
26+
}
27+
28+
if (visited.has(realPath)) {
29+
return []
30+
}
31+
visited.add(realPath)
32+
33+
let entries: Dirent[]
34+
try {
35+
entries = readdirSync(commandsDir, { withFileTypes: true })
36+
} catch (error) {
37+
log(`Failed to read command directory: ${commandsDir}`, error)
38+
return []
39+
}
40+
1541
const commands: LoadedCommand[] = []
1642

1743
for (const entry of entries) {
44+
if (entry.isDirectory()) {
45+
if (entry.name.startsWith(".")) continue
46+
const subDirPath = join(commandsDir, entry.name)
47+
const subPrefix = prefix ? `${prefix}:${entry.name}` : entry.name
48+
commands.push(...loadCommandsFromDir(subDirPath, scope, visited, subPrefix))
49+
continue
50+
}
51+
1852
if (!isMarkdownFile(entry)) continue
1953

2054
const commandPath = join(commandsDir, entry.name)
21-
const commandName = basename(entry.name, ".md")
55+
const baseCommandName = basename(entry.name, ".md")
56+
const commandName = prefix ? `${prefix}:${baseCommandName}` : baseCommandName
2257

2358
try {
2459
const content = readFileSync(commandPath, "utf-8")
@@ -51,7 +86,8 @@ $ARGUMENTS
5186
definition,
5287
scope,
5388
})
54-
} catch {
89+
} catch (error) {
90+
log(`Failed to parse command: ${commandPath}`, error)
5591
continue
5692
}
5793
}

0 commit comments

Comments
 (0)