From 5a3103d096e644bd4285c99ca274f4e84cf7d840 Mon Sep 17 00:00:00 2001 From: Sven Date: Mon, 2 Mar 2026 05:47:52 +0100 Subject: [PATCH] feat(skills): add skill-list, skill-install, skill-update CLI commands Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/cli.ts | 16 +++ packages/cli/src/core/index.ts | 7 ++ packages/cli/src/core/skills.ts | 197 ++++++++++++++++++++++++++++++++ 3 files changed, 220 insertions(+) create mode 100644 packages/cli/src/core/skills.ts diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 122a916..413763f 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -83,6 +83,9 @@ import { cmdInitMapCodebase, cmdInitExisting, cmdInitProgress, + cmdSkillList, + cmdSkillInstall, + cmdSkillUpdate, } from './core/index.js'; // ─── Arg parsing utilities ─────────────────────────────────────────────────── @@ -278,6 +281,18 @@ const handleValidate: Handler = (args, cwd, raw) => { error('Unknown validate subcommand. Available: consistency, health'); }; +const handleSkill: Handler = (args, cwd, raw) => { + const sub = args[1]; + const handlers: Record void> = { + 'list': () => cmdSkillList(cwd, raw), + 'install': () => cmdSkillInstall(cwd, args[2], raw), + 'update': () => cmdSkillUpdate(cwd, raw), + }; + const handler = sub ? handlers[sub] : undefined; + if (handler) return handler(); + error('Unknown skill subcommand. Available: list, install, update'); +}; + const handleInit: Handler = (args, cwd, raw) => { const workflow = args[1]; const handlers: Record void> = { @@ -346,6 +361,7 @@ const COMMANDS: Record = { const f = getFlags(args, 'phase', 'name'); cmdScaffold(cwd, args[1], { phase: f.phase, name: f.name ? args.slice(args.indexOf('--name') + 1).join(' ') : null }, raw); }, + 'skill': handleSkill, 'init': handleInit, 'phase-plan-index': (args, cwd, raw) => cmdPhasePlanIndex(cwd, args[1], raw), 'state-snapshot': (_args, cwd, raw) => cmdStateSnapshot(cwd, raw), diff --git a/packages/cli/src/core/index.ts b/packages/cli/src/core/index.ts index d8d6e00..2429f6d 100644 --- a/packages/cli/src/core/index.ts +++ b/packages/cli/src/core/index.ts @@ -243,6 +243,13 @@ export type { SpawnDashboardOptions, } from './dashboard-launcher.js'; +// Skills exports +export { + cmdSkillList, + cmdSkillInstall, + cmdSkillUpdate, +} from './skills.js'; + // Init exports export type { WorkflowType, diff --git a/packages/cli/src/core/skills.ts b/packages/cli/src/core/skills.ts new file mode 100644 index 0000000..9f8575a --- /dev/null +++ b/packages/cli/src/core/skills.ts @@ -0,0 +1,197 @@ +/** + * Skills — List, install, and update CLI skills + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; + +import { output, error, rethrowCliSignals } from './core.js'; +import { extractFrontmatter } from './frontmatter.js'; + +// ─── Constants ─────────────────────────────────────────────────────────────── + +/** Skills installed by MAXSIM (not user-created). */ +const BUILT_IN_SKILLS = [ + 'tdd', + 'systematic-debugging', + 'verification-before-completion', + 'code-review', + 'simplify', + 'memory-management', + 'using-maxsim', + 'batch-execution', + 'subagent-driven-development', + 'writing-plans', +] as const; + +/** Installed skills directory under the Claude config. */ +function skillsDir(): string { + return path.join(os.homedir(), '.claude', 'agents', 'skills'); +} + +/** Bundled skills directory inside the npm package. */ +function bundledSkillsDir(): string { + return path.resolve(__dirname, 'assets', 'templates', 'skills'); +} + +// ─── skill list ────────────────────────────────────────────────────────────── + +interface SkillInfo { + name: string; + description: string; + path: string; + builtIn: boolean; +} + +export function cmdSkillList(_cwd: string, raw: boolean): void { + const dir = skillsDir(); + + if (!fs.existsSync(dir)) { + output({ count: 0, skills: [] }, raw, '0 skills installed'); + return; + } + + const skills: SkillInfo[] = []; + + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const skillMd = path.join(dir, entry.name, 'SKILL.md'); + if (!fs.existsSync(skillMd)) continue; + + try { + const content = fs.readFileSync(skillMd, 'utf-8'); + const fm = extractFrontmatter(content); + skills.push({ + name: (fm.name as string) || entry.name, + description: (fm.description as string) || '', + path: path.join(dir, entry.name), + builtIn: (BUILT_IN_SKILLS as readonly string[]).includes(entry.name), + }); + } catch { + // Skill exists but SKILL.md is malformed — still list it + skills.push({ + name: entry.name, + description: '', + path: path.join(dir, entry.name), + builtIn: (BUILT_IN_SKILLS as readonly string[]).includes(entry.name), + }); + } + } + } catch (e: unknown) { + rethrowCliSignals(e); + error('Failed to list skills: ' + (e as Error).message); + } + + skills.sort((a, b) => a.name.localeCompare(b.name)); + + const summary = skills.map(s => `${s.name}: ${s.description}`).join('\n'); + output({ count: skills.length, skills }, raw, `${skills.length} skills installed\n${summary}`); +} + +// ─── skill install ─────────────────────────────────────────────────────────── + +export function cmdSkillInstall(_cwd: string, skillName: string | undefined, raw: boolean): void { + if (!skillName) { + error('skill name required for skill install'); + } + + const bundled = bundledSkillsDir(); + const srcDir = path.join(bundled, skillName); + const srcFile = path.join(srcDir, 'SKILL.md'); + + if (!fs.existsSync(srcFile)) { + const available = listBundledSkillNames(bundled); + error(`Skill '${skillName}' not found in bundle. Available: ${available.join(', ') || 'none'}`); + } + + const destDir = path.join(skillsDir(), skillName); + installSkillFromBundle(srcDir, destDir); + + output( + { installed: true, skill: skillName, path: destDir }, + raw, + `Installed skill '${skillName}' to ${destDir}`, + ); +} + +// ─── skill update ──────────────────────────────────────────────────────────── + +export function cmdSkillUpdate(_cwd: string, raw: boolean): void { + const bundled = bundledSkillsDir(); + + if (!fs.existsSync(bundled)) { + error('Bundled skills directory not found. Is MAXSIM installed correctly?'); + } + + const dest = skillsDir(); + fs.mkdirSync(dest, { recursive: true }); + + let updated = 0; + const updatedNames: string[] = []; + + for (const skillName of BUILT_IN_SKILLS) { + const srcDir = path.join(bundled, skillName); + if (!fs.existsSync(srcDir)) continue; + + const destSkillDir = path.join(dest, skillName); + + // Remove old version if present + if (fs.existsSync(destSkillDir)) { + fs.rmSync(destSkillDir, { recursive: true }); + } + + installSkillFromBundle(srcDir, destSkillDir); + updated++; + updatedNames.push(skillName); + } + + output( + { updated, skills: updatedNames }, + raw, + `Updated ${updated} built-in skills`, + ); +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** Copy a bundled skill to the destination and process path replacements. */ +function installSkillFromBundle(srcDir: string, destDir: string): void { + fs.mkdirSync(destDir, { recursive: true }); + copyDirRecursive(srcDir, destDir); + + // Expand ~/.claude/ to absolute home path for consistency + const destFile = path.join(destDir, 'SKILL.md'); + let content = fs.readFileSync(destFile, 'utf-8'); + const homePrefix = path.join(os.homedir(), '.claude') + '/'; + content = content.replace(/~\/\.claude\//g, homePrefix.replace(/\\/g, '/')); + fs.writeFileSync(destFile, content, 'utf-8'); +} + +/** Recursively copy a directory. */ +function copyDirRecursive(src: string, dest: string): void { + const entries = fs.readdirSync(src, { withFileTypes: true }); + for (const entry of entries) { + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + if (entry.isDirectory()) { + fs.mkdirSync(destPath, { recursive: true }); + copyDirRecursive(srcPath, destPath); + } else { + fs.copyFileSync(srcPath, destPath); + } + } +} + +/** List skill names available in a bundled skills directory. */ +function listBundledSkillNames(bundledDir: string): string[] { + try { + return fs.readdirSync(bundledDir, { withFileTypes: true }) + .filter(e => e.isDirectory() && fs.existsSync(path.join(bundledDir, e.name, 'SKILL.md'))) + .map(e => e.name); + } catch { + return []; + } +}