/** * @mandujs/skills - Claude Code Plugin for Mandu Framework * * Programmatic API for installing and managing Mandu skills * in Claude Code projects. */ import { readFile, writeFile, mkdir, access, copyFile } from "fs/promises"; import { join, dirname, resolve } from "path"; import { fileURLToPath } from "url"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); /** Root of the @mandujs/skills package */ const PACKAGE_ROOT = resolve(__dirname, ".."); /** * Available skill IDs. * * Two families: * - Task-shaped skills (domain knowledge — "how to make an API", "what is * an Island", etc.) * - `mandu-mcp-*` workflow skills (MCP tool orchestration — "when editing, * run ate_auto_pipeline + guard_check + doctor in parallel"). See #234. * * The MCP workflow skills are loaded alongside the task-shaped ones so * agents see both "what" (domain) and "how to invoke" (orchestration). * `mandu-mcp-index` is the always-on router that points to the other * `mandu-mcp-*` skills. */ export const SKILL_IDS = [ // Task-shaped (domain knowledge) "mandu-create-feature", "mandu-create-api", "mandu-debug", "mandu-explain", "mandu-guard-guide", "mandu-lint", "mandu-deploy", "mandu-slot", "mandu-fs-routes", "mandu-hydration", // Workflow-shaped (MCP tool orchestration — #234) "mandu-mcp-index", "mandu-mcp-orient", "mandu-mcp-create-flow", "mandu-mcp-verify", "mandu-mcp-safe-change", "mandu-mcp-deploy", ] as const; export type SkillId = (typeof SKILL_IDS)[number]; export interface InstallOptions { /** Target project directory (defaults to cwd) */ targetDir?: string; /** Overwrite existing files */ force?: boolean; /** Only install specific skills */ skills?: SkillId[]; /** Skip MCP config setup */ skipMcp?: boolean; /** Skip Claude settings setup */ skipSettings?: boolean; /** Dry run - report what would be done without writing files */ dryRun?: boolean; } export interface InstallResult { installed: string[]; skipped: string[]; errors: string[]; } async function fileExists(filePath: string): Promise { try { await access(filePath); return true; } catch { return false; } } /** * Deep-merge two JSON objects. Arrays are replaced, not merged. */ function deepMerge(target: Record, source: Record): Record { const result = { ...target }; for (const key of Object.keys(source)) { const srcVal = source[key]; const tgtVal = target[key]; if ( srcVal !== null && typeof srcVal === "object" && !Array.isArray(srcVal) && tgtVal !== null && typeof tgtVal === "object" && !Array.isArray(tgtVal) ) { result[key] = deepMerge( tgtVal as Record, srcVal as Record ); } else { result[key] = srcVal; } } return result; } /** * Install Mandu skills into a project. * * Copies skill SKILL.md files from `skills//SKILL.md` to * `/.claude/skills//SKILL.md`. * * Directory layout follows the Claude Code skills spec — each skill lives * in its own subdirectory with a `SKILL.md` manifest inside, so that * auxiliary assets (scripts, examples, resources) can sit alongside it. * See: https://docs.claude.com/en/docs/claude-code/skills * * (Closes #197 — prior releases wrote flat `.md` files, which Claude * Code silently skipped because the directory layout did not match.) */ export async function installSkills(options: InstallOptions = {}): Promise { const targetDir = options.targetDir || process.cwd(); const force = options.force ?? false; const skillIds = options.skills ?? [...SKILL_IDS]; const dryRun = options.dryRun ?? false; const result: InstallResult = { installed: [], skipped: [], errors: [], }; // 1. Install skill files to .claude/skills//SKILL.md const skillsDir = join(targetDir, ".claude", "skills"); if (!dryRun) { await mkdir(skillsDir, { recursive: true }); } for (const skillId of skillIds) { const srcPath = join(PACKAGE_ROOT, "skills", skillId, "SKILL.md"); const skillSubdir = join(skillsDir, skillId); const destPath = join(skillSubdir, "SKILL.md"); const relLabel = `skills/${skillId}/SKILL.md`; try { if (!force && (await fileExists(destPath))) { result.skipped.push(`${relLabel} (exists)`); continue; } if (dryRun) { result.installed.push(`${relLabel} (dry-run)`); continue; } // Ensure the skill's subdirectory exists before copying. `mkdir` // with `recursive: true` is idempotent — safe to re-run across // skills even when the parent already exists. await mkdir(skillSubdir, { recursive: true }); await copyFile(srcPath, destPath); result.installed.push(relLabel); } catch (err) { result.errors.push(`${relLabel}: ${err instanceof Error ? err.message : String(err)}`); } } // 2. Setup .mcp.json (merge, don't overwrite) if (!options.skipMcp) { const mcpPath = join(targetDir, ".mcp.json"); try { const templateContent = await readFile( join(PACKAGE_ROOT, "templates", ".mcp.json"), "utf-8" ); const templateConfig = JSON.parse(templateContent); if (await fileExists(mcpPath)) { if (force) { if (!dryRun) { const existing = JSON.parse(await readFile(mcpPath, "utf-8")); const merged = deepMerge(existing, templateConfig); await writeFile(mcpPath, JSON.stringify(merged, null, 2) + "\n"); } result.installed.push(".mcp.json (merged)"); } else { // Check if mandu server already configured const existing = JSON.parse(await readFile(mcpPath, "utf-8")); if (existing.mcpServers?.mandu) { result.skipped.push(".mcp.json (mandu server exists)"); } else { if (!dryRun) { const merged = deepMerge(existing, templateConfig); await writeFile(mcpPath, JSON.stringify(merged, null, 2) + "\n"); } result.installed.push(".mcp.json (mandu server added)"); } } } else { if (!dryRun) { await writeFile(mcpPath, JSON.stringify(templateConfig, null, 2) + "\n"); } result.installed.push(".mcp.json (created)"); } } catch (err) { result.errors.push(`.mcp.json: ${err instanceof Error ? err.message : String(err)}`); } } // 3. Setup .claude/settings.json (merge hooks and permissions) if (!options.skipSettings) { const settingsPath = join(targetDir, ".claude", "settings.json"); try { const templateContent = await readFile( join(PACKAGE_ROOT, "templates", ".claude", "settings.json"), "utf-8" ); const templateSettings = JSON.parse(templateContent); if (!dryRun) { await mkdir(join(targetDir, ".claude"), { recursive: true }); } if (await fileExists(settingsPath)) { if (force) { if (!dryRun) { const existing = JSON.parse(await readFile(settingsPath, "utf-8")); const merged = deepMerge(existing, templateSettings); await writeFile(settingsPath, JSON.stringify(merged, null, 2) + "\n"); } result.installed.push(".claude/settings.json (merged)"); } else { result.skipped.push(".claude/settings.json (exists, use --force to merge)"); } } else { if (!dryRun) { await writeFile(settingsPath, JSON.stringify(templateSettings, null, 2) + "\n"); } result.installed.push(".claude/settings.json (created)"); } } catch (err) { result.errors.push(`.claude/settings.json: ${err instanceof Error ? err.message : String(err)}`); } } return result; } /** * Get the path to a skill SKILL.md file in the package */ export function getSkillPath(skillId: SkillId): string { return join(PACKAGE_ROOT, "skills", skillId, "SKILL.md"); } /** * Get the path to a template file in the package */ export function getTemplatePath(relativePath: string): string { return join(PACKAGE_ROOT, "templates", relativePath); } /** * List all available skill IDs */ export function listSkillIds(): readonly SkillId[] { return SKILL_IDS; } // ============================================================================ // Per-project skills generator (Phase 14.1) // ============================================================================ export { generateSkillsForProject, analyzeProject, analyzeManifest, analyzeGuard, analyzeStack, buildGlossarySkill, buildConventionsSkill, buildWorkflowSkill, listGeneratedSkills, } from "./generator/index.js"; export type { GenerateSkillsOptions, GenerateSkillsResult, GeneratedSkillFile, ProjectAnalysis, ManifestAnalysis, GuardAnalysis, StackAnalysis, } from "./generator/index.js";