#!/usr/bin/env bun /** * Initialize a new Claude skill from template or scratch * * Usage: * bun run init-skill.ts * bun run init-skill.ts --template * * 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 [--template ]", 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);