Add doctor check for unavailable skills

This commit is contained in:
Mariano Belinky
2026-05-02 20:12:29 +02:00
parent 971624792a
commit f2913d0228
6 changed files with 243 additions and 0 deletions

View File

@@ -55,6 +55,7 @@ Notes:
- Doctor includes a memory-search readiness check and can recommend `openclaw configure --section model` when embedding credentials are missing.
- Doctor warns when no command owner is configured. The command owner is the human operator account allowed to run owner-only commands and approve dangerous actions. DM pairing only lets someone talk to the bot; if you approved a sender before first-owner bootstrap existed, set `commands.ownerAllowFrom` explicitly.
- Doctor warns when Codex-mode agents are configured and personal Codex CLI assets exist in the operator's Codex home. Local Codex app-server launches use isolated per-agent homes, so use `openclaw migrate codex --dry-run` to inventory assets that should be promoted deliberately.
- Doctor warns when skills allowed for the default agent are unavailable in the current runtime environment because bins, env vars, config, or OS requirements are missing. `doctor --fix` can disable those unavailable skills with `skills.entries.<skill>.enabled=false`; install/configure the missing requirement instead when you want to keep the skill active.
- If sandbox mode is enabled but Docker is unavailable, doctor reports a high-signal warning with remediation (`install Docker` or `openclaw config set agents.defaults.sandbox.mode off`).
- If `gateway.auth.token`/`gateway.auth.password` are SecretRef-managed and unavailable in the current command path, doctor reports a read-only warning and does not write plaintext fallback credentials.
- If channel SecretRef inspection fails in a fix path, doctor continues and reports a warning instead of exiting early.

View File

@@ -122,6 +122,7 @@ cat ~/.openclaw/openclaw.json
<Accordion title="Workspace and shell">
- systemd linger check on Linux.
- Workspace bootstrap file size check (truncation/near-limit warnings for context files).
- Skills readiness check for the default agent; reports allowed skills with missing bins, env, config, or OS requirements, and `--fix` can disable unavailable skills in `skills.entries`.
- Shell completion status check and auto-install/upgrade.
- Memory search embedding provider readiness check (local model, remote API key, or QMD binary).
- Source install checks (pnpm workspace mismatch, missing UI assets, missing tsx binary).

View File

@@ -0,0 +1,110 @@
import { describe, expect, it } from "vitest";
import type { SkillStatusEntry, SkillStatusReport } from "../agents/skills-status.js";
import { createEmptyInstallChecks } from "../cli/requirements-test-fixtures.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
collectUnavailableAgentSkills,
disableUnavailableSkillsInConfig,
formatUnavailableSkillDoctorLines,
} from "./doctor-skills.js";
function createSkill(overrides: Partial<SkillStatusEntry>): SkillStatusEntry {
return {
name: "demo",
description: "Demo",
source: "test",
bundled: false,
filePath: "/tmp/demo/SKILL.md",
baseDir: "/tmp/demo",
skillKey: overrides.name ?? "demo",
always: false,
disabled: false,
blockedByAllowlist: false,
blockedByAgentFilter: false,
eligible: true,
modelVisible: true,
userInvocable: true,
commandVisible: true,
...createEmptyInstallChecks(),
...overrides,
};
}
function createReport(skills: SkillStatusEntry[]): SkillStatusReport {
return {
workspaceDir: "/tmp/ws",
managedSkillsDir: "/tmp/managed",
agentId: "main",
skills,
};
}
describe("doctor skills", () => {
it("collects only unavailable skills that this agent is allowed to use", () => {
const unavailable = createSkill({
name: "missing-bin",
eligible: false,
modelVisible: false,
commandVisible: false,
missing: { bins: ["tool"], anyBins: [], env: [], config: [], os: [] },
});
const report = createReport([
createSkill({ name: "ready" }),
unavailable,
createSkill({ name: "disabled", eligible: false, disabled: true }),
createSkill({ name: "agent-filtered", eligible: true, blockedByAgentFilter: true }),
createSkill({ name: "bundled-blocked", eligible: false, blockedByAllowlist: true }),
]);
expect(collectUnavailableAgentSkills(report)).toEqual([unavailable]);
});
it("formats actionable missing requirement lines without secret values", () => {
const lines = formatUnavailableSkillDoctorLines([
createSkill({
name: "places",
eligible: false,
missing: {
bins: ["goplaces"],
anyBins: [],
env: ["GOOGLE_MAPS_API_KEY"],
config: [],
os: [],
},
install: [
{
id: "brew",
kind: "brew",
label: "Install goplaces (brew)",
bins: ["goplaces"],
},
],
}),
]);
expect(lines.join("\n")).toContain("places: bins: goplaces; env: GOOGLE_MAPS_API_KEY");
expect(lines.join("\n")).toContain("install option: Install goplaces (brew)");
expect(lines.join("\n")).toContain("openclaw doctor --fix");
});
it("disables unavailable skills through skills.entries without dropping existing config", () => {
const config: OpenClawConfig = {
skills: {
entries: {
gog: { env: { EXISTING: "1" } },
other: { enabled: true },
},
},
};
const next = disableUnavailableSkillsInConfig(config, [
createSkill({ name: "gog", skillKey: "gog", eligible: false }),
createSkill({ name: "wacli", skillKey: "wacli", eligible: false }),
]);
expect(next.skills?.entries?.gog).toEqual({ env: { EXISTING: "1" }, enabled: false });
expect(next.skills?.entries?.wacli).toEqual({ enabled: false });
expect(next.skills?.entries?.other).toEqual({ enabled: true });
expect(config.skills?.entries?.gog).toEqual({ env: { EXISTING: "1" } });
});
});

