diff --git a/src/cli/skills-cli.format.ts b/src/cli/skills-cli.format.ts index 5f6dcfdcd2a..bce024676a8 100644 --- a/src/cli/skills-cli.format.ts +++ b/src/cli/skills-cli.format.ts @@ -1,4 +1,5 @@ import type { SkillStatusEntry, SkillStatusReport } from "../agents/skills-status.js"; +import type { SkillCapability } from "../agents/skills/types.js"; import { renderTable } from "../terminal/table.js"; import { theme } from "../terminal/theme.js"; import { shortenHomePath } from "../utils.js"; @@ -18,6 +19,37 @@ export type SkillsCheckOptions = { json?: boolean; }; +const CAPABILITY_ICONS: Record = { + shell: "shell", + filesystem: "filesystem", + network: "network", + browser: "browser", + sessions: "sessions", +}; + +function formatCapabilityTags(capabilities: SkillCapability[]): string { + if (capabilities.length === 0) { + return ""; + } + return capabilities.map((cap) => CAPABILITY_ICONS[cap] ?? cap).join(" "); +} + +function formatScanBadge(scanResult?: { severity: string }): string { + if (!scanResult) { + return ""; + } + switch (scanResult.severity) { + case "critical": + return theme.error("[blocked]"); + case "warn": + return theme.warn("[warn]"); + case "info": + return theme.muted("[notice]"); + default: + return ""; + } +} + function appendClawHubHint(output: string, json?: boolean): string { if (json) { return output; @@ -26,21 +58,19 @@ function appendClawHubHint(output: string, json?: boolean): string { } function formatSkillStatus(skill: SkillStatusEntry): string { + if (skill.scanResult?.severity === "critical") { + return theme.error("x blocked"); + } if (skill.eligible) { - return theme.success("✓ ready"); + return theme.success("+ ready"); } if (skill.disabled) { - return theme.warn("⏸ disabled"); + return theme.warn("- disabled"); } if (skill.blockedByAllowlist) { - return theme.warn("🚫 blocked"); + return theme.warn("x blocked"); } - return theme.error("✗ missing"); -} - -function formatSkillName(skill: SkillStatusEntry): string { - const emoji = skill.emoji ?? "📦"; - return `${emoji} ${theme.command(skill.name)}`; + return theme.error("x missing"); } function formatSkillMissingSummary(skill: SkillStatusEntry): string { @@ -82,6 +112,8 @@ export function formatSkillsList(report: SkillStatusReport, opts: SkillsListOpti primaryEnv: s.primaryEnv, homepage: s.homepage, missing: s.missing, + capabilities: s.capabilities, + scanResult: s.scanResult, })), }; return JSON.stringify(jsonReport, null, 2); @@ -95,13 +127,26 @@ export function formatSkillsList(report: SkillStatusReport, opts: SkillsListOpti } const eligible = skills.filter((s) => s.eligible); - const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const termWidth = process.stdout.columns ?? 120; + const tableWidth = Math.max(60, termWidth - 1); + const descLimit = opts.verbose ? 30 : 44; const rows = skills.map((skill) => { const missing = formatSkillMissingSummary(skill); + const caps = formatCapabilityTags(skill.capabilities); + const scan = formatScanBadge(skill.scanResult); + // Plain text name (no emoji) to avoid double-width alignment issues + const name = theme.command(skill.name); + const skillLabel = caps ? `${name} ${theme.muted(caps)}` : name; + // Truncate description as plain text BEFORE applying ANSI + const rawDesc = + skill.description.length > descLimit + ? skill.description.slice(0, descLimit - 1) + "..." + : skill.description; return { Status: formatSkillStatus(skill), - Skill: formatSkillName(skill), - Description: theme.muted(skill.description), + Skill: skillLabel, + Scan: scan, + Description: theme.muted(rawDesc), Source: skill.source ?? "", Missing: missing ? theme.warn(missing) : "", }; @@ -109,12 +154,13 @@ export function formatSkillsList(report: SkillStatusReport, opts: SkillsListOpti const columns = [ { key: "Status", header: "Status", minWidth: 10 }, - { key: "Skill", header: "Skill", minWidth: 18, flex: true }, - { key: "Description", header: "Description", minWidth: 24, flex: true }, + { key: "Skill", header: "Skill", minWidth: 16 }, + { key: "Description", header: "Description", minWidth: 20, maxWidth: descLimit + 4 }, { key: "Source", header: "Source", minWidth: 10 }, ]; if (opts.verbose) { - columns.push({ key: "Missing", header: "Missing", minWidth: 18, flex: true }); + columns.push({ key: "Scan", header: "Scan", minWidth: 10 }); + columns.push({ key: "Missing", header: "Missing", minWidth: 14 }); } const lines: string[] = []; @@ -153,85 +199,165 @@ export function formatSkillInfo( return JSON.stringify(skill, null, 2); } - const lines: string[] = []; - const emoji = skill.emoji ?? "📦"; - const status = skill.eligible - ? theme.success("✓ Ready") - : skill.disabled - ? theme.warn("⏸ Disabled") - : skill.blockedByAllowlist - ? theme.warn("🚫 Blocked by allowlist") - : theme.error("✗ Missing requirements"); + const status = + skill.scanResult?.severity === "critical" + ? theme.error("x Blocked (security)") + : skill.eligible + ? theme.success("+ Ready") + : skill.disabled + ? theme.warn("- Disabled") + : skill.blockedByAllowlist + ? theme.warn("x Blocked by allowlist") + : theme.error("x Missing requirements"); - lines.push(`${emoji} ${theme.heading(skill.name)} ${status}`); + const lines: string[] = []; + lines.push(`${theme.heading(skill.name)} ${status}`); lines.push(""); lines.push(skill.description); - lines.push(""); - lines.push(theme.heading("Details:")); - lines.push(`${theme.muted(" Source:")} ${skill.source}`); - lines.push(`${theme.muted(" Path:")} ${shortenHomePath(skill.filePath)}`); + // Details table + const detailRows: Array> = [ + { Field: "Source", Value: skill.source }, + { Field: "Path", Value: shortenHomePath(skill.filePath) }, + ]; if (skill.homepage) { - lines.push(`${theme.muted(" Homepage:")} ${skill.homepage}`); + detailRows.push({ Field: "Homepage", Value: skill.homepage }); } if (skill.primaryEnv) { - lines.push(`${theme.muted(" Primary env:")} ${skill.primaryEnv}`); + detailRows.push({ Field: "Primary env", Value: skill.primaryEnv }); } + lines.push(""); + lines.push( + renderTable({ + columns: [ + { key: "Field", header: "Detail", minWidth: 12 }, + { key: "Value", header: "Value", minWidth: 20 }, + ], + rows: detailRows, + }).trimEnd(), + ); - 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) { + // Capabilities table + if (skill.capabilities.length > 0) { + const capLabels: Record = { + shell: "Run shell commands", + filesystem: "Read and write files", + network: "Make outbound HTTP requests", + browser: "Control browser sessions", + sessions: "Spawn sub-sessions and agents", + }; + const capRows = skill.capabilities.map((cap) => ({ + Capability: CAPABILITY_ICONS[cap] ?? cap, + Name: cap, + Description: capLabels[cap] ?? cap, + })); lines.push(""); - lines.push(theme.heading("Requirements:")); - if (skill.requirements.bins.length > 0) { - const binsStatus = skill.requirements.bins.map((bin) => { - const missing = skill.missing.bins.includes(bin); - return missing ? theme.error(`✗ ${bin}`) : theme.success(`✓ ${bin}`); - }); - lines.push(`${theme.muted(" 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 ? theme.error(`✗ ${bin}`) : theme.success(`✓ ${bin}`); - }); - lines.push(`${theme.muted(" 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 ? theme.error(`✗ ${env}`) : theme.success(`✓ ${env}`); - }); - lines.push(`${theme.muted(" 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 ? theme.error(`✗ ${cfg}`) : theme.success(`✓ ${cfg}`); - }); - lines.push(`${theme.muted(" 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 ? theme.error(`✗ ${osName}`) : theme.success(`✓ ${osName}`); - }); - lines.push(`${theme.muted(" OS:")} ${osStatus.join(", ")}`); - } + lines.push(theme.heading("Capabilities")); + lines.push( + renderTable({ + columns: [ + { key: "Capability", header: "Icon", minWidth: 6 }, + { key: "Name", header: "Capability", minWidth: 12 }, + { key: "Description", header: "Description", minWidth: 20 }, + ], + rows: capRows, + }).trimEnd(), + ); } + // Security table + if (skill.scanResult) { + const scanBadge = formatScanBadge(skill.scanResult); + const secRows: Array> = [ + { Field: "Scan", Value: scanBadge || theme.success("+ clean") }, + ]; + lines.push(""); + lines.push(theme.heading("Security")); + lines.push( + renderTable({ + columns: [ + { key: "Field", header: "Check", minWidth: 10 }, + { key: "Value", header: "Result", minWidth: 14 }, + ], + rows: secRows, + }).trimEnd(), + ); + } + + // Requirements table + const reqRows: Array> = []; + for (const bin of skill.requirements.bins) { + const ok = !skill.missing.bins.includes(bin); + reqRows.push({ + Type: "bin", + Name: bin, + Status: ok ? theme.success("+ ok") : theme.error("x missing"), + }); + } + for (const bin of skill.requirements.anyBins) { + const ok = skill.missing.anyBins.length === 0; + reqRows.push({ + Type: "anyBin", + Name: bin, + Status: ok ? theme.success("+ ok") : theme.error("x missing"), + }); + } + for (const env of skill.requirements.env) { + const ok = !skill.missing.env.includes(env); + reqRows.push({ + Type: "env", + Name: env, + Status: ok ? theme.success("+ ok") : theme.error("x missing"), + }); + } + for (const cfg of skill.requirements.config) { + const ok = !skill.missing.config.includes(cfg); + reqRows.push({ + Type: "config", + Name: cfg, + Status: ok ? theme.success("+ ok") : theme.error("x missing"), + }); + } + for (const osName of skill.requirements.os) { + const ok = !skill.missing.os.includes(osName); + reqRows.push({ + Type: "os", + Name: osName, + Status: ok ? theme.success("+ ok") : theme.error("x missing"), + }); + } + if (reqRows.length > 0) { + lines.push(""); + lines.push(theme.heading("Requirements")); + lines.push( + renderTable({ + columns: [ + { key: "Type", header: "Type", minWidth: 8 }, + { key: "Name", header: "Name", minWidth: 14 }, + { key: "Status", header: "Status", minWidth: 10 }, + ], + rows: reqRows, + }).trimEnd(), + ); + } + + // Install options table if (skill.install.length > 0 && !skill.eligible) { + const installRows = skill.install.map((inst) => ({ + Kind: inst.kind, + Label: inst.label, + })); lines.push(""); - lines.push(theme.heading("Install options:")); - for (const inst of skill.install) { - lines.push(` ${theme.warn("→")} ${inst.label}`); - } + lines.push(theme.heading("Install options")); + lines.push( + renderTable({ + columns: [ + { key: "Kind", header: "Kind", minWidth: 8 }, + { key: "Label", header: "Action", minWidth: 20 }, + ], + rows: installRows, + }).trimEnd(), + ); } return appendClawHubHint(lines.join("\n"), opts.json); @@ -271,22 +397,113 @@ export function formatSkillsCheck(report: SkillStatusReport, opts: SkillsCheckOp const lines: string[] = []; lines.push(theme.heading("Skills Status Check")); - lines.push(""); - lines.push(`${theme.muted("Total:")} ${report.skills.length}`); - lines.push(`${theme.success("✓")} ${theme.muted("Eligible:")} ${eligible.length}`); - lines.push(`${theme.warn("⏸")} ${theme.muted("Disabled:")} ${disabled.length}`); - lines.push(`${theme.warn("🚫")} ${theme.muted("Blocked by allowlist:")} ${blocked.length}`); - lines.push(`${theme.error("✗")} ${theme.muted("Missing requirements:")} ${missingReqs.length}`); - if (eligible.length > 0) { - lines.push(""); - lines.push(theme.heading("Ready to use:")); - for (const skill of eligible) { - const emoji = skill.emoji ?? "📦"; - lines.push(` ${emoji} ${skill.name}`); + // Summary table + const summaryRows = [ + { Metric: "Total", Count: String(report.skills.length) }, + { Metric: theme.success("Eligible"), Count: String(eligible.length) }, + { Metric: theme.warn("Disabled"), Count: String(disabled.length) }, + { Metric: theme.warn("Blocked (allowlist)"), Count: String(blocked.length) }, + { Metric: theme.error("Missing requirements"), Count: String(missingReqs.length) }, + ]; + lines.push( + renderTable({ + columns: [ + { key: "Metric", header: "Status", minWidth: 20 }, + { key: "Count", header: "Count", minWidth: 6 }, + ], + rows: summaryRows, + }).trimEnd(), + ); + + // Capability summary for community skills + const communitySkills = report.skills.filter( + (s) => s.source === "openclaw-managed" && !s.bundled, + ); + if (communitySkills.length > 0) { + const capCounts = new Map(); + for (const skill of communitySkills) { + for (const cap of skill.capabilities) { + const list = capCounts.get(cap) ?? []; + list.push(skill.name); + capCounts.set(cap, list); + } + } + if (capCounts.size > 0) { + const capRows = [...capCounts.entries()].map(([cap, names]) => ({ + Icon: CAPABILITY_ICONS[cap] ?? cap, + Capability: cap, + Count: String(names.length), + Skills: names.join(", "), + })); + lines.push(""); + lines.push(theme.heading("Community skill capabilities")); + lines.push( + renderTable({ + columns: [ + { key: "Icon", header: "Icon", minWidth: 5 }, + { key: "Capability", header: "Capability", minWidth: 12 }, + { key: "Count", header: "#", minWidth: 4 }, + { key: "Skills", header: "Skills", minWidth: 16 }, + ], + rows: capRows, + }).trimEnd(), + ); + } + + // Scan results summary + const scanClean = communitySkills.filter( + (s) => !s.scanResult || s.scanResult.severity === "clean", + ).length; + const scanWarn = communitySkills.filter((s) => s.scanResult?.severity === "warn").length; + const scanBlocked = communitySkills.filter((s) => s.scanResult?.severity === "critical").length; + if (scanWarn > 0 || scanBlocked > 0) { + const scanRows = [ + { Result: theme.success("Clean"), Count: String(scanClean) }, + ...(scanWarn > 0 ? [{ Result: theme.warn("Warning"), Count: String(scanWarn) }] : []), + ...(scanBlocked > 0 + ? [{ Result: theme.error("Blocked"), Count: String(scanBlocked) }] + : []), + ]; + lines.push(""); + lines.push(theme.heading("Scan results")); + lines.push( + renderTable({ + columns: [ + { key: "Result", header: "Result", minWidth: 10 }, + { key: "Count", header: "#", minWidth: 4 }, + ], + rows: scanRows, + }).trimEnd(), + ); } } + // Ready skills table + if (eligible.length > 0) { + const readyRows = eligible.map((skill) => { + const caps = formatCapabilityTags(skill.capabilities); + return { + Skill: theme.command(skill.name), + Caps: caps ? theme.muted(caps) : "", + Source: skill.source, + }; + }); + lines.push(""); + lines.push(theme.heading("Ready to use")); + lines.push( + renderTable({ + columns: [ + { key: "Skill", header: "Skill", minWidth: 16 }, + { key: "Caps", header: "Caps", minWidth: 8 }, + { key: "Source", header: "Source", minWidth: 10 }, + ], + rows: readyRows, + }).trimEnd(), + ); + } + + // Missing requirements if (missingReqs.length > 0) { lines.push(""); lines.push(theme.heading("Missing requirements:"));