mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-23 14:48:12 +00:00
cli: format skills capability output
This commit is contained in:
@@ -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<SkillCapability, string> = {
|
||||
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<Record<string, string>> = [
|
||||
{ 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<SkillCapability, string> = {
|
||||
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<Record<string, string>> = [
|
||||
{ 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<Record<string, string>> = [];
|
||||
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<SkillCapability, string[]>();
|
||||
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:"));
|
||||
|
||||
Reference in New Issue
Block a user