From 903f5af59ca28bb7d83c8279062d0ecb2f1f0ed4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 8 Jan 2026 07:23:05 +0100 Subject: [PATCH] feat: add skills CLI --- src/agents/skills-status.ts | 13 +- src/cli/program.ts | 2 + src/cli/skills-cli.test.ts | 282 ++++++++++++++++++++++++ src/cli/skills-cli.ts | 420 ++++++++++++++++++++++++++++++++++++ 4 files changed, 716 insertions(+), 1 deletion(-) create mode 100644 src/cli/skills-cli.test.ts create mode 100644 src/cli/skills-cli.ts diff --git a/src/agents/skills-status.ts b/src/agents/skills-status.ts index f444a5e76..63c120ba2 100644 --- a/src/agents/skills-status.ts +++ b/src/agents/skills-status.ts @@ -45,12 +45,14 @@ export type SkillStatusEntry = { eligible: boolean; requirements: { bins: string[]; + anyBins: string[]; env: string[]; config: string[]; os: string[]; }; missing: { bins: string[]; + anyBins: string[]; env: string[]; config: string[]; os: string[]; @@ -149,11 +151,17 @@ function buildSkillStatus( const homepage = homepageRaw?.trim() ? homepageRaw.trim() : undefined; const requiredBins = entry.clawdbot?.requires?.bins ?? []; + const requiredAnyBins = entry.clawdbot?.requires?.anyBins ?? []; const requiredEnv = entry.clawdbot?.requires?.env ?? []; const requiredConfig = entry.clawdbot?.requires?.config ?? []; const requiredOs = entry.clawdbot?.os ?? []; const missingBins = requiredBins.filter((bin) => !hasBinary(bin)); + const missingAnyBins = + requiredAnyBins.length > 0 && + !requiredAnyBins.some((bin) => hasBinary(bin)) + ? requiredAnyBins + : []; const missingOs = requiredOs.length > 0 && !requiredOs.includes(process.platform) ? requiredOs @@ -181,9 +189,10 @@ function buildSkillStatus( .map((check) => check.path); const missing = always - ? { bins: [], env: [], config: [], os: [] } + ? { bins: [], anyBins: [], env: [], config: [], os: [] } : { bins: missingBins, + anyBins: missingAnyBins, env: missingEnv, config: missingConfig, os: missingOs, @@ -193,6 +202,7 @@ function buildSkillStatus( !blockedByAllowlist && (always || (missing.bins.length === 0 && + missing.anyBins.length === 0 && missing.env.length === 0 && missing.config.length === 0 && missing.os.length === 0)); @@ -213,6 +223,7 @@ function buildSkillStatus( eligible, requirements: { bins: requiredBins, + anyBins: requiredAnyBins, env: requiredEnv, config: requiredConfig, os: requiredOs, diff --git a/src/cli/program.ts b/src/cli/program.ts index 867d003ed..1bd4fda41 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -43,6 +43,7 @@ import { registerNodesCli } from "./nodes-cli.js"; import { registerPairingCli } from "./pairing-cli.js"; import { forceFreePort } from "./ports.js"; import { registerProvidersCli } from "./providers-cli.js"; +import { registerSkillsCli } from "./skills-cli.js"; import { registerTuiCli } from "./tui-cli.js"; export { forceFreePort }; @@ -630,6 +631,7 @@ Examples: registerHooksCli(program); registerPairingCli(program); registerProvidersCli(program); + registerSkillsCli(program); program .command("status") diff --git a/src/cli/skills-cli.test.ts b/src/cli/skills-cli.test.ts new file mode 100644 index 000000000..b8b5ad26b --- /dev/null +++ b/src/cli/skills-cli.test.ts @@ -0,0 +1,282 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; +import { + buildWorkspaceSkillStatus, + type SkillStatusEntry, + type SkillStatusReport, +} from "../agents/skills-status.js"; +import { + formatSkillInfo, + formatSkillsCheck, + formatSkillsList, +} from "./skills-cli.js"; + +function createMockSkill( + overrides: Partial = {}, +): SkillStatusEntry { + return { + name: "test-skill", + description: "A test skill", + source: "bundled", + filePath: "/path/to/SKILL.md", + baseDir: "/path/to", + skillKey: "test-skill", + emoji: "๐Ÿงช", + homepage: "https://example.com", + always: false, + disabled: false, + blockedByAllowlist: false, + eligible: true, + requirements: { + bins: [], + anyBins: [], + env: [], + config: [], + os: [], + }, + missing: { + bins: [], + anyBins: [], + env: [], + config: [], + os: [], + }, + configChecks: [], + install: [], + ...overrides, + }; +} + +function createMockReport(skills: SkillStatusEntry[]): SkillStatusReport { + return { + workspaceDir: "/workspace", + managedSkillsDir: "/managed", + skills, + }; +} + +describe("skills-cli", () => { + describe("formatSkillsList", () => { + it("formats empty skills list", () => { + const report = createMockReport([]); + const output = formatSkillsList(report, {}); + expect(output).toContain("No skills found"); + expect(output).toContain("npx clawdhub"); + }); + + it("formats skills list with eligible skill", () => { + const report = createMockReport([ + createMockSkill({ + name: "peekaboo", + description: "Capture UI screenshots", + emoji: "๐Ÿ“ธ", + eligible: true, + }), + ]); + const output = formatSkillsList(report, {}); + expect(output).toContain("peekaboo"); + expect(output).toContain("๐Ÿ“ธ"); + expect(output).toContain("โœ“"); + }); + + it("formats skills list with disabled skill", () => { + const report = createMockReport([ + createMockSkill({ + name: "disabled-skill", + disabled: true, + eligible: false, + }), + ]); + const output = formatSkillsList(report, {}); + expect(output).toContain("disabled-skill"); + expect(output).toContain("disabled"); + }); + + it("formats skills list with missing requirements", () => { + const report = createMockReport([ + createMockSkill({ + name: "needs-stuff", + eligible: false, + missing: { + bins: ["ffmpeg"], + anyBins: ["rg", "grep"], + env: ["API_KEY"], + config: [], + os: ["darwin"], + }, + }), + ]); + const output = formatSkillsList(report, { verbose: true }); + expect(output).toContain("needs-stuff"); + expect(output).toContain("missing"); + expect(output).toContain("anyBins"); + expect(output).toContain("os:"); + }); + + it("filters to eligible only with --eligible flag", () => { + const report = createMockReport([ + createMockSkill({ name: "eligible-one", eligible: true }), + createMockSkill({ + name: "not-eligible", + eligible: false, + disabled: true, + }), + ]); + const output = formatSkillsList(report, { eligible: true }); + expect(output).toContain("eligible-one"); + expect(output).not.toContain("not-eligible"); + }); + + it("outputs JSON with --json flag", () => { + const report = createMockReport([ + createMockSkill({ name: "json-skill" }), + ]); + const output = formatSkillsList(report, { json: true }); + const parsed = JSON.parse(output); + expect(parsed.skills).toHaveLength(1); + expect(parsed.skills[0].name).toBe("json-skill"); + }); + }); + + describe("formatSkillInfo", () => { + it("returns not found message for unknown skill", () => { + const report = createMockReport([]); + const output = formatSkillInfo(report, "unknown-skill", {}); + expect(output).toContain("not found"); + expect(output).toContain("npx clawdhub"); + }); + + it("shows detailed info for a skill", () => { + const report = createMockReport([ + createMockSkill({ + name: "detailed-skill", + description: "A detailed description", + homepage: "https://example.com", + requirements: { + bins: ["node"], + anyBins: ["rg", "grep"], + env: ["API_KEY"], + config: [], + os: [], + }, + missing: { + bins: [], + anyBins: [], + env: ["API_KEY"], + config: [], + os: [], + }, + }), + ]); + const output = formatSkillInfo(report, "detailed-skill", {}); + expect(output).toContain("detailed-skill"); + expect(output).toContain("A detailed description"); + expect(output).toContain("https://example.com"); + expect(output).toContain("node"); + expect(output).toContain("Any binaries"); + expect(output).toContain("API_KEY"); + }); + + it("outputs JSON with --json flag", () => { + const report = createMockReport([ + createMockSkill({ name: "info-skill" }), + ]); + const output = formatSkillInfo(report, "info-skill", { json: true }); + const parsed = JSON.parse(output); + expect(parsed.name).toBe("info-skill"); + }); + }); + + describe("formatSkillsCheck", () => { + it("shows summary of skill status", () => { + const report = createMockReport([ + createMockSkill({ name: "ready-1", eligible: true }), + createMockSkill({ name: "ready-2", eligible: true }), + createMockSkill({ + name: "not-ready", + eligible: false, + missing: { bins: ["go"], anyBins: [], env: [], config: [], os: [] }, + }), + createMockSkill({ name: "disabled", eligible: false, disabled: true }), + ]); + const output = formatSkillsCheck(report, {}); + expect(output).toContain("2"); // eligible count + expect(output).toContain("ready-1"); + expect(output).toContain("ready-2"); + expect(output).toContain("not-ready"); + expect(output).toContain("go"); // missing binary + expect(output).toContain("npx clawdhub"); + }); + + it("outputs JSON with --json flag", () => { + const report = createMockReport([ + createMockSkill({ name: "skill-1", eligible: true }), + createMockSkill({ name: "skill-2", eligible: false }), + ]); + const output = formatSkillsCheck(report, { json: true }); + const parsed = JSON.parse(output); + expect(parsed.summary.eligible).toBe(1); + expect(parsed.summary.total).toBe(2); + }); + }); + + describe("integration: loads real skills from bundled directory", () => { + function resolveBundledSkillsDir(): string | undefined { + const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + const root = path.resolve(moduleDir, "..", ".."); + const candidate = path.join(root, "skills"); + if (fs.existsSync(candidate)) return candidate; + return undefined; + } + + it("loads bundled skills and formats them", () => { + const bundledDir = resolveBundledSkillsDir(); + if (!bundledDir) { + // Skip if skills dir not found (e.g., in CI without skills) + return; + } + + const report = buildWorkspaceSkillStatus("/tmp", { + managedSkillsDir: "/nonexistent", + }); + + // Should have loaded some skills + expect(report.skills.length).toBeGreaterThan(0); + + // Format should work without errors + const listOutput = formatSkillsList(report, {}); + expect(listOutput).toContain("Skills"); + + const checkOutput = formatSkillsCheck(report, {}); + expect(checkOutput).toContain("Total:"); + + // JSON output should be valid + const jsonOutput = formatSkillsList(report, { json: true }); + const parsed = JSON.parse(jsonOutput); + expect(parsed.skills).toBeInstanceOf(Array); + }); + + it("formats info for a real bundled skill (peekaboo)", () => { + const bundledDir = resolveBundledSkillsDir(); + if (!bundledDir) return; + + const report = buildWorkspaceSkillStatus("/tmp", { + managedSkillsDir: "/nonexistent", + }); + + // peekaboo is a bundled skill that should always exist + const peekaboo = report.skills.find((s) => s.name === "peekaboo"); + if (!peekaboo) { + // Skip if peekaboo not found + return; + } + + const output = formatSkillInfo(report, "peekaboo", {}); + expect(output).toContain("peekaboo"); + expect(output).toContain("Details:"); + }); + }); +}); diff --git a/src/cli/skills-cli.ts b/src/cli/skills-cli.ts new file mode 100644 index 000000000..7fdb02785 --- /dev/null +++ b/src/cli/skills-cli.ts @@ -0,0 +1,420 @@ +import chalk from "chalk"; +import type { Command } from "commander"; +import { + buildWorkspaceSkillStatus, + type SkillStatusEntry, + type SkillStatusReport, +} from "../agents/skills-status.js"; +import { loadConfig } from "../config/config.js"; +import { defaultRuntime } from "../runtime.js"; + +export type SkillsListOptions = { + json?: boolean; + eligible?: boolean; + verbose?: boolean; +}; + +export type SkillInfoOptions = { + json?: boolean; +}; + +export type SkillsCheckOptions = { + json?: boolean; +}; + +function appendClawdHubHint(output: string, json?: boolean): string { + if (json) return output; + return `${output}\n\nTip: use \`npx clawdhub\` to search, install, and sync skills.`; +} + +/** + * Format a single skill for display in the list + */ +function formatSkillLine(skill: SkillStatusEntry, verbose = false): string { + const emoji = skill.emoji ?? "๐Ÿ“ฆ"; + const status = skill.eligible + ? chalk.green("โœ“") + : skill.disabled + ? chalk.yellow("disabled") + : skill.blockedByAllowlist + ? chalk.yellow("blocked") + : chalk.red("missing reqs"); + + const name = skill.eligible + ? chalk.white(skill.name) + : chalk.gray(skill.name); + + const desc = chalk.gray( + skill.description.length > 50 + ? `${skill.description.slice(0, 47)}...` + : skill.description, + ); + + if (verbose) { + const missing: string[] = []; + if (skill.missing.bins.length > 0) { + missing.push(`bins: ${skill.missing.bins.join(", ")}`); + } + if (skill.missing.anyBins.length > 0) { + missing.push(`anyBins: ${skill.missing.anyBins.join(", ")}`); + } + if (skill.missing.env.length > 0) { + missing.push(`env: ${skill.missing.env.join(", ")}`); + } + if (skill.missing.config.length > 0) { + missing.push(`config: ${skill.missing.config.join(", ")}`); + } + if (skill.missing.os.length > 0) { + missing.push(`os: ${skill.missing.os.join(", ")}`); + } + const missingStr = + missing.length > 0 ? chalk.red(` [${missing.join("; ")}]`) : ""; + return `${emoji} ${name} ${status}${missingStr}\n ${desc}`; + } + + return `${emoji} ${name} ${status} - ${desc}`; +} + +/** + * Format the skills list output + */ +export function formatSkillsList( + report: SkillStatusReport, + opts: SkillsListOptions, +): string { + const skills = opts.eligible + ? report.skills.filter((s) => s.eligible) + : report.skills; + + if (opts.json) { + const jsonReport = { + workspaceDir: report.workspaceDir, + managedSkillsDir: report.managedSkillsDir, + skills: skills.map((s) => ({ + name: s.name, + description: s.description, + emoji: s.emoji, + eligible: s.eligible, + disabled: s.disabled, + blockedByAllowlist: s.blockedByAllowlist, + source: s.source, + primaryEnv: s.primaryEnv, + homepage: s.homepage, + missing: s.missing, + })), + }; + return JSON.stringify(jsonReport, null, 2); + } + + if (skills.length === 0) { + const message = opts.eligible + ? "No eligible skills found. Run `clawdbot skills list` to see all skills." + : "No skills found."; + return appendClawdHubHint(message, opts.json); + } + + const eligible = skills.filter((s) => s.eligible); + const notEligible = skills.filter((s) => !s.eligible); + + const lines: string[] = []; + lines.push( + chalk.bold.cyan("Skills") + + chalk.gray(` (${eligible.length}/${skills.length} ready)`), + ); + lines.push(""); + + if (eligible.length > 0) { + lines.push(chalk.bold.green("Ready:")); + for (const skill of eligible) { + lines.push(` ${formatSkillLine(skill, opts.verbose)}`); + } + } + + if (notEligible.length > 0 && !opts.eligible) { + if (eligible.length > 0) lines.push(""); + lines.push(chalk.bold.yellow("Not ready:")); + for (const skill of notEligible) { + lines.push(` ${formatSkillLine(skill, opts.verbose)}`); + } + } + + return appendClawdHubHint(lines.join("\n"), opts.json); +} + +/** + * Format detailed info for a single skill + */ +export function formatSkillInfo( + report: SkillStatusReport, + skillName: string, + opts: SkillInfoOptions, +): string { + const skill = report.skills.find( + (s) => s.name === skillName || s.skillKey === skillName, + ); + + if (!skill) { + if (opts.json) { + return JSON.stringify({ error: "not found", skill: skillName }, null, 2); + } + return appendClawdHubHint( + `Skill "${skillName}" not found. Run \`clawdbot skills list\` to see available skills.`, + opts.json, + ); + } + + if (opts.json) { + return JSON.stringify(skill, null, 2); + } + + const lines: string[] = []; + const emoji = skill.emoji ?? "๐Ÿ“ฆ"; + const status = skill.eligible + ? chalk.green("โœ“ Ready") + : skill.disabled + ? chalk.yellow("โธ Disabled") + : skill.blockedByAllowlist + ? chalk.yellow("๐Ÿšซ Blocked by allowlist") + : chalk.red("โœ— Missing requirements"); + + lines.push(`${emoji} ${chalk.bold.cyan(skill.name)} ${status}`); + lines.push(""); + lines.push(chalk.white(skill.description)); + lines.push(""); + + // Details + lines.push(chalk.bold("Details:")); + lines.push(` Source: ${skill.source}`); + lines.push(` Path: ${chalk.gray(skill.filePath)}`); + if (skill.homepage) { + lines.push(` Homepage: ${chalk.blue(skill.homepage)}`); + } + if (skill.primaryEnv) { + lines.push(` Primary env: ${skill.primaryEnv}`); + } + + // Requirements + const hasRequirements = + skill.requirements.bins.length > 0 || + skill.requirements.anyBins.length > 0 || + skill.requirements.env.length > 0 || + skill.requirements.config.length > 0 || + skill.requirements.os.length > 0; + + if (hasRequirements) { + lines.push(""); + lines.push(chalk.bold("Requirements:")); + if (skill.requirements.bins.length > 0) { + const binsStatus = skill.requirements.bins.map((bin) => { + const missing = skill.missing.bins.includes(bin); + return missing ? chalk.red(`โœ— ${bin}`) : chalk.green(`โœ“ ${bin}`); + }); + lines.push(` Binaries: ${binsStatus.join(", ")}`); + } + if (skill.requirements.anyBins.length > 0) { + const anyBinsMissing = skill.missing.anyBins.length > 0; + const anyBinsStatus = skill.requirements.anyBins.map((bin) => { + const missing = anyBinsMissing; + return missing ? chalk.red(`โœ— ${bin}`) : chalk.green(`โœ“ ${bin}`); + }); + lines.push(` Any binaries: ${anyBinsStatus.join(", ")}`); + } + if (skill.requirements.env.length > 0) { + const envStatus = skill.requirements.env.map((env) => { + const missing = skill.missing.env.includes(env); + return missing ? chalk.red(`โœ— ${env}`) : chalk.green(`โœ“ ${env}`); + }); + lines.push(` Environment: ${envStatus.join(", ")}`); + } + if (skill.requirements.config.length > 0) { + const configStatus = skill.requirements.config.map((cfg) => { + const missing = skill.missing.config.includes(cfg); + return missing ? chalk.red(`โœ— ${cfg}`) : chalk.green(`โœ“ ${cfg}`); + }); + lines.push(` Config: ${configStatus.join(", ")}`); + } + if (skill.requirements.os.length > 0) { + const osStatus = skill.requirements.os.map((osName) => { + const missing = skill.missing.os.includes(osName); + return missing ? chalk.red(`โœ— ${osName}`) : chalk.green(`โœ“ ${osName}`); + }); + lines.push(` OS: ${osStatus.join(", ")}`); + } + } + + // Install options + if (skill.install.length > 0 && !skill.eligible) { + lines.push(""); + lines.push(chalk.bold("Install options:")); + for (const inst of skill.install) { + lines.push(` ${chalk.yellow("โ†’")} ${inst.label}`); + } + } + + return appendClawdHubHint(lines.join("\n"), opts.json); +} + +/** + * Format a check/summary of all skills status + */ +export function formatSkillsCheck( + report: SkillStatusReport, + opts: SkillsCheckOptions, +): string { + const eligible = report.skills.filter((s) => s.eligible); + const disabled = report.skills.filter((s) => s.disabled); + const blocked = report.skills.filter( + (s) => s.blockedByAllowlist && !s.disabled, + ); + const missingReqs = report.skills.filter( + (s) => !s.eligible && !s.disabled && !s.blockedByAllowlist, + ); + + if (opts.json) { + return JSON.stringify( + { + summary: { + total: report.skills.length, + eligible: eligible.length, + disabled: disabled.length, + blocked: blocked.length, + missingRequirements: missingReqs.length, + }, + eligible: eligible.map((s) => s.name), + disabled: disabled.map((s) => s.name), + blocked: blocked.map((s) => s.name), + missingRequirements: missingReqs.map((s) => ({ + name: s.name, + missing: s.missing, + install: s.install, + })), + }, + null, + 2, + ); + } + + const lines: string[] = []; + lines.push(chalk.bold.cyan("Skills Status Check")); + lines.push(""); + lines.push(`Total: ${report.skills.length}`); + lines.push(`${chalk.green("โœ“")} Eligible: ${eligible.length}`); + lines.push(`${chalk.yellow("โธ")} Disabled: ${disabled.length}`); + lines.push(`${chalk.yellow("๐Ÿšซ")} Blocked by allowlist: ${blocked.length}`); + lines.push(`${chalk.red("โœ—")} Missing requirements: ${missingReqs.length}`); + + if (eligible.length > 0) { + lines.push(""); + lines.push(chalk.bold.green("Ready to use:")); + for (const skill of eligible) { + const emoji = skill.emoji ?? "๐Ÿ“ฆ"; + lines.push(` ${emoji} ${skill.name}`); + } + } + + if (missingReqs.length > 0) { + lines.push(""); + lines.push(chalk.bold.red("Missing requirements:")); + for (const skill of missingReqs) { + const emoji = skill.emoji ?? "๐Ÿ“ฆ"; + const missing: string[] = []; + if (skill.missing.bins.length > 0) { + missing.push(`bins: ${skill.missing.bins.join(", ")}`); + } + if (skill.missing.anyBins.length > 0) { + missing.push(`anyBins: ${skill.missing.anyBins.join(", ")}`); + } + if (skill.missing.env.length > 0) { + missing.push(`env: ${skill.missing.env.join(", ")}`); + } + if (skill.missing.config.length > 0) { + missing.push(`config: ${skill.missing.config.join(", ")}`); + } + if (skill.missing.os.length > 0) { + missing.push(`os: ${skill.missing.os.join(", ")}`); + } + lines.push( + ` ${emoji} ${skill.name} ${chalk.gray(`(${missing.join("; ")})`)}`, + ); + } + } + + return appendClawdHubHint(lines.join("\n"), opts.json); +} + +/** + * Register the skills CLI commands + */ +export function registerSkillsCli(program: Command) { + const skills = program + .command("skills") + .description("List and inspect available skills"); + + skills + .command("list") + .description("List all available skills") + .option("--json", "Output as JSON", false) + .option("--eligible", "Show only eligible (ready to use) skills", false) + .option( + "-v, --verbose", + "Show more details including missing requirements", + false, + ) + .action(async (opts) => { + try { + const config = loadConfig(); + const workspaceDir = config.agent?.workspace ?? process.cwd(); + const report = buildWorkspaceSkillStatus(workspaceDir, { config }); + console.log(formatSkillsList(report, opts)); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); + + skills + .command("info") + .description("Show detailed information about a skill") + .argument("", "Skill name") + .option("--json", "Output as JSON", false) + .action(async (name, opts) => { + try { + const config = loadConfig(); + const workspaceDir = config.agent?.workspace ?? process.cwd(); + const report = buildWorkspaceSkillStatus(workspaceDir, { config }); + console.log(formatSkillInfo(report, name, opts)); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); + + skills + .command("check") + .description("Check which skills are ready vs missing requirements") + .option("--json", "Output as JSON", false) + .action(async (opts) => { + try { + const config = loadConfig(); + const workspaceDir = config.agent?.workspace ?? process.cwd(); + const report = buildWorkspaceSkillStatus(workspaceDir, { config }); + console.log(formatSkillsCheck(report, opts)); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); + + // Default action (no subcommand) - show list + skills.action(async () => { + try { + const config = loadConfig(); + const workspaceDir = config.agent?.workspace ?? process.cwd(); + const report = buildWorkspaceSkillStatus(workspaceDir, { config }); + console.log(formatSkillsList(report, {})); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); +}