import { mkdir, readFile, writeFile } from "node:fs/promises"; import { existsSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { BUGS_REFERENCE, CHANGE_REQUESTS_REFERENCE, FILE_FORMAT_REFERENCE, SKILL_MD_TEMPLATE, SKILL_REMOTE_MD_TEMPLATE, } from "./templates.js"; const MANAGED_HEADER = "\n"; export type AdapterMode = "pointer" | "mirror"; export interface SkillPathsDefinition { skillId: string; paths: string[]; } export interface SkillAdapterDefinition { id: string; mode: AdapterMode; skills: SkillPathsDefinition[]; } export interface SyncAdaptersOptions { agents?: string[]; force?: boolean; dryRun?: boolean; } export type AdapterFileAction = "created" | "updated" | "skipped"; export interface AdapterFileChange { path: string; action: AdapterFileAction; reason?: string; } export interface SyncAdaptersResult { canonical: AdapterFileChange[]; adapters: AdapterFileChange[]; selectedAgents: string[]; } const CANONICAL_BASE_DIR = ".sdd/skill"; const CANONICAL_SKILLS: Array<{ id: string; files: Array<{ path: string; content: string }>; }> = [ { id: "sdd", files: [ { path: `${CANONICAL_BASE_DIR}/sdd/SKILL.md`, content: SKILL_MD_TEMPLATE, }, { path: `${CANONICAL_BASE_DIR}/sdd/references/file-format.md`, content: FILE_FORMAT_REFERENCE, }, { path: `${CANONICAL_BASE_DIR}/sdd/references/change-requests.md`, content: CHANGE_REQUESTS_REFERENCE, }, { path: `${CANONICAL_BASE_DIR}/sdd/references/bugs.md`, content: BUGS_REFERENCE, }, ], }, { id: "sdd-remote", files: [ { path: `${CANONICAL_BASE_DIR}/sdd-remote/SKILL.md`, content: SKILL_REMOTE_MD_TEMPLATE, }, ], }, ]; const CANONICAL_FILES: Array<{ path: string; content: string }> = CANONICAL_SKILLS.flatMap((skill) => skill.files); export const SKILL_ADAPTERS: SkillAdapterDefinition[] = [ { id: "claude", mode: "mirror", skills: [ { skillId: "sdd", paths: [ ".claude/skills/sdd/SKILL.md", ".claude/skills/sdd/references/file-format.md", ".claude/skills/sdd/references/change-requests.md", ".claude/skills/sdd/references/bugs.md", ], }, { skillId: "sdd-remote", paths: [".claude/skills/sdd-remote/SKILL.md"], }, ], }, { id: "universal", mode: "mirror", skills: [ { skillId: "sdd", paths: [ ".agents/skills/sdd/SKILL.md", ".agents/skills/sdd/references/file-format.md", ".agents/skills/sdd/references/change-requests.md", ".agents/skills/sdd/references/bugs.md", ], }, { skillId: "sdd-remote", paths: [".agents/skills/sdd-remote/SKILL.md"], }, ], }, ]; export function listSupportedAdapters(): string[] { return SKILL_ADAPTERS.map((adapter) => adapter.id); } export async function syncSkillAdapters( root: string, options?: SyncAdaptersOptions, ): Promise { const force = options?.force ?? false; const dryRun = options?.dryRun ?? false; const selected = resolveSelectedAdapters(options?.agents); // Build a map of skillId → canonical files for quick lookup const canonicalFilesBySkill = new Map< string, Array<{ path: string; content: string }> >(); for (const skill of CANONICAL_SKILLS) { canonicalFilesBySkill.set(skill.id, skill.files); } const canonicalChanges: AdapterFileChange[] = []; for (const file of CANONICAL_FILES) { const change = await upsertFile(root, file.path, file.content, { force, dryRun, managed: false, }); canonicalChanges.push(change); } const adapterChanges: AdapterFileChange[] = []; for (const adapter of selected) { for (const skillDef of adapter.skills) { const canonicalFiles = canonicalFilesBySkill.get(skillDef.skillId); if (adapter.mode === "pointer") { const content = buildPointerContent(adapter.id, skillDef.skillId); for (const path of skillDef.paths) { const change = await upsertFile(root, path, content, { force, dryRun, managed: true, }); adapterChanges.push(change); } continue; } // mirror mode: copy canonical content to adapter paths if (!canonicalFiles) { for (const path of skillDef.paths) { adapterChanges.push({ path, action: "skipped", reason: `No canonical files found for skill "${skillDef.skillId}"`, }); } continue; } for (let i = 0; i < skillDef.paths.length; i += 1) { const canonical = canonicalFiles[i]; if (!canonical) { adapterChanges.push({ path: skillDef.paths[i], action: "skipped", reason: "No corresponding canonical source file", }); continue; } const change = await upsertFile( root, skillDef.paths[i], canonical.content, { force, dryRun, managed: false, }, ); adapterChanges.push(change); } } } return { canonical: canonicalChanges, adapters: adapterChanges, selectedAgents: selected.map((adapter) => adapter.id), }; } function resolveSelectedAdapters(agents?: string[]): SkillAdapterDefinition[] { if (!agents || agents.length === 0) { return SKILL_ADAPTERS; } const normalized = new Set( agents.map((agent) => agent.trim().toLowerCase()).filter(Boolean), ); const selected = SKILL_ADAPTERS.filter((adapter) => normalized.has(adapter.id), ); const missing = Array.from(normalized).filter( (agent) => !SKILL_ADAPTERS.some((adapter) => adapter.id === agent), ); if (missing.length > 0) { throw new Error(`Unsupported adapters: ${missing.join(", ")}`); } return selected; } function buildPointerContent(adapterId: string, skillId: string): string { if (adapterId === "cursor") { return `--- description: SDD ${skillId} adapter alwaysApply: false --- Read .sdd/skill/${skillId}/SKILL.md for the full SDD ${skillId} workflow. If needed, also read the references in .sdd/skill/${skillId}/references/ `; } return `# SDD ${skillId} Adapter (${adapterId}) Primary instructions: .sdd/skill/${skillId}/SKILL.md References: - .sdd/skill/${skillId}/references/ Fallback (legacy): .claude/skills/${skillId}/SKILL.md `; } async function upsertFile( root: string, relativePath: string, content: string, options: { force: boolean; dryRun: boolean; managed: boolean }, ): Promise { const absPath = resolve(root, relativePath); const finalContent = options.managed ? `${MANAGED_HEADER}${content}` : content; if (!existsSync(absPath)) { if (!options.dryRun) { await mkdir(dirname(absPath), { recursive: true }); await writeFile(absPath, finalContent, "utf-8"); } return { path: relativePath, action: "created" }; } const current = await readFile(absPath, "utf-8"); if (current === finalContent) { return { path: relativePath, action: "skipped", reason: "Already up to date", }; } if (!options.force) { return { path: relativePath, action: "skipped", reason: "Exists (use --force to overwrite)", }; } if (!options.dryRun) { await writeFile(absPath, finalContent, "utf-8"); } return { path: relativePath, action: "updated" }; }