/** * Action: deploy * * Deploys a skill directory into the agent skill directories so compatible * agents (Claude Code, Cline, Copilot, Codex, …) can discover it. * * Default targets (always resolved to absolute paths): * $HOME/.agents/skills/ * $HOME/.claude/skills/ * * $HOME is the current user's home directory (e.g. /Users/ on macOS, * /home/ on Linux), resolved at runtime via os.homedir(). * * Modes: * symlink (default) — creates an absolute-path symlink to the skill source. * The source stays the single source of truth (e.g. a git repo). * copy — copies the skill directory (excluding node_modules/, dist/, .git/). * * Existing symlinks at the destination are replaced. Existing real directories * are only replaced when --force is given. * * Usage: * skill-tools --action deploy --skill-dir /abs/path/to/skill [--mode symlink|copy] [--targets dir1,dir2] [--force] */ import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, symlinkSync, unlinkSync, } from "node:fs"; import { homedir } from "node:os"; import { basename, join, resolve } from "node:path"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const DEFAULT_TARGETS = [".agents/skills", ".claude/skills"]; const COPY_EXCLUDES = new Set(["node_modules", "dist", ".git"]); function readSkillName(skillDir: string): string | undefined { const skillMdPath = join(skillDir, "SKILL.md"); const content = readFileSync(skillMdPath, "utf-8"); const match = content.match(/^---\n[\s\S]*?^name:\s*(\S+)\s*$/m); return match?.[1]; } function safeRealpath(path: string): string { try { return realpathSync(path); } catch { return resolve(path); } } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- export function run(options: Record): Record { const rawSkillDir = options["skill-dir"]; if (!rawSkillDir) { return { action: "deploy", success: false, error: "Missing required option: --skill-dir", }; } const skillDir = resolve(rawSkillDir); if (!existsSync(skillDir)) { return { action: "deploy", success: false, error: `Skill directory does not exist: ${skillDir}`, }; } if (!existsSync(join(skillDir, "SKILL.md"))) { return { action: "deploy", success: false, error: `Not a skill directory (missing SKILL.md): ${skillDir}`, }; } const mode = options["mode"] ?? "symlink"; if (mode !== "symlink" && mode !== "copy") { return { action: "deploy", success: false, error: `'mode' must be 'symlink' or 'copy'. Got: '${mode}'`, }; } const force = options["force"] === "true"; const skillName = readSkillName(skillDir) ?? basename(skillDir); const dirName = basename(skillDir); const warnings: string[] = []; if (skillName !== dirName) { warnings.push( `Frontmatter name '${skillName}' does not match directory name '${dirName}' — deploying as '${skillName}'` ); } const targets = ( options["targets"] ? options["targets"].split(",").map((t) => t.trim()).filter(Boolean) : DEFAULT_TARGETS.map((t) => join(homedir(), t)) ).map((t) => resolve(t)); const sourceReal = safeRealpath(skillDir); const deployments: Record[] = []; let success = true; for (const targetDir of targets) { const destination = join(targetDir, skillName); try { mkdirSync(targetDir, { recursive: true }); let existing: ReturnType | undefined; try { existing = lstatSync(destination); } catch { existing = undefined; } if (existing && !existing.isSymbolicLink() && safeRealpath(destination) === sourceReal) { deployments.push({ target: destination, status: "skipped", reason: "Destination is the source directory itself", }); continue; } if (existing) { if (existing.isSymbolicLink()) { unlinkSync(destination); } else if (force) { rmSync(destination, { recursive: true }); } else { deployments.push({ target: destination, status: "skipped", reason: "Destination exists and is not a symlink — pass --force to replace", }); success = false; continue; } } if (mode === "symlink") { symlinkSync(skillDir, destination); } else { cpSync(skillDir, destination, { recursive: true, filter: (src) => !COPY_EXCLUDES.has(basename(src)), }); } deployments.push({ target: destination, status: "deployed", mode }); } catch (err) { deployments.push({ target: destination, status: "failed", reason: err instanceof Error ? err.message : String(err), }); success = false; } } const result: Record = { action: "deploy", success, "skill-name": skillName, "skill-dir": skillDir, mode, deployments, }; if (warnings.length > 0) { result["warnings"] = warnings; } return result; }