From f2913d022879ba42af75a94c121a87d0fcae9055 Mon Sep 17 00:00:00 2001 From: Mariano Belinky Date: Sat, 2 May 2026 20:12:29 +0200 Subject: [PATCH] Add doctor check for unavailable skills --- docs/cli/doctor.md | 1 + docs/gateway/doctor.md | 1 + src/commands/doctor-skills.test.ts | 110 +++++++++++++++++ src/commands/doctor-skills.ts | 111 ++++++++++++++++++ src/flows/doctor-health-contributions.test.ts | 7 ++ src/flows/doctor-health-contributions.ts | 13 ++ 6 files changed, 243 insertions(+) create mode 100644 src/commands/doctor-skills.test.ts create mode 100644 src/commands/doctor-skills.ts diff --git a/docs/cli/doctor.md b/docs/cli/doctor.md index 8068961b848..095c66f64d1 100644 --- a/docs/cli/doctor.md +++ b/docs/cli/doctor.md @@ -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..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. diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index d97177f32d3..80a409a4c67 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -122,6 +122,7 @@ cat ~/.openclaw/openclaw.json - 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). diff --git a/src/commands/doctor-skills.test.ts b/src/commands/doctor-skills.test.ts new file mode 100644 index 00000000000..d4426ff164b --- /dev/null +++ b/src/commands/doctor-skills.test.ts @@ -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 { + 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" } }); + }); +}); diff --git a/src/commands/doctor-skills.ts b/src/commands/doctor-skills.ts new file mode 100644 index 00000000000..764c9f189ab --- /dev/null +++ b/src/commands/doctor-skills.ts @@ -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 ")} or ${formatCliCommand("openclaw skills info --agent ")}`, + ); + 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 { + 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; +} diff --git a/src/flows/doctor-health-contributions.test.ts b/src/flows/doctor-health-contributions.test.ts index eb3e8f6b9fb..d065ba2eef9 100644 --- a/src/flows/doctor-health-contributions.test.ts +++ b/src/flows/doctor-health-contributions.test.ts @@ -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({ diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index 36b9f2bbf9f..9206c18778a 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -468,6 +468,14 @@ async function runWorkspaceStatusHealth(ctx: DoctorHealthFlowContext): Promise { + const { maybeRepairSkillReadiness } = await import("../commands/doctor-skills.js"); + ctx.cfg = await maybeRepairSkillReadiness({ + cfg: ctx.cfg, + prompter: ctx.prompter, + }); +} + async function runBootstrapSizeHealth(ctx: DoctorHealthFlowContext): Promise { 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",