import * as fs from "node:fs"; import * as path from "node:path"; import { COMMANDS_SUBDIR, SKILL_DIRS } from "./constants.js"; import { dirExists, readFileSafe } from "./fs-utils.js"; import type { ClaudeCommand, ClaudeSkill } from "./types.js"; /** * Recursively discover all `.md` files under the commands directory, * computing command names that follow Claude CLI's convention. * * Root-level files: basename without .md -> "test" * Nested files: path segments joined by ":" -> "xyz:test1" */ export function discoverCommands(cwd: string): ClaudeCommand[] { const commandsDir = path.join(cwd, COMMANDS_SUBDIR); if (!dirExists(commandsDir)) return []; const commands: ClaudeCommand[] = []; function walk(dir: string, prefix: string): void { let entries: fs.Dirent[]; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } entries.sort((a, b) => a.name.localeCompare(b.name)); for (const entry of entries) { if (entry.isDirectory()) { const nestedPrefix = prefix ? `${prefix}:${entry.name}` : entry.name; walk(path.join(dir, entry.name), nestedPrefix); } else if (entry.isFile() && entry.name.endsWith(".md")) { const baseName = entry.name.slice(0, -3); const commandName = prefix ? `${prefix}:${baseName}` : baseName; const dirPart = prefix ? prefix.replace(/:/g, path.sep) : ""; const relativePath = dirPart ? path.join(dirPart, entry.name) : entry.name; const absolutePath = path.join(dir, entry.name); const content = readFileSafe(absolutePath); if (content === null) continue; const description = extractDescription(content, commandName); commands.push({ name: commandName, relativePath, description }); } } } walk(commandsDir, ""); return commands; } /** * Discover Agent Skills directories in the project. * * Scans `.claude/skills/`, `.agents/skills/`, and `.pi/skills/` * for directories containing `SKILL.md`. First match wins on name collisions. */ export function discoverSkills(cwd: string): ClaudeSkill[] { const skills: ClaudeSkill[] = []; const seen = new Set(); for (const skillDir of SKILL_DIRS) { const fullSkillDir = path.join(cwd, skillDir); if (!dirExists(fullSkillDir)) continue; try { const entries = fs.readdirSync(fullSkillDir, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory()) continue; if (seen.has(entry.name)) continue; const skillMdPath = path.join(fullSkillDir, entry.name, "SKILL.md"); const content = readFileSafe(skillMdPath); if (content === null) continue; seen.add(entry.name); const description = extractDescription(content, entry.name); skills.push({ name: entry.name, skillMdPath, dirPath: path.join(fullSkillDir, entry.name), description, }); } } catch { // Skip unreadable directories } } skills.sort((a, b) => a.name.localeCompare(b.name)); return skills; } /** * Reconstruct a command's absolute file path from its name and cwd. */ export function resolveCommandPath(cwd: string, commandName: string): string { const parts = commandName.split(":"); const fileName = parts.pop()! + ".md"; return path.join(cwd, COMMANDS_SUBDIR, ...parts, fileName); } /** * Extract a description for a command or skill from its content. * * Priority: frontmatter description > first heading > first content line > fallback */ export function extractDescription(content: string, fallbackName: string): string { const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); if (fmMatch) { const frontmatter = fmMatch[1]; const descMatch = frontmatter.match(/^description:\s*["']?(.+?)["']?\s*$/m); if (descMatch) { return descMatch[1].trim(); } } const body = content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n*/, "").trim(); const headingMatch = body.match(/^#\s+(.+)$/m); if (headingMatch) { return headingMatch[1].trim(); } const lines = body.split("\n"); for (const line of lines) { const trimmed = line.trim(); if (trimmed && !trimmed.startsWith("