mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 16:10:49 +00:00
Add doctor check for unavailable skills
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
110
src/commands/doctor-skills.test.ts
Normal file
110
src/commands/doctor-skills.test.ts
Normal 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" } });
|
||||
});
|
||||
});
|
||||
111
src/commands/doctor-skills.ts
Normal file
111
src/commands/doctor-skills.ts
Normal 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;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user