Add skill-manager skill (migrated from skill-creator) with deploy action

- Skill content moved from ~/.agents/skills/skill-creator, renamed to skill-manager
- New deploy CLI action: installs any skill via absolute-path symlinks (or copies)
  into $HOME/.agents/skills, $HOME/.claude/skills and $HOME/.cline/skills
- Taskfile + shell module wrappers (task deploy / cli:deploy)
- SKILL.md: deploy docs, origin-repository/origin-path metadata

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 22:32:42 +03:00
co-authored by Claude Fable 5
parent cc30987dd2
commit 6042272ccc
62 changed files with 3474 additions and 1 deletions
+191
View File
@@ -0,0 +1,191 @@
/**
* 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/.cline/skills/<skill-name>
*
* 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", ".cline/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;
}