Remove $HOME/.cline/skills from the default deploy targets everywhere (SKILL.md, deploy action, CLI help, Taskfiles), document what $HOME is and how it is resolved, and restore the execute bit on the cli api scripts (build.sh, scaffold.sh, validate.sh) so 'task build' works. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
194 lines
5.4 KiB
TypeScript
194 lines
5.4 KiB
TypeScript
/**
|
|
* 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/<skill-name>
|
|
* $HOME/.claude/skills/<skill-name>
|
|
*
|
|
* $HOME is the current user's home directory (e.g. /Users/<username> on macOS,
|
|
* /home/<username> 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<string, string | undefined>): Record<string, unknown> {
|
|
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<string, unknown>[] = [];
|
|
let success = true;
|
|
|
|
for (const targetDir of targets) {
|
|
const destination = join(targetDir, skillName);
|
|
|
|
try {
|
|
mkdirSync(targetDir, { recursive: true });
|
|
|
|
let existing: ReturnType<typeof lstatSync> | 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<string, unknown> = {
|
|
action: "deploy",
|
|
success,
|
|
"skill-name": skillName,
|
|
"skill-dir": skillDir,
|
|
mode,
|
|
deployments,
|
|
};
|
|
if (warnings.length > 0) {
|
|
result["warnings"] = warnings;
|
|
}
|
|
return result;
|
|
}
|