playbook/outfitter-agents/plugins/outfitter/skills/skills-dev/scripts/init-skill.ts

272 lines
6.6 KiB
TypeScript
Executable File

#!/usr/bin/env bun
/**
* Initialize a new Claude skill from template or scratch
*
* Usage:
* bun run init-skill.ts <skill-name> <output-dir>
* bun run init-skill.ts <skill-name> <output-dir> --template <template-name>
*
* Templates: api-wrapper, document-processor, dev-workflow, research-synthesizer, simple
*/
import * as fs from "node:fs";
import { homedir } from "node:os";
import * as path from "node:path";
const SCRIPT_DIR = path.dirname(new URL(import.meta.url).pathname);
const TEMPLATES_DIR = path.join(SCRIPT_DIR, "../templates/skill-archetypes");
/**
* Result of skill initialization.
*/
interface InitResult {
/** Whether initialization succeeded or failed */
status: "success" | "error";
/** Path to created skill directory */
skillDir?: string;
/** Template used if any */
template?: string;
/** Files created during initialization */
files?: string[];
/** Suggested next steps after initialization */
nextSteps?: string[];
/** Error message if status is "error" */
error?: string;
/** Available templates when template not found */
availableTemplates?: string[];
}
function getAvailableTemplates(): string[] {
if (!fs.existsSync(TEMPLATES_DIR)) return [];
return fs
.readdirSync(TEMPLATES_DIR)
.filter((f) => fs.statSync(path.join(TEMPLATES_DIR, f)).isDirectory());
}
function copyDir(src: string, dest: string): string[] {
const copiedFiles: string[] = [];
fs.mkdirSync(dest, { recursive: true });
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
// Prevent path traversal attacks
if (entry.name.includes("..") || path.isAbsolute(entry.name)) {
throw new Error(`Invalid file name: ${entry.name}`);
}
const srcPath = path.join(src, entry.name);
// Rename SKILL.template.md to SKILL.md during copy
const destName =
entry.name === "SKILL.template.md" ? "SKILL.md" : entry.name;
const destPath = path.join(dest, destName);
if (entry.isDirectory()) {
copiedFiles.push(...copyDir(srcPath, destPath));
} else {
fs.copyFileSync(srcPath, destPath);
copiedFiles.push(path.relative(dest, destPath) || destName);
}
}
return copiedFiles;
}
function toTitleCase(str: string): string {
return str
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
}
function createMinimalSkill(skillName: string, outputDir: string): InitResult {
const skillDir = path.join(outputDir, skillName);
if (fs.existsSync(skillDir)) {
return {
status: "error",
error: `Directory already exists: ${skillDir}`,
};
}
fs.mkdirSync(skillDir, { recursive: true });
const titleName = toTitleCase(skillName);
const skillMd = `---
name: ${titleName}
description: TODO - Describe what this skill does and when to use it. Include trigger keywords users might mention.
---
# ${titleName}
## Quick Start
TODO: Fastest path to value (3-5 lines)
## Instructions
When this skill is activated:
1. **Step 1**
- Detail
2. **Step 2**
- Detail
## Examples
### Example 1: Basic Usage
\`\`\`
TODO: Add concrete example
\`\`\`
## Best Practices
- TODO: Add best practices
## Related Skills
- TODO: Add related skills if any
`;
fs.writeFileSync(path.join(skillDir, "SKILL.md"), skillMd);
return {
status: "success",
skillDir,
files: ["SKILL.md"],
nextSteps: [
"Edit SKILL.md frontmatter - description is critical for discovery",
"Replace all TODO placeholders with actual content",
"Add examples that demonstrate real usage",
"Run validate-claude-skill to check quality",
],
};
}
function createFromTemplate(
skillName: string,
outputDir: string,
templateName: string,
): InitResult {
const templateDir = path.join(TEMPLATES_DIR, templateName);
const availableTemplates = getAvailableTemplates();
if (!fs.existsSync(templateDir)) {
return {
status: "error",
error: `Template '${templateName}' not found`,
availableTemplates,
};
}
const skillDir = path.join(outputDir, skillName);
if (fs.existsSync(skillDir)) {
return {
status: "error",
error: `Directory already exists: ${skillDir}`,
};
}
const files = copyDir(templateDir, skillDir);
// Make scripts executable
const scriptsDir = path.join(skillDir, "scripts");
if (fs.existsSync(scriptsDir)) {
for (const script of fs.readdirSync(scriptsDir)) {
const scriptPath = path.join(scriptsDir, script);
fs.chmodSync(scriptPath, 0o755);
}
}
return {
status: "success",
skillDir,
template: templateName,
files,
nextSteps: [
"Replace all {{PLACEHOLDERS}} in SKILL.md with actual values",
"Update scripts/ with your specific logic",
"Craft a strong description for discoverability",
"Test with real inputs",
"Run validate-claude-skill to check quality",
],
};
}
function showUsage(): void {
const templates = getAvailableTemplates();
console.log(
JSON.stringify(
{
usage: "init-skill.ts <skill-name> <output-dir> [--template <name>]",
examples: [
"bun run init-skill.ts my-skill ~/.claude/skills",
"bun run init-skill.ts github-api .claude/skills --template api-wrapper",
],
availableTemplates: templates,
templateDescriptions: {
"api-wrapper": "For wrapping external APIs (REST, GraphQL)",
"document-processor":
"For working with file formats (PDF, DOCX, etc)",
"dev-workflow": "For automating development tasks (git, CI, etc)",
"research-synthesizer": "For gathering and synthesizing information",
simple: "Minimal skill without scripts",
},
},
null,
2,
),
);
}
// Main
const args = process.argv.slice(2);
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
showUsage();
process.exit(0);
}
const templateIdx = args.indexOf("--template");
let template: string | null = null;
if (templateIdx !== -1) {
template = args[templateIdx + 1];
args.splice(templateIdx, 2);
}
const [skillName, outputDir] = args;
if (!skillName || !outputDir) {
showUsage();
process.exit(1);
}
// Validate skill name (minimum 2 chars, kebab-case)
if (skillName.length < 2 || !/^[a-z][a-z0-9-]*[a-z0-9]$|^[a-z]{2}$/.test(skillName)) {
console.log(
JSON.stringify({
status: "error",
error:
"Skill name must be kebab-case (lowercase with hyphens, min 2 chars)",
examples: ["my-skill", "github-api", "pdf-processor", "ai"],
}),
);
process.exit(1);
}
// Expand ~ to home directory
const expandedOutputDir = outputDir.replace(/^~/, homedir());
let result: InitResult;
if (template) {
result = createFromTemplate(skillName, expandedOutputDir, template);
} else {
result = createMinimalSkill(skillName, expandedOutputDir);
}
console.log(JSON.stringify(result, null, 2));
process.exit(result.status === "success" ? 0 : 1);