/ utils / plugins / walkPluginMarkdown.ts
walkPluginMarkdown.ts
 1  import { join } from 'path'
 2  import { logForDebugging } from '../debug.js'
 3  import { getFsImplementation } from '../fsOperations.js'
 4  
 5  const SKILL_MD_RE = /^skill\.md$/i
 6  
 7  /**
 8   * Recursively walk a plugin directory, invoking onFile for each .md file.
 9   *
10   * The namespace array tracks the subdirectory path relative to the root
11   * (e.g., ['foo', 'bar'] for root/foo/bar/file.md). Callers that don't need
12   * namespacing can ignore the second argument.
13   *
14   * When stopAtSkillDir is true and a directory contains SKILL.md, onFile is
15   * called for all .md files in that directory but subdirectories are not
16   * scanned — skill directories are leaf containers.
17   *
18   * Readdir errors are swallowed with a debug log so one bad directory doesn't
19   * abort a plugin load.
20   */
21  export async function walkPluginMarkdown(
22    rootDir: string,
23    onFile: (fullPath: string, namespace: string[]) => Promise<void>,
24    opts: { stopAtSkillDir?: boolean; logLabel?: string } = {},
25  ): Promise<void> {
26    const fs = getFsImplementation()
27    const label = opts.logLabel ?? 'plugin'
28  
29    async function scan(dirPath: string, namespace: string[]): Promise<void> {
30      try {
31        const entries = await fs.readdir(dirPath)
32  
33        if (
34          opts.stopAtSkillDir &&
35          entries.some(e => e.isFile() && SKILL_MD_RE.test(e.name))
36        ) {
37          // Skill directory: collect .md files here, don't recurse.
38          await Promise.all(
39            entries.map(entry =>
40              entry.isFile() && entry.name.toLowerCase().endsWith('.md')
41                ? onFile(join(dirPath, entry.name), namespace)
42                : undefined,
43            ),
44          )
45          return
46        }
47  
48        await Promise.all(
49          entries.map(entry => {
50            const fullPath = join(dirPath, entry.name)
51            if (entry.isDirectory()) {
52              return scan(fullPath, [...namespace, entry.name])
53            }
54            if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) {
55              return onFile(fullPath, namespace)
56            }
57            return undefined
58          }),
59        )
60      } catch (error) {
61        logForDebugging(
62          `Failed to scan ${label} directory ${dirPath}: ${error}`,
63          { level: 'error' },
64        )
65      }
66    }
67  
68    await scan(rootDir, [])
69  }