import { Command } from "commander"; import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync, } from "node:fs"; import { join } from "node:path"; import chalk from "chalk"; import ora from "ora"; import { parseSkillMd, type ParsedSkill } from "@skills-hub-ai/skill-parser"; import { translateToHand, serializeHandToml, } from "@skills-hub-ai/skill-parser/openfang"; import { detectInstallTarget, detectAllTargets, ALL_TARGETS, targetToPath, type KnownTarget, } from "../lib/install-path.js"; type SyncTarget = | "openfang" | "mcp" | "codex" | "claude-code" | "cursor" | "windsurf" | "cline" | "copilot" | "opencode"; const VALID_TARGETS: SyncTarget[] = [ "openfang", "mcp", "codex", ...(ALL_TARGETS as SyncTarget[]), ]; function discoverInstalledSkills(): { slug: string; skill: ParsedSkill }[] { const target = detectInstallTarget(); const results: { slug: string; skill: ParsedSkill }[] = []; if (!existsSync(target.path)) return results; const dirs = readdirSync(target.path, { withFileTypes: true }).filter((d) => d.isDirectory(), ); for (const dir of dirs) { const skillFile = join(target.path, dir.name, "SKILL.md"); if (!existsSync(skillFile)) continue; try { const content = readFileSync(skillFile, "utf-8"); const result = parseSkillMd(content); if (result.success && result.skill) { results.push({ slug: dir.name, skill: result.skill }); } } catch { // Skip unreadable skills } } return results; } function syncOpenfang( skills: { slug: string; skill: ParsedSkill }[], outputDir: string, ): number { mkdirSync(outputDir, { recursive: true }); let count = 0; for (const { slug, skill } of skills) { const hand = translateToHand(skill, { sourceUrl: `https://skills-hub.ai/skills/${slug}`, }); const toml = serializeHandToml(hand); writeFileSync(join(outputDir, `${slug}.hand.toml`), toml); count++; } return count; } function syncMcp( skills: { slug: string; skill: ParsedSkill }[], outputDir: string, ): number { mkdirSync(outputDir, { recursive: true }); // Generate an MCP server config that references the skills const mcpConfig = { mcpServers: { "skills-hub": { command: "npx", args: ["@skills-hub-ai/mcp"], }, }, skills: skills.map(({ slug, skill }) => ({ slug, name: skill.name, description: skill.description, version: skill.version, permissions: skill.permissions, })), }; writeFileSync( join(outputDir, "skills-hub-mcp.json"), JSON.stringify(mcpConfig, null, 2) + "\n", ); // Also write individual tool definitions for each skill const tools = skills.map(({ slug, skill }) => ({ name: slug, description: skill.description, inputSchema: { type: "object" as const, properties: { input: { type: "string" as const, description: `Input for the ${skill.name} skill`, }, }, required: ["input"], }, })); writeFileSync( join(outputDir, "tools.json"), JSON.stringify(tools, null, 2) + "\n", ); return skills.length; } function syncToSkillsMd( skills: { slug: string; skill: ParsedSkill }[], outputDir: string, ): number { mkdirSync(outputDir, { recursive: true }); let count = 0; for (const { slug, skill } of skills) { const skillDir = join(outputDir, slug); mkdirSync(skillDir, { recursive: true }); const lines = [ "---", `name: ${skill.name}`, `description: ${skill.description}`, `version: ${skill.version}`, ]; if (skill.category) lines.push(`category: ${skill.category}`); if (skill.platforms.length > 0) { lines.push("platforms:"); for (const p of skill.platforms) lines.push(` - ${p}`); } if (skill.permissions.length > 0) { lines.push("permissions:"); for (const p of skill.permissions) lines.push(` - ${p}`); } lines.push("---", "", skill.instructions); writeFileSync(join(skillDir, "SKILL.md"), lines.join("\n") + "\n"); count++; } return count; } function syncCodex( skills: { slug: string; skill: ParsedSkill }[], outputDir: string, ): number { mkdirSync(outputDir, { recursive: true }); let count = 0; for (const { slug, skill } of skills) { // Codex CLI uses markdown instruction files const codexContent = `# ${skill.name} ${skill.description} ## Instructions ${skill.instructions} `; writeFileSync(join(outputDir, `${slug}.md`), codexContent); count++; } return count; } function syncOneTarget( target: SyncTarget, skills: { slug: string; skill: ParsedSkill }[], outputDir?: string, ): { count: number; dir: string } { let count = 0; let dir = outputDir || join(process.cwd(), ".skills-sync", target); switch (target) { case "openfang": count = syncOpenfang(skills, dir); break; case "mcp": count = syncMcp(skills, dir); break; case "codex": count = syncCodex(skills, dir); break; case "claude-code": case "cursor": case "windsurf": case "cline": case "copilot": case "opencode": dir = outputDir || targetToPath(target as KnownTarget); count = syncToSkillsMd(skills, dir); break; } return { count, dir }; } export const syncCommand = new Command("sync") .description( `Sync installed skills to other AI tools. Use --all to sync to every detected tool.`, ) .argument( "[framework]", `Target framework: ${VALID_TARGETS.join(", ")}`, ) .option( "-o, --output ", "Output directory (default: tool's skills directory)", ) .option("-a, --all", "Sync to all detected AI tools on this machine") .action( async ( framework: string | undefined, options: { output?: string; all?: boolean }, ) => { if (!framework && !options.all) { console.error( chalk.red( `Specify a framework or use --all. Valid frameworks: ${VALID_TARGETS.join(", ")}`, ), ); process.exit(1); } const spinner = ora("Discovering installed skills...").start(); const skills = discoverInstalledSkills(); if (skills.length === 0) { spinner.fail( "No installed skills found. Run `skills-hub install ` first.", ); return; } // --all: sync to every detected AI tool if (options.all) { const source = detectInstallTarget(); const targets = detectAllTargets().filter( (t) => t.type !== source.type, ); if (targets.length === 0) { spinner.fail( `Only ${chalk.cyan(source.type)} detected. Install another AI tool to sync across platforms.`, ); return; } spinner.text = `Syncing ${skills.length} skills to ${targets.length} tools...`; let totalSynced = 0; const results: { target: string; count: number; dir: string }[] = []; for (const t of targets) { const { count, dir } = syncOneTarget( t.type as SyncTarget, skills, ); totalSynced += count; results.push({ target: t.type, count, dir }); } spinner.succeed( `Synced ${skills.length} skills across ${targets.length} tools`, ); console.log( ` Source: ${chalk.cyan(source.type)} (${skills.length} skills)`, ); for (const r of results) { console.log( ` ${chalk.green("→")} ${chalk.cyan(r.target)}: ${r.count} skills → ${r.dir}`, ); } return; } // Single target sync const target = framework!.toLowerCase() as SyncTarget; if (!VALID_TARGETS.includes(target)) { spinner.fail( `Unknown framework "${framework}". Valid: ${VALID_TARGETS.join(", ")}`, ); return; } spinner.text = `Syncing ${skills.length} skills to ${target}...`; const { count, dir } = syncOneTarget(target, skills, options.output); spinner.succeed(`Synced ${count} skills to ${chalk.cyan(dir)}`); if (target === "openfang") { console.log( ` Each skill is a ${chalk.bold("HAND.toml")} file ready for the OpenFang runtime.`, ); } else if (target === "mcp") { console.log( ` Config: ${chalk.bold("skills-hub-mcp.json")}, add to your MCP client config.`, ); console.log( ` Or run: ${chalk.yellow("npx @skills-hub-ai/mcp")} to start the MCP server.`, ); } else if (target === "codex") { console.log( ` Each skill is a markdown file usable as a Codex CLI instruction.`, ); } else if (ALL_TARGETS.includes(target as KnownTarget)) { console.log( ` Skills synced as ${chalk.bold("SKILL.md")} files to the ${target} skills directory.`, ); } }, );