View File

@@ -0,0 +1,111 @@
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import type { SkillStatusEntry, SkillStatusReport } from "../agents/skills-status.js";
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { note } from "../terminal/note.js";
import type { DoctorPrompter } from "./doctor-prompter.js";
export function collectUnavailableAgentSkills(report: SkillStatusReport): SkillStatusEntry[] {
return report.skills.filter(
(skill) =>
!skill.eligible &&
!skill.disabled &&
!skill.blockedByAllowlist &&
!skill.blockedByAgentFilter,
);
}
function formatMissingSummary(skill: SkillStatusEntry): string {
const missing: string[] = [];
if (skill.missing.bins.length > 0) {
missing.push(`bins: ${skill.missing.bins.join(", ")}`);
}
if (skill.missing.anyBins.length > 0) {
missing.push(`any bins: ${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(", ")}`);
}
return missing.join("; ") || "unknown requirement";
}
function formatInstallHints(skill: SkillStatusEntry): string[] {
if (skill.install.length === 0) {
return [];
}
return skill.install.slice(0, 2).map((entry) => ` install option: ${entry.label}`);
}
export function formatUnavailableSkillDoctorLines(skills: SkillStatusEntry[]): string[] {
const lines: string[] = [
"Some skills are allowed for this agent but are not usable in the current runtime environment.",
];
for (const skill of skills) {
lines.push(`- ${skill.name}: ${formatMissingSummary(skill)}`);
lines.push(...formatInstallHints(skill));
}
lines.push(`Disable unused skills: ${formatCliCommand("openclaw doctor --fix")}`);
lines.push(
`Inspect details: ${formatCliCommand("openclaw skills check --agent <id>")} or ${formatCliCommand("openclaw skills info <name> --agent <id>")}`,
);
return lines;
}
export function disableUnavailableSkillsInConfig(
config: OpenClawConfig,
skills: readonly SkillStatusEntry[],
): OpenClawConfig {
if (skills.length === 0) {
return config;
}
const entries = { ...(config.skills?.entries ?? {}) };
for (const skill of skills) {
entries[skill.skillKey] = {
...(entries[skill.skillKey] ?? {}),
enabled: false,
};
}
return {
...config,
skills: {
...config.skills,
entries,
},
};
}
export async function maybeRepairSkillReadiness(params: {
cfg: OpenClawConfig;
prompter: DoctorPrompter;
}): Promise<OpenClawConfig> {
const agentId = resolveDefaultAgentId(params.cfg);
const workspaceDir = resolveAgentWorkspaceDir(params.cfg, agentId);
const report = buildWorkspaceSkillStatus(workspaceDir, {
config: params.cfg,
agentId,
});
const unavailable = collectUnavailableAgentSkills(report);
if (unavailable.length === 0) {
return params.cfg;
}
note(formatUnavailableSkillDoctorLines(unavailable).join("\n"), "Skills");
const shouldDisable = await params.prompter.confirmAutoFix({
message: `Disable ${unavailable.length} unavailable skill${unavailable.length === 1 ? "" : "s"} in config?`,
initialValue: false,
});
if (!shouldDisable) {
return params.cfg;
}
const next = disableUnavailableSkillsInConfig(params.cfg, unavailable);
note(unavailable.map((skill) => `- Disabled ${skill.name}`).join("\n"), "Doctor changes");
return next;
}

View File

@@ -96,6 +96,13 @@ describe("doctor health contributions", () => {
expect(ids.indexOf("doctor:command-owner")).toBeLessThan(ids.indexOf("doctor:write-config"));
});
it("checks skill readiness before final config writes", () => {
const ids = resolveDoctorHealthContributions().map((entry) => entry.id);
expect(ids.indexOf("doctor:skills")).toBeGreaterThan(-1);
expect(ids.indexOf("doctor:skills")).toBeLessThan(ids.indexOf("doctor:write-config"));
});
it("skips doctor config writes under legacy update parents", () => {
expect(
shouldSkipLegacyUpdateDoctorConfigWrite({

View File

@@ -468,6 +468,14 @@ async function runWorkspaceStatusHealth(ctx: DoctorHealthFlowContext): Promise<v
noteWorkspaceStatus(ctx.cfg);
}
async function runSkillsHealth(ctx: DoctorHealthFlowContext): Promise<void> {
const { maybeRepairSkillReadiness } = await import("../commands/doctor-skills.js");
ctx.cfg = await maybeRepairSkillReadiness({
cfg: ctx.cfg,
prompter: ctx.prompter,
});
}
async function runBootstrapSizeHealth(ctx: DoctorHealthFlowContext): Promise<void> {
const { noteBootstrapFileSize } = await import("../commands/doctor-bootstrap-size.js");
await noteBootstrapFileSize(ctx.cfg);
@@ -712,6 +720,11 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] {
label: "Workspace status",
run: runWorkspaceStatusHealth,
}),
createDoctorHealthContribution({
id: "doctor:skills",
label: "Skills",
run: runSkillsHealth,
}),
createDoctorHealthContribution({
id: "doctor:bootstrap-size",
label: "Bootstrap size",