feat: add skills CLI
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
282
src/cli/skills-cli.test.ts
Normal file
282
src/cli/skills-cli.test.ts
Normal file
@@ -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> = {},
|
||||
): 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:");
|
||||
});
|
||||
});
|
||||
});
|
||||
420
src/cli/skills-cli.ts
Normal file
420
src/cli/skills-cli.ts
Normal file
@@ -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("<name>", "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);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user