Files
openclaw/src/agents/skills-status.ts
2026-02-23 05:43:43 +00:00

254 lines
7.7 KiB
TypeScript

import path from "node:path";
import type { OpenClawConfig } from "../config/config.js";
import { evaluateEntryRequirementsForCurrentPlatform } from "../shared/entry-status.js";
import type { RequirementConfigCheck, Requirements } from "../shared/requirements.js";
import { CONFIG_DIR } from "../utils.js";
import {
hasBinary,
isBundledSkillAllowed,
isConfigPathTruthy,
loadWorkspaceSkillEntries,
resolveBundledAllowlist,
resolveSkillConfig,
resolveSkillsInstallPreferences,
type SkillEntry,
type SkillEligibilityContext,
type SkillInstallSpec,
type SkillsInstallPreferences,
} from "./skills.js";
import { resolveBundledSkillsContext } from "./skills/bundled-context.js";
export type SkillStatusConfigCheck = RequirementConfigCheck;
export type SkillInstallOption = {
id: string;
kind: SkillInstallSpec["kind"];
label: string;
bins: string[];
};
export type SkillStatusEntry = {
name: string;
description: string;
source: string;
bundled: boolean;
filePath: string;
baseDir: string;
skillKey: string;
primaryEnv?: string;
emoji?: string;
homepage?: string;
always: boolean;
disabled: boolean;
blockedByAllowlist: boolean;
eligible: boolean;
requirements: Requirements;
missing: Requirements;
configChecks: SkillStatusConfigCheck[];
install: SkillInstallOption[];
};
export type SkillStatusReport = {
workspaceDir: string;
managedSkillsDir: string;
skills: SkillStatusEntry[];
};
function resolveSkillKey(entry: SkillEntry): string {
return entry.metadata?.skillKey ?? entry.skill.name;
}
function selectPreferredInstallSpec(
install: SkillInstallSpec[],
prefs: SkillsInstallPreferences,
): { spec: SkillInstallSpec; index: number } | undefined {
if (install.length === 0) {
return undefined;
}
const indexed = install.map((spec, index) => ({ spec, index }));
const findKind = (kind: SkillInstallSpec["kind"]) =>
indexed.find((item) => item.spec.kind === kind);
const brewSpec = findKind("brew");
const nodeSpec = findKind("node");
const goSpec = findKind("go");
const uvSpec = findKind("uv");
const downloadSpec = findKind("download");
const brewAvailable = hasBinary("brew");
// Table-driven preference chain; first match wins.
const pickers: Array<() => { spec: SkillInstallSpec; index: number } | undefined> = [
() => (prefs.preferBrew && brewAvailable ? brewSpec : undefined),
() => uvSpec,
() => nodeSpec,
// Only prefer brew when available to avoid guaranteed failure on Linux/Docker.
() => (brewAvailable ? brewSpec : undefined),
() => goSpec,
// Prefer download over an unavailable brew spec.
() => downloadSpec,
// Last resort: surface descriptive brew-missing error instead of "no installer found".
() => brewSpec,
() => indexed[0],
];
for (const pick of pickers) {
const selected = pick();
if (selected) {
return selected;
}
}
return undefined;
}
function normalizeInstallOptions(
entry: SkillEntry,
prefs: SkillsInstallPreferences,
): SkillInstallOption[] {
// If the skill is explicitly OS-scoped, don't surface install actions on unsupported platforms.
// (Installers run locally; remote OS eligibility is handled separately.)
const requiredOs = entry.metadata?.os ?? [];
if (requiredOs.length > 0 && !requiredOs.includes(process.platform)) {
return [];
}
const install = entry.metadata?.install ?? [];
if (install.length === 0) {
return [];
}
const platform = process.platform;
const filtered = install.filter((spec) => {
const osList = spec.os ?? [];
return osList.length === 0 || osList.includes(platform);
});
if (filtered.length === 0) {
return [];
}
const toOption = (spec: SkillInstallSpec, index: number): SkillInstallOption => {
const id = (spec.id ?? `${spec.kind}-${index}`).trim();
const bins = spec.bins ?? [];
let label = (spec.label ?? "").trim();
if (spec.kind === "node" && spec.package) {
label = `Install ${spec.package} (${prefs.nodeManager})`;
}
if (!label) {
if (spec.kind === "brew" && spec.formula) {
label = `Install ${spec.formula} (brew)`;
} else if (spec.kind === "node" && spec.package) {
label = `Install ${spec.package} (${prefs.nodeManager})`;
} else if (spec.kind === "go" && spec.module) {
label = `Install ${spec.module} (go)`;
} else if (spec.kind === "uv" && spec.package) {
label = `Install ${spec.package} (uv)`;
} else if (spec.kind === "download" && spec.url) {
const url = spec.url.trim();
const last = url.split("/").pop();
label = `Download ${last && last.length > 0 ? last : url}`;
} else {
label = "Run installer";
}
}
return { id, kind: spec.kind, label, bins };
};
const allDownloads = filtered.every((spec) => spec.kind === "download");
if (allDownloads) {
return filtered.map((spec, index) => toOption(spec, index));
}
const preferred = selectPreferredInstallSpec(filtered, prefs);
if (!preferred) {
return [];
}
return [toOption(preferred.spec, preferred.index)];
}
function buildSkillStatus(
entry: SkillEntry,
config?: OpenClawConfig,
prefs?: SkillsInstallPreferences,
eligibility?: SkillEligibilityContext,
bundledNames?: Set<string>,
): SkillStatusEntry {
const skillKey = resolveSkillKey(entry);
const skillConfig = resolveSkillConfig(config, skillKey);
const disabled = skillConfig?.enabled === false;
const allowBundled = resolveBundledAllowlist(config);
const blockedByAllowlist = !isBundledSkillAllowed(entry, allowBundled);
const always = entry.metadata?.always === true;
const isEnvSatisfied = (envName: string) =>
Boolean(
process.env[envName] ||
skillConfig?.env?.[envName] ||
(skillConfig?.apiKey && entry.metadata?.primaryEnv === envName),
);
const isConfigSatisfied = (pathStr: string) => isConfigPathTruthy(config, pathStr);
const bundled =
bundledNames && bundledNames.size > 0
? bundledNames.has(entry.skill.name)
: entry.skill.source === "openclaw-bundled";
const { emoji, homepage, required, missing, requirementsSatisfied, configChecks } =
evaluateEntryRequirementsForCurrentPlatform({
always,
entry,
hasLocalBin: hasBinary,
remote: eligibility?.remote,
isEnvSatisfied,
isConfigSatisfied,
});
const eligible = !disabled && !blockedByAllowlist && requirementsSatisfied;
return {
name: entry.skill.name,
description: entry.skill.description,
source: entry.skill.source,
bundled,
filePath: entry.skill.filePath,
baseDir: entry.skill.baseDir,
skillKey,
primaryEnv: entry.metadata?.primaryEnv,
emoji,
homepage,
always,
disabled,
blockedByAllowlist,
eligible,
requirements: required,
missing,
configChecks,
install: normalizeInstallOptions(entry, prefs ?? resolveSkillsInstallPreferences(config)),
};
}
export function buildWorkspaceSkillStatus(
workspaceDir: string,
opts?: {
config?: OpenClawConfig;
managedSkillsDir?: string;
entries?: SkillEntry[];
eligibility?: SkillEligibilityContext;
},
): SkillStatusReport {
const managedSkillsDir = opts?.managedSkillsDir ?? path.join(CONFIG_DIR, "skills");
const bundledContext = resolveBundledSkillsContext();
const skillEntries =
opts?.entries ??
loadWorkspaceSkillEntries(workspaceDir, {
config: opts?.config,
managedSkillsDir,
bundledSkillsDir: bundledContext.dir,
});
const prefs = resolveSkillsInstallPreferences(opts?.config);
return {
workspaceDir,
managedSkillsDir,
skills: skillEntries.map((entry) =>
buildSkillStatus(entry, opts?.config, prefs, opts?.eligibility, bundledContext.names),
),
};
}