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:
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Action: scaffold
|
||||
*
|
||||
* Scaffolds a new skill directory following the agentskills.io structure.
|
||||
*
|
||||
* Creates:
|
||||
* <output-dir>/<skill-name>/
|
||||
* ├── SKILL.md — populated from built-in template
|
||||
* ├── references/ — created empty (ready for docs)
|
||||
* └── assets/ — created empty (ready for templates)
|
||||
*
|
||||
* The SKILL.md is pre-filled with the standard frontmatter template:
|
||||
* - name set to <skill-name>
|
||||
* - description placeholder with imperative phrasing hint
|
||||
* - license, metadata, compatibility stubs
|
||||
*
|
||||
* Usage:
|
||||
* skill-tools --action scaffold --skill-name my-skill [--output-dir /path]
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SKILL.md template
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildSkillMd(skillName: string): string {
|
||||
return `---
|
||||
name: ${skillName}
|
||||
description: >
|
||||
[What the skill does — be specific about capabilities].
|
||||
Use this skill when [specific trigger conditions — user intent, task type, domain],
|
||||
even if the user doesn't explicitly mention [domain keywords].
|
||||
license: Proprietary
|
||||
metadata:
|
||||
author: workspace-swiss-knife
|
||||
version: "1.0"
|
||||
# compatibility: Designed for Cline, Claude Code, GitHub Copilot, OpenAI Codex, and other compatible agents (uncomment if needed)
|
||||
# allowed-tools: Bash Read Write (uncomment if needed)
|
||||
---
|
||||
|
||||
# [Skill Title]
|
||||
|
||||
[One-sentence summary of what this skill does and for whom.]
|
||||
|
||||
## Overview
|
||||
|
||||
[2–3 sentences of context. What problem does this skill solve? What domain does it operate in?]
|
||||
|
||||
## Prerequisites
|
||||
|
||||
[List any tools, packages, env vars, or conditions required. Remove section if none.]
|
||||
|
||||
- Requires: \`tool-name\`
|
||||
- Environment: \`ENV_VAR=value\`
|
||||
|
||||
## Workflow
|
||||
|
||||
[Step-by-step instructions. Be prescriptive for fragile steps; flexible for steps with valid alternatives.]
|
||||
|
||||
1. [Step one]
|
||||
2. [Step two]
|
||||
3. [Step three]
|
||||
|
||||
## Gotchas
|
||||
|
||||
[Non-obvious facts the agent will get wrong without being told. This is the highest-value section.
|
||||
Delete this section if you have no gotchas yet — add them as you discover them.]
|
||||
|
||||
- [Specific fact that defies reasonable assumptions]
|
||||
- [Another non-obvious environment-specific detail]
|
||||
|
||||
## References
|
||||
|
||||
[Tell the agent when to load each reference file. Use conditional loading, not generic "see references/".
|
||||
Delete this section if no references/ directory.]
|
||||
|
||||
- Read \`references/api-errors.md\` if the API returns a non-200 status code.
|
||||
- Read \`references/schema.md\` before writing any database queries.
|
||||
`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function run(options: Record<string, string | undefined>): Record<string, unknown> {
|
||||
const skillName = options["skill-name"];
|
||||
if (!skillName) {
|
||||
return {
|
||||
action: "scaffold",
|
||||
success: false,
|
||||
error: "Missing required option: --skill-name",
|
||||
};
|
||||
}
|
||||
|
||||
// Validate skill name
|
||||
if (/[^a-z0-9-]/.test(skillName)) {
|
||||
return {
|
||||
action: "scaffold",
|
||||
success: false,
|
||||
error: `'skill-name' must use only a-z, 0-9, hyphens. Got: '${skillName}'`,
|
||||
};
|
||||
}
|
||||
if (skillName.startsWith("-") || skillName.endsWith("-")) {
|
||||
return {
|
||||
action: "scaffold",
|
||||
success: false,
|
||||
error: `'skill-name' must not start or end with a hyphen. Got: '${skillName}'`,
|
||||
};
|
||||
}
|
||||
if (/--/.test(skillName)) {
|
||||
return {
|
||||
action: "scaffold",
|
||||
success: false,
|
||||
error: `'skill-name' must not contain consecutive hyphens. Got: '${skillName}'`,
|
||||
};
|
||||
}
|
||||
if (skillName.length > 64) {
|
||||
return {
|
||||
action: "scaffold",
|
||||
success: false,
|
||||
error: `'skill-name' must be ≤ 64 characters. Got: ${skillName.length}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve output directory
|
||||
const rawOutputDir = options["output-dir"] ?? join(process.cwd(), "..", "..", ".agents", "skills");
|
||||
const outputDir = resolve(rawOutputDir);
|
||||
const skillDir = join(outputDir, skillName);
|
||||
|
||||
if (existsSync(skillDir)) {
|
||||
return {
|
||||
action: "scaffold",
|
||||
success: false,
|
||||
error: `Directory already exists: ${skillDir}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Create directories
|
||||
const created: string[] = [];
|
||||
mkdirSync(skillDir, { recursive: true });
|
||||
created.push(skillDir);
|
||||
|
||||
const refsDir = join(skillDir, "references");
|
||||
const assetsDir = join(skillDir, "assets");
|
||||
mkdirSync(refsDir);
|
||||
created.push(refsDir);
|
||||
mkdirSync(assetsDir);
|
||||
created.push(assetsDir);
|
||||
|
||||
// Write SKILL.md
|
||||
const skillMdPath = join(skillDir, "SKILL.md");
|
||||
writeFileSync(skillMdPath, buildSkillMd(skillName), "utf-8");
|
||||
created.push(skillMdPath);
|
||||
|
||||
return {
|
||||
action: "scaffold",
|
||||
success: true,
|
||||
"skill-name": skillName,
|
||||
"skill-dir": skillDir,
|
||||
created,
|
||||
"next-steps": [
|
||||
`Edit ${skillMdPath} — fill in description, title, overview, workflow`,
|
||||
`Run validation: skill-tools --action validate --skill-dir ${skillDir}`,
|
||||
"Add reference docs to references/ as the skill grows",
|
||||
"Add templates/data files to assets/ if needed",
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Action: validate
|
||||
*
|
||||
* Validates a skill directory against the agentskills.io specification.
|
||||
*
|
||||
* Checks performed:
|
||||
* 1. SKILL.md exists
|
||||
* 2. YAML frontmatter delimiters (--- ... ---) are present
|
||||
* 3. 'name' field is present in frontmatter
|
||||
* 4. 'name' matches the directory name (basename)
|
||||
* 5. 'name' length ≤ 64 characters
|
||||
* 6. 'name' uses only a-z, 0-9, hyphens
|
||||
* 7. 'name' does not start with a hyphen
|
||||
* 8. 'name' does not end with a hyphen
|
||||
* 9. 'name' has no consecutive hyphens
|
||||
* 10. 'description' field is present and non-empty
|
||||
* 11. 'description' length ≤ 1024 characters
|
||||
* 12. SKILL.md body (lines after frontmatter) ≤ 500 lines (recommendation)
|
||||
* 13. If scripts/ exists, Taskfile.yml must be present inside it
|
||||
*
|
||||
* Output: YAML with success flag, counts, and per-check detail list.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { join, basename } from "node:path";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CheckResult {
|
||||
id: number;
|
||||
pass: boolean;
|
||||
level: "error" | "warning" | "info";
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface DirectoryInfo {
|
||||
found: boolean;
|
||||
files?: number;
|
||||
"Taskfile.yml"?: boolean;
|
||||
}
|
||||
|
||||
interface ValidateOutput {
|
||||
action: "validate";
|
||||
"skill-dir": string;
|
||||
success: boolean;
|
||||
summary: {
|
||||
passed: number;
|
||||
failed: number;
|
||||
warnings: number;
|
||||
};
|
||||
checks: CheckResult[];
|
||||
optional: {
|
||||
"scripts/": DirectoryInfo;
|
||||
"references/": DirectoryInfo;
|
||||
"assets/": DirectoryInfo;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function countFilesSync(dir: string): number {
|
||||
try {
|
||||
const entries = readdirSync(dir, { withFileTypes: true });
|
||||
let count = 0;
|
||||
for (const e of entries) {
|
||||
if (e.isDirectory()) {
|
||||
count += countFilesSync(join(dir, e.name));
|
||||
} else {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Frontmatter parser — handles scalar and > block values
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseFrontmatter(raw: string): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
const lines = raw.split("\n");
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
const keyMatch = line.match(/^([a-zA-Z_-]+)\s*:\s*(.*)/);
|
||||
if (keyMatch) {
|
||||
const key = keyMatch[1];
|
||||
const val = keyMatch[2].trim();
|
||||
if (val === ">" || val === "|") {
|
||||
// Multi-line block scalar — collect indented continuation lines
|
||||
const parts: string[] = [];
|
||||
i++;
|
||||
while (
|
||||
i < lines.length &&
|
||||
(lines[i].startsWith(" ") || lines[i].startsWith("\t") || lines[i].trim() === "")
|
||||
) {
|
||||
parts.push(lines[i].trim());
|
||||
i++;
|
||||
}
|
||||
result[key] = parts.join(" ").trim();
|
||||
continue;
|
||||
} else {
|
||||
result[key] = val;
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Optional directories inspector
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function inspectOptional(skillDir: string): ValidateOutput["optional"] {
|
||||
function dirInfo(subdir: string): DirectoryInfo {
|
||||
const p = join(skillDir, subdir);
|
||||
if (!existsSync(p)) return { found: false };
|
||||
return { found: true, files: countFilesSync(p) };
|
||||
}
|
||||
|
||||
const scriptsDir = join(skillDir, "scripts");
|
||||
const scriptsInfo = dirInfo("scripts");
|
||||
|
||||
if (scriptsInfo.found) {
|
||||
scriptsInfo["Taskfile.yml"] = existsSync(join(scriptsDir, "Taskfile.yml"));
|
||||
}
|
||||
|
||||
return {
|
||||
"scripts/": scriptsInfo,
|
||||
"references/": dirInfo("references"),
|
||||
"assets/": dirInfo("assets"),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildOutput(
|
||||
skillDir: string,
|
||||
checks: CheckResult[],
|
||||
optional: ValidateOutput["optional"]
|
||||
): Record<string, unknown> {
|
||||
const passed = checks.filter((c) => c.pass).length;
|
||||
const failed = checks.filter((c) => !c.pass && c.level === "error").length;
|
||||
const warnings = checks.filter((c) => !c.pass && c.level === "warning").length;
|
||||
|
||||
return {
|
||||
action: "validate",
|
||||
"skill-dir": skillDir,
|
||||
success: failed === 0,
|
||||
summary: { passed, failed, warnings },
|
||||
checks,
|
||||
optional,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function run(options: Record<string, string | undefined>): Record<string, unknown> {
|
||||
const skillDir = options["skill-dir"];
|
||||
if (!skillDir) {
|
||||
return {
|
||||
action: "validate",
|
||||
success: false,
|
||||
error: "Missing required option: --skill-dir",
|
||||
};
|
||||
}
|
||||
|
||||
const checks: CheckResult[] = [];
|
||||
let checkId = 1;
|
||||
|
||||
const ok = (message: string, level: CheckResult["level"] = "error"): CheckResult =>
|
||||
({ id: checkId++, pass: true, level, message });
|
||||
const err = (message: string, level: CheckResult["level"] = "error"): CheckResult =>
|
||||
({ id: checkId++, pass: false, level, message });
|
||||
|
||||
// ── Check 1: SKILL.md exists ─────────────────────────────────────────────
|
||||
const skillMdPath = join(skillDir, "SKILL.md");
|
||||
if (!existsSync(skillMdPath)) {
|
||||
checks.push(err("SKILL.md does not exist in skill directory"));
|
||||
return buildOutput(skillDir, checks, inspectOptional(skillDir));
|
||||
}
|
||||
checks.push(ok("SKILL.md exists"));
|
||||
|
||||
const raw = readFileSync(skillMdPath, "utf-8");
|
||||
|
||||
// ── Check 2: Frontmatter delimiters ──────────────────────────────────────
|
||||
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
||||
if (!fmMatch) {
|
||||
checks.push(err("YAML frontmatter delimiters (--- ... ---) not found"));
|
||||
return buildOutput(skillDir, checks, inspectOptional(skillDir));
|
||||
}
|
||||
checks.push(ok("YAML frontmatter delimiters found"));
|
||||
|
||||
const fm = parseFrontmatter(fmMatch[1]);
|
||||
const body = fmMatch[2];
|
||||
|
||||
// ── Checks 3–9: name field ───────────────────────────────────────────────
|
||||
const name = fm["name"];
|
||||
if (!name) {
|
||||
checks.push(err("'name' field is missing from frontmatter"));
|
||||
} else {
|
||||
checks.push(ok(`'name' field is present: ${name}`));
|
||||
|
||||
const dirName = basename(skillDir);
|
||||
checks.push(
|
||||
name === dirName
|
||||
? ok("'name' matches directory name")
|
||||
: err(`'name' (${name}) does not match directory name (${dirName})`)
|
||||
);
|
||||
|
||||
checks.push(
|
||||
name.length <= 64
|
||||
? ok(`'name' length is ${name.length} (max 64)`)
|
||||
: err(`'name' length is ${name.length} (max 64)`)
|
||||
);
|
||||
|
||||
checks.push(
|
||||
/[^a-z0-9-]/.test(name)
|
||||
? err("'name' contains characters outside a-z, 0-9, hyphen")
|
||||
: ok("'name' uses only allowed characters (a-z, 0-9, -)")
|
||||
);
|
||||
|
||||
checks.push(
|
||||
name.startsWith("-")
|
||||
? err("'name' must not start with a hyphen")
|
||||
: ok("'name' does not start with a hyphen")
|
||||
);
|
||||
|
||||
checks.push(
|
||||
name.endsWith("-")
|
||||
? err("'name' must not end with a hyphen")
|
||||
: ok("'name' does not end with a hyphen")
|
||||
);
|
||||
|
||||
checks.push(
|
||||
/--/.test(name)
|
||||
? err("'name' contains consecutive hyphens")
|
||||
: ok("'name' has no consecutive hyphens")
|
||||
);
|
||||
}
|
||||
|
||||
// ── Checks 10–11: description field ──────────────────────────────────────
|
||||
const description = fm["description"];
|
||||
if (!description || description.trim() === "") {
|
||||
checks.push(err("'description' field is missing or empty"));
|
||||
} else {
|
||||
const descLen = description.trim().length;
|
||||
checks.push(ok(`'description' is present (${descLen} chars)`));
|
||||
checks.push(
|
||||
descLen <= 1024
|
||||
? ok(`'description' length is ${descLen} (max 1024)`)
|
||||
: err(`'description' length is ${descLen} (max 1024)`)
|
||||
);
|
||||
}
|
||||
|
||||
// ── Check 12: Body line count ─────────────────────────────────────────────
|
||||
const bodyLines = body.split("\n").length;
|
||||
checks.push(
|
||||
bodyLines <= 500
|
||||
? ok(`SKILL.md body is ${bodyLines} lines (max 500 recommended)`)
|
||||
: err(`SKILL.md body is ${bodyLines} lines (max 500 recommended)`, "warning")
|
||||
);
|
||||
|
||||
// ── Check 13: scripts/ must have Taskfile.yml ─────────────────────────────
|
||||
const scriptsDir = join(skillDir, "scripts");
|
||||
if (existsSync(scriptsDir)) {
|
||||
checks.push(
|
||||
existsSync(join(scriptsDir, "Taskfile.yml"))
|
||||
? ok("scripts/Taskfile.yml exists")
|
||||
: err("scripts/ exists but Taskfile.yml is missing — it is required")
|
||||
);
|
||||
}
|
||||
|
||||
return buildOutput(skillDir, checks, inspectOptional(skillDir));
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* skill-tools CLI dispatcher
|
||||
*
|
||||
* Usage:
|
||||
* skill-tools --action <action-name> [options]
|
||||
*
|
||||
* Actions:
|
||||
* validate — validate a skill directory against the agentskills.io spec
|
||||
* scaffold — scaffold a new skill directory from the built-in template
|
||||
* deploy — deploy a skill into $HOME/.agents/skills, $HOME/.claude/skills, $HOME/.cline/skills
|
||||
*
|
||||
* Output is always YAML to stdout. Exit 0 on success, 1 on error.
|
||||
*/
|
||||
|
||||
import { parseArgs } from "node:util";
|
||||
import * as yaml from "js-yaml";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Action registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ActionModule = {
|
||||
run: (options: Record<string, string | undefined>) => Record<string, unknown>;
|
||||
};
|
||||
|
||||
const ACTIONS: Record<string, () => Promise<ActionModule>> = {
|
||||
validate: () => import("../actions/validate/action.js"),
|
||||
scaffold: () => import("../actions/scaffold/action.js"),
|
||||
deploy: () => import("../actions/deploy/action.js"),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main(): Promise<void> {
|
||||
let values: {
|
||||
action?: string;
|
||||
"skill-dir"?: string;
|
||||
"skill-name"?: string;
|
||||
"output-dir"?: string;
|
||||
mode?: string;
|
||||
targets?: string;
|
||||
force?: boolean;
|
||||
help?: boolean;
|
||||
};
|
||||
|
||||
try {
|
||||
({ values } = parseArgs({
|
||||
args: process.argv.slice(2),
|
||||
options: {
|
||||
action: { type: "string", short: "a" },
|
||||
"skill-dir": { type: "string" },
|
||||
"skill-name": { type: "string" },
|
||||
"output-dir": { type: "string" },
|
||||
mode: { type: "string" },
|
||||
targets: { type: "string" },
|
||||
force: { type: "boolean", short: "f" },
|
||||
help: { type: "boolean", short: "h" },
|
||||
},
|
||||
strict: true,
|
||||
}));
|
||||
} catch (err) {
|
||||
printError(String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (values.help || !values.action) {
|
||||
console.log(`skill-tools --action <action> [options]
|
||||
|
||||
Actions:
|
||||
validate Validate a skill directory against the agentskills.io spec
|
||||
--skill-dir <path> Path to the skill root directory (required)
|
||||
|
||||
scaffold Scaffold a new skill directory from the built-in template
|
||||
--skill-name <name> Skill name (lowercase, hyphens only, required)
|
||||
--output-dir <path> Parent directory to create the skill in (default: .agents/skills)
|
||||
|
||||
deploy Deploy a skill into the agent skill directories (absolute paths)
|
||||
--skill-dir <path> Path to the skill root directory (required)
|
||||
--mode <mode> symlink (default) or copy
|
||||
--targets <dirs> Comma-separated target directories
|
||||
(default: $HOME/.agents/skills,$HOME/.claude/skills,$HOME/.cline/skills)
|
||||
--force Replace an existing real directory at the destination
|
||||
|
||||
Examples:
|
||||
skill-tools --action validate --skill-dir /path/to/my-skill
|
||||
skill-tools --action scaffold --skill-name my-skill --output-dir /path/to/.agents/skills
|
||||
skill-tools --action deploy --skill-dir /path/to/my-skill
|
||||
skill-tools --action deploy --skill-dir /path/to/my-skill --mode copy --force
|
||||
`);
|
||||
process.exit(values.help ? 0 : 1);
|
||||
}
|
||||
|
||||
const actionName = values.action;
|
||||
|
||||
if (!ACTIONS[actionName]) {
|
||||
printError(
|
||||
`Unknown action '${actionName}'. Available: ${Object.keys(ACTIONS).join(", ")}`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const mod = await ACTIONS[actionName]();
|
||||
const result = mod.run({
|
||||
"skill-dir": values["skill-dir"],
|
||||
"skill-name": values["skill-name"],
|
||||
"output-dir": values["output-dir"],
|
||||
mode: values.mode,
|
||||
targets: values.targets,
|
||||
force: values.force ? "true" : undefined,
|
||||
});
|
||||
process.stdout.write(
|
||||
yaml.dump(result, { noRefs: true, sortKeys: false, lineWidth: 120 })
|
||||
);
|
||||
const success = (result["success"] as boolean | undefined) ?? true;
|
||||
process.exit(success ? 0 : 1);
|
||||
} catch (err) {
|
||||
const errorOutput = {
|
||||
success: false,
|
||||
action: actionName,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
process.stdout.write(yaml.dump(errorOutput, { noRefs: true, sortKeys: false }));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function printError(msg: string): void {
|
||||
process.stderr.write(`Error: ${msg}\n`);
|
||||
process.stderr.write(
|
||||
`Usage: skill-tools --action <${Object.keys(ACTIONS).join("|")}> [options]\n`
|
||||
);
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user