(cwd: string)
| 67 | |
| 68 | /** Inspect both discovery dirs and return the merged command map. Project wins on collisions. */ |
| 69 | export async function loadCustomCommands(cwd: string): Promise<Map<string, CustomCommandSpec>> { |
| 70 | const map = new Map<string, CustomCommandSpec>(); |
| 71 | // Claude Code commands first (lowest precedence) so a user's own QodeX command of |
| 72 | // the same name wins (later entries overwrite earlier ones in the map). |
| 73 | const claudeDirs = (await claudeCodeCommandDirs(cwd)).map(d => ({ dir: d.dir, origin: 'plugin' as const })); |
| 74 | const dirs: Array<{ dir: string; origin: 'project' | 'user' | 'plugin' }> = [ |
| 75 | ...claudeDirs, |
| 76 | { dir: path.join(os.homedir(), '.qodex', 'commands'), origin: 'user' }, |
| 77 | { dir: path.join(cwd, '.qodex', 'commands'), origin: 'project' }, |
| 78 | ]; |
| 79 | for (const { dir, origin } of dirs) { |
| 80 | let entries; |
| 81 | try { |
| 82 | entries = await fs.readdir(dir, { withFileTypes: true }); |
| 83 | } catch { |
| 84 | continue; |
| 85 | } |
| 86 | for (const ent of entries) { |
| 87 | if (!ent.isFile() || !ent.name.endsWith('.md')) continue; |
| 88 | const name = ent.name.slice(0, -3); |
| 89 | if (!/^[a-zA-Z][\w-]*$/.test(name)) { |
| 90 | logger.debug('Skipping custom command with invalid name', { file: ent.name }); |
| 91 | continue; |
| 92 | } |
| 93 | const filePath = path.join(dir, ent.name); |
| 94 | try { |
| 95 | const raw = await fs.readFile(filePath, 'utf-8'); |
| 96 | const spec = parseSpec(raw, name, filePath, origin); |
| 97 | map.set(name, spec); |
| 98 | } catch (e: any) { |
| 99 | logger.warn('Failed to load custom command', { file: filePath, err: e.message }); |
| 100 | } |
| 101 | } |
| 102 | } |
| 103 | return map; |
| 104 | } |
| 105 | |
| 106 | /** Parse a Markdown file into a CustomCommandSpec. */ |
| 107 | export function parseSpec( |
no test coverage detected