diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d1db822c12..0e14966b9dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -308,6 +308,7 @@ Docs: https://docs.openclaw.ai - Agents/status: resolve `session_status(sessionKey="current")` for sparse channel-plugin sessions after literal current lookups miss, so Scope, Slack, Discord, and other plugin-driven agents avoid retrying through `Unknown sessionKey: current`. Fixes #74141. (#72306) Thanks @bittoby. - Cron: retry recurring wake-now main-session jobs through temporary heartbeat busy skips before recording success, so queued cron events no longer appear as ok ghost runs while the main lane is still busy. Fixes #75964. (#76083) Thanks @kshetrajna12 and @xuruiray. - Providers/Google: keep Gemini thinking-signature-only stream chunks active during reasoning, so Gemini 3.1 Pro Preview replies no longer hit idle timeouts before visible text. Fixes #76071. (#76080) Thanks @marcoschierhorn and @zhangguiping-xydt. +- CLI/skills: show per-agent model and command visibility in `openclaw skills check --agent`, and let doctor report or disable unavailable skills allowed for the default agent. (#75983) Thanks @mbelinky. ## 2026.4.30 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/cli/skills.md b/docs/cli/skills.md index 7d279cf522f..a3949134769 100644 --- a/docs/cli/skills.md +++ b/docs/cli/skills.md @@ -38,8 +38,8 @@ openclaw skills info openclaw skills info --json openclaw skills info --agent openclaw skills check -openclaw skills check --json openclaw skills check --agent +openclaw skills check --json ``` `search`/`install`/`update` use ClawHub directly and install into the active @@ -63,6 +63,8 @@ Notes: - `--agent ` targets one configured agent workspace and overrides current working directory inference. - `update --all` only updates tracked ClawHub installs in the active workspace. +- `check --agent ` checks the selected agent's workspace and reports which + ready skills are actually visible to that agent's prompt or command surface. - `list` is the default action when no subcommand is provided. - `list`, `info`, and `check` write their rendered output to stdout. With `--json`, that means the machine-readable payload stays on stdout for pipes 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/docs/tools/skills.md b/docs/tools/skills.md index 37f1e0dc540..b9320c6b029 100644 --- a/docs/tools/skills.md +++ b/docs/tools/skills.md @@ -188,7 +188,9 @@ instructions to reference the skill folder path. When `true`, the skill is exposed as a user slash command. - When `true`, the skill is excluded from the model prompt (still available via user invocation). + When `true`, OpenClaw keeps the skill's instructions out of the agent's normal + prompt. The skill is still installed and can still be run explicitly as a + slash command when `user-invocable` is also `true`. When set to `tool`, the slash command bypasses the model and dispatches directly to a tool. diff --git a/src/agents/skills-status.test.ts b/src/agents/skills-status.test.ts index bef7d1acec6..8ce80855c96 100644 --- a/src/agents/skills-status.test.ts +++ b/src/agents/skills-status.test.ts @@ -74,8 +74,252 @@ describe("buildWorkspaceSkillStatus", () => { expect(check).toEqual({ path: "channels.discord.token", satisfied: true }); expect(check && "value" in check).toBe(false); }); + + it("reports prompt and command visibility separately from eligibility", () => { + const entry: SkillEntry = { + skill: createFixtureSkill({ + name: "background-only", + description: "test", + filePath: "/tmp/background-only/SKILL.md", + baseDir: "/tmp/background-only", + source: "test", + }), + frontmatter: {}, + invocation: { + userInvocable: false, + disableModelInvocation: true, + }, + }; + + const report = buildWorkspaceSkillStatus("/tmp/ws", { entries: [entry] }); + const skill = report.skills[0]; + expect(skill?.eligible).toBe(true); + expect(skill?.modelVisible).toBe(false); + expect(skill?.userInvocable).toBe(false); + expect(skill?.commandVisible).toBe(false); + }); + + it("uses default-visible exposure semantics when older entries omit exposure fields", () => { + const entry: SkillEntry = { + skill: createFixtureSkill({ + name: "legacy-exposure", + description: "test", + filePath: "/tmp/legacy-exposure/SKILL.md", + baseDir: "/tmp/legacy-exposure", + source: "test", + }), + frontmatter: {}, + exposure: { + includeInRuntimeRegistry: true, + } as SkillEntry["exposure"], + }; + + const report = buildWorkspaceSkillStatus("/tmp/ws", { entries: [entry] }); + const skill = report.skills[0]; + expect(skill?.eligible).toBe(true); + expect(skill?.modelVisible).toBe(true); + expect(skill?.userInvocable).toBe(true); + expect(skill?.commandVisible).toBe(true); + }); + + it("reports skills blocked by an agent skill filter", () => { + const alpha: SkillEntry = { + skill: createFixtureSkill({ + name: "alpha", + description: "test", + filePath: "/tmp/alpha/SKILL.md", + baseDir: "/tmp/alpha", + source: "test", + }), + frontmatter: {}, + }; + const beta: SkillEntry = { + skill: createFixtureSkill({ + name: "beta", + description: "test", + filePath: "/tmp/beta/SKILL.md", + baseDir: "/tmp/beta", + source: "test", + }), + frontmatter: {}, + }; + + const report = buildWorkspaceSkillStatus("/tmp/ws", { + entries: [alpha, beta], + agentId: "specialist", + config: { + agents: { + list: [{ id: "specialist", skills: ["alpha"] }], + }, + }, + }); + + expect(report.agentId).toBe("specialist"); + expect(report.agentSkillFilter).toEqual(["alpha"]); + expect(report.skills.find((skill) => skill.name === "alpha")?.blockedByAgentFilter).toBe(false); + expect(report.skills.find((skill) => skill.name === "alpha")?.modelVisible).toBe(true); + expect(report.skills.find((skill) => skill.name === "beta")?.blockedByAgentFilter).toBe(true); + expect(report.skills.find((skill) => skill.name === "beta")?.modelVisible).toBe(false); + }); + + it("classifies a mixed broken skill pack without flattening visibility reasons", () => { + const missingBin = "openclaw-test-definitely-missing-skill-bin"; + const report = buildWorkspaceSkillStatus("/tmp/ws", { + agentId: "specialist", + config: { + agents: { + list: [ + { + id: "specialist", + skills: [ + "ready", + "needs-bin", + "needs-env", + "prompt-hidden", + "slash-hidden", + "disabled", + "bundled-blocked", + ], + }, + ], + }, + skills: { + allowBundled: ["some-other-bundled-skill"], + entries: { + disabled: { enabled: false }, + }, + install: { + nodeManager: "pnpm", + }, + }, + }, + entries: [ + createEntry("ready"), + createEntry("needs-bin", { + metadata: { + requires: { bins: [missingBin] }, + install: [ + { + kind: "node", + package: "@openclaw/missing-skill-bin", + bins: [missingBin], + }, + ], + }, + }), + createEntry("needs-env", { + metadata: { + primaryEnv: "OPENCLAW_TEST_MISSING_SKILL_KEY", + requires: { env: ["OPENCLAW_TEST_MISSING_SKILL_KEY"] }, + }, + }), + createEntry("prompt-hidden", { + invocation: { + userInvocable: true, + disableModelInvocation: true, + }, + }), + createEntry("slash-hidden", { + invocation: { + userInvocable: false, + disableModelInvocation: false, + }, + }), + createEntry("agent-filtered"), + createEntry("disabled"), + createEntry("bundled-blocked", { source: "openclaw-bundled" }), + ], + }); + + const byName = new Map(report.skills.map((skill) => [skill.name, skill])); + expect(report.agentSkillFilter).toEqual([ + "ready", + "needs-bin", + "needs-env", + "prompt-hidden", + "slash-hidden", + "disabled", + "bundled-blocked", + ]); + expect(byName.get("ready")).toMatchObject({ + eligible: true, + modelVisible: true, + commandVisible: true, + }); + expect(byName.get("needs-bin")).toMatchObject({ + eligible: false, + modelVisible: false, + commandVisible: false, + missing: { bins: [missingBin] }, + install: [ + { + kind: "node", + label: "Install @openclaw/missing-skill-bin (pnpm)", + bins: [missingBin], + }, + ], + }); + expect(byName.get("needs-env")).toMatchObject({ + eligible: false, + primaryEnv: "OPENCLAW_TEST_MISSING_SKILL_KEY", + missing: { env: ["OPENCLAW_TEST_MISSING_SKILL_KEY"] }, + }); + expect(byName.get("prompt-hidden")).toMatchObject({ + eligible: true, + modelVisible: false, + commandVisible: true, + }); + expect(byName.get("slash-hidden")).toMatchObject({ + eligible: true, + modelVisible: true, + userInvocable: false, + commandVisible: false, + }); + expect(byName.get("agent-filtered")).toMatchObject({ + eligible: true, + blockedByAgentFilter: true, + modelVisible: false, + commandVisible: false, + }); + expect(byName.get("disabled")).toMatchObject({ + eligible: false, + disabled: true, + modelVisible: false, + commandVisible: false, + }); + expect(byName.get("bundled-blocked")).toMatchObject({ + eligible: false, + blockedByAllowlist: true, + modelVisible: false, + commandVisible: false, + }); + }); }); +function createEntry( + name: string, + params: { + description?: string; + source?: string; + metadata?: SkillEntry["metadata"]; + invocation?: SkillEntry["invocation"]; + } = {}, +): SkillEntry { + const baseDir = `/tmp/${name}`; + return { + skill: createFixtureSkill({ + name, + description: params.description ?? `${name} skill`, + filePath: `${baseDir}/SKILL.md`, + baseDir, + source: params.source ?? "test", + }), + frontmatter: {}, + metadata: params.metadata, + invocation: params.invocation, + }; +} + function createFixtureSkill(params: { name: string; description: string; diff --git a/src/agents/skills-status.ts b/src/agents/skills-status.ts index fb839f56d60..4d2e626a903 100644 --- a/src/agents/skills-status.ts +++ b/src/agents/skills-status.ts @@ -16,6 +16,7 @@ import { type SkillInstallSpec, type SkillsInstallPreferences, } from "./skills.js"; +import { resolveEffectiveAgentSkillFilter } from "./skills/agent-filter.js"; import { resolveBundledSkillsContext } from "./skills/bundled-context.js"; import { resolveSkillSource } from "./skills/source.js"; @@ -42,7 +43,11 @@ export type SkillStatusEntry = { always: boolean; disabled: boolean; blockedByAllowlist: boolean; + blockedByAgentFilter: boolean; eligible: boolean; + modelVisible: boolean; + userInvocable: boolean; + commandVisible: boolean; requirements: Requirements; missing: Requirements; configChecks: SkillStatusConfigCheck[]; @@ -52,6 +57,8 @@ export type SkillStatusEntry = { export type SkillStatusReport = { workspaceDir: string; managedSkillsDir: string; + agentId?: string; + agentSkillFilter?: string[]; skills: SkillStatusEntry[]; }; @@ -167,18 +174,44 @@ function normalizeInstallOptions( return [toOption(preferred.spec, preferred.index)]; } +function isSkillVisibleInAvailableSkillsPrompt(entry: SkillEntry): boolean { + if (entry.exposure) { + return ( + entry.exposure.includeInAvailableSkillsPrompt || + !("includeInAvailableSkillsPrompt" in entry.exposure) + ); + } + if (entry.invocation) { + return !entry.invocation.disableModelInvocation; + } + return !entry.skill.disableModelInvocation; +} + +function isSkillUserInvocable(entry: SkillEntry): boolean { + if (entry.exposure) { + return entry.exposure.userInvocable || !("userInvocable" in entry.exposure); + } + if (entry.invocation) { + return entry.invocation.userInvocable || !("userInvocable" in entry.invocation); + } + return true; +} + function buildSkillStatus( entry: SkillEntry, config?: OpenClawConfig, prefs?: SkillsInstallPreferences, eligibility?: SkillEligibilityContext, bundledNames?: Set, + agentSkillFilter?: 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 blockedByAgentFilter = + agentSkillFilter !== undefined && !agentSkillFilter.includes(entry.skill.name); const always = entry.metadata?.always === true; const isEnvSatisfied = (envName: string) => Boolean( @@ -202,6 +235,8 @@ function buildSkillStatus( isConfigSatisfied, }); const eligible = !disabled && !blockedByAllowlist && requirementsSatisfied; + const availableToAgent = eligible && !blockedByAgentFilter; + const userInvocable = isSkillUserInvocable(entry); return { name: entry.skill.name, @@ -217,7 +252,11 @@ function buildSkillStatus( always, disabled, blockedByAllowlist, + blockedByAgentFilter, eligible, + modelVisible: availableToAgent && isSkillVisibleInAvailableSkillsPrompt(entry), + userInvocable, + commandVisible: availableToAgent && userInvocable, requirements: required, missing, configChecks, @@ -232,10 +271,14 @@ export function buildWorkspaceSkillStatus( managedSkillsDir?: string; entries?: SkillEntry[]; eligibility?: SkillEligibilityContext; + agentId?: string; }, ): SkillStatusReport { const managedSkillsDir = opts?.managedSkillsDir ?? path.join(CONFIG_DIR, "skills"); const bundledContext = resolveBundledSkillsContext(); + const agentSkillFilter = opts?.agentId + ? resolveEffectiveAgentSkillFilter(opts.config, opts.agentId) + : undefined; const skillEntries = opts?.entries ?? loadWorkspaceSkillEntries(workspaceDir, { @@ -247,8 +290,17 @@ export function buildWorkspaceSkillStatus( return { workspaceDir, managedSkillsDir, + agentId: opts?.agentId, + agentSkillFilter, skills: skillEntries.map((entry) => - buildSkillStatus(entry, opts?.config, prefs, opts?.eligibility, bundledContext.names), + buildSkillStatus( + entry, + opts?.config, + prefs, + opts?.eligibility, + bundledContext.names, + agentSkillFilter, + ), ), }; } diff --git a/src/cli/command-path-policy.test.ts b/src/cli/command-path-policy.test.ts index bd95dbc224b..ba77dffdc5b 100644 --- a/src/cli/command-path-policy.test.ts +++ b/src/cli/command-path-policy.test.ts @@ -184,6 +184,8 @@ describe("command-path-policy", () => { expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "skills", "info", "browser"])).toBe( "bypass", ); + expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "skills", "check"])).toBe("bypass"); + expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "skills", "list"])).toBe("bypass"); expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "skills", "search", "browser"])).toBe( "default", ); diff --git a/src/cli/run-main.exit.test.ts b/src/cli/run-main.exit.test.ts index 63a858c8b96..1be30a2a709 100644 --- a/src/cli/run-main.exit.test.ts +++ b/src/cli/run-main.exit.test.ts @@ -362,6 +362,9 @@ describe("runCli exit behavior", () => { ["agents list", ["node", "openclaw", "agents", "list"]], ["models list", ["node", "openclaw", "models", "list"]], ["models status without live probe", ["node", "openclaw", "models", "status"]], + ["skills check", ["node", "openclaw", "skills", "check"]], + ["skills info", ["node", "openclaw", "skills", "info", "weather"]], + ["skills list", ["node", "openclaw", "skills", "list"]], ["tasks list", ["node", "openclaw", "tasks", "list"]], ["legacy singular tool namespace", ["node", "openclaw", "tool", "image_generate"]], ["gateway tools namespace typo", ["node", "openclaw", "tools", "effective"]], @@ -386,6 +389,16 @@ describe("runCli exit behavior", () => { expect(startProxyMock).toHaveBeenCalledWith(undefined); }); + it("does not install the env proxy dispatcher for bypassed skills inspection commands", async () => { + hasEnvHttpProxyAgentConfiguredMock.mockReturnValue(true); + tryRouteCliMock.mockResolvedValueOnce(true); + + await runCli(["node", "openclaw", "skills", "check"]); + + expect(hasEnvHttpProxyAgentConfiguredMock).not.toHaveBeenCalled(); + expect(ensureGlobalUndiciEnvProxyDispatcherMock).not.toHaveBeenCalled(); + }); + it.each([ ["tool", ["node", "openclaw", "tool", "image_generate"]], ["tools", ["node", "openclaw", "tools", "effective"]], diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index f27c6b10c96..84e703ac919 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -265,6 +265,7 @@ function shouldBootstrapCliProxyBeforeFastPath(env: NodeJS.ProcessEnv = process. async function bootstrapCliProxyCaptureAndDispatcher( startupTrace: ReturnType, + options: { ensureDispatcher?: boolean } = {}, ): Promise { const [ { initializeDebugProxyCapture, finalizeDebugProxyCapture }, @@ -276,7 +277,9 @@ async function bootstrapCliProxyCaptureAndDispatcher( process.once("exit", () => { finalizeDebugProxyCapture(); }); - await startupTrace.measure("proxy-dispatcher", () => ensureCliEnvProxyDispatcher()); + if (options.ensureDispatcher !== false) { + await startupTrace.measure("proxy-dispatcher", () => ensureCliEnvProxyDispatcher()); + } maybeWarnAboutDebugProxyCoverage(); } @@ -440,7 +443,9 @@ export async function runCli(argv: string[] = process.argv) { return; } - const bootstrapProxyBeforeFastPath = shouldBootstrapCliProxyBeforeFastPath(); + const shouldUseCliEnvProxy = shouldStartProxyForCli(normalizedArgv); + const bootstrapProxyBeforeFastPath = + shouldUseCliEnvProxy && shouldBootstrapCliProxyBeforeFastPath(); if ( !bootstrapProxyBeforeFastPath && (await tryRunGatewayRunFastPath(normalizedArgv, startupTrace)) @@ -448,7 +453,9 @@ export async function runCli(argv: string[] = process.argv) { return; } - await bootstrapCliProxyCaptureAndDispatcher(startupTrace); + await bootstrapCliProxyCaptureAndDispatcher(startupTrace, { + ensureDispatcher: shouldUseCliEnvProxy, + }); if ( bootstrapProxyBeforeFastPath && diff --git a/src/cli/skills-cli.commands.test.ts b/src/cli/skills-cli.commands.test.ts index b1792f29293..2829f8a418e 100644 --- a/src/cli/skills-cli.commands.test.ts +++ b/src/cli/skills-cli.commands.test.ts @@ -59,6 +59,9 @@ const mocks = vi.hoisted(() => { runtimeStdout.push(JSON.stringify(value, null, space > 0 ? space : undefined)); }), exit: vi.fn((code: number) => { + if (code === 0) { + return; + } throw new Error(`__exit__:${code}`); }), }; @@ -142,7 +145,16 @@ describe("skills cli commands", () => { return program; }; - const runCommand = (argv: string[]) => createProgram().parseAsync(argv, { from: "user" }); + const runCommand = async (argv: string[]) => { + try { + await createProgram().parseAsync(argv, { from: "user" }); + } catch (error) { + if (error instanceof Error && error.message === "__exit__:0") { + return; + } + throw error; + } + }; beforeEach(() => { runtimeLogs.length = 0; @@ -414,9 +426,10 @@ describe("skills cli commands", () => { ])("routes skills $label JSON output through stdout", async ({ argv, assert }) => { await runCommand(argv); - expect(buildWorkspaceSkillStatusMock).toHaveBeenCalledWith("/tmp/workspace", { - config: {}, - }); + expect(buildWorkspaceSkillStatusMock).toHaveBeenCalledWith( + "/tmp/workspace", + expect.objectContaining({ config: {} }), + ); expect( defaultRuntime.writeStdout.mock.calls.length + defaultRuntime.writeJson.mock.calls.length, ).toBeGreaterThan(0); @@ -441,9 +454,10 @@ describe("skills cli commands", () => { await runCommand(argv); }); - expect(buildWorkspaceSkillStatusMock).toHaveBeenCalledWith("/tmp/workspace-writer", { - config: {}, - }); + expect(buildWorkspaceSkillStatusMock).toHaveBeenCalledWith( + "/tmp/workspace-writer", + expect.objectContaining({ config: {} }), + ); }); it.each([ @@ -460,9 +474,10 @@ describe("skills cli commands", () => { }); expect(resolveAgentIdByWorkspacePathMock).not.toHaveBeenCalled(); - expect(buildWorkspaceSkillStatusMock).toHaveBeenCalledWith("/tmp/workspace-writer", { - config: {}, - }); + expect(buildWorkspaceSkillStatusMock).toHaveBeenCalledWith( + "/tmp/workspace-writer", + expect.objectContaining({ config: {} }), + ); }); it("falls back to the default agent outside configured workspaces", async () => { @@ -476,9 +491,10 @@ describe("skills cli commands", () => { expect(resolveAgentIdByWorkspacePathMock).toHaveBeenCalledWith({}, "/tmp/unrelated"); expect(resolveDefaultAgentIdMock).toHaveBeenCalledWith({}); - expect(buildWorkspaceSkillStatusMock).toHaveBeenCalledWith("/tmp/workspace-main", { - config: {}, - }); + expect(buildWorkspaceSkillStatusMock).toHaveBeenCalledWith( + "/tmp/workspace-main", + expect.objectContaining({ config: {} }), + ); }); it("keeps non-JSON skills list output on stdout with human-readable formatting", async () => { diff --git a/src/cli/skills-cli.format.ts b/src/cli/skills-cli.format.ts index cce9b148f02..43a5239f32d 100644 --- a/src/cli/skills-cli.format.ts +++ b/src/cli/skills-cli.format.ts @@ -17,6 +17,7 @@ export type SkillInfoOptions = { export type SkillsCheckOptions = { json?: boolean; + agent?: string; }; function appendClawHubHint(output: string, json?: boolean): string { @@ -27,15 +28,18 @@ function appendClawHubHint(output: string, json?: boolean): string { } function formatSkillStatus(skill: SkillStatusEntry): string { - if (skill.eligible) { - return theme.success("✓ ready"); - } if (skill.disabled) { return theme.warn("⏸ disabled"); } if (skill.blockedByAllowlist) { return theme.warn("🚫 blocked"); } + if (skill.blockedByAgentFilter) { + return theme.warn("🚫 excluded"); + } + if (skill.eligible) { + return theme.success("✓ ready"); + } return theme.warn("△ needs setup"); } @@ -95,7 +99,9 @@ function formatSkillMissingSummary(skill: SkillStatusEntry): string { } export function formatSkillsList(report: SkillStatusReport, opts: SkillsListOptions): string { - const skills = opts.eligible ? report.skills.filter((s) => s.eligible) : report.skills; + const isReadyForAgent = (skill: SkillStatusEntry) => + skill.eligible && !skill.blockedByAgentFilter; + const skills = opts.eligible ? report.skills.filter(isReadyForAgent) : report.skills; if (opts.json) { const jsonReport = sanitizeJsonValue({ @@ -108,6 +114,10 @@ export function formatSkillsList(report: SkillStatusReport, opts: SkillsListOpti eligible: s.eligible, disabled: s.disabled, blockedByAllowlist: s.blockedByAllowlist, + blockedByAgentFilter: s.blockedByAgentFilter, + modelVisible: s.modelVisible, + userInvocable: s.userInvocable, + commandVisible: s.commandVisible, source: s.source, bundled: s.bundled, primaryEnv: s.primaryEnv, @@ -125,7 +135,7 @@ export function formatSkillsList(report: SkillStatusReport, opts: SkillsListOpti return appendClawHubHint(message, opts.json); } - const eligible = skills.filter((s) => s.eligible); + const ready = skills.filter(isReadyForAgent); const tableWidth = getTerminalTableWidth(); const rows = skills.map((skill) => { const missing = formatSkillMissingSummary(skill); @@ -150,7 +160,7 @@ export function formatSkillsList(report: SkillStatusReport, opts: SkillsListOpti const lines: string[] = []; lines.push( - `${theme.heading("Skills")} ${theme.muted(`(${eligible.length}/${skills.length} ready)`)}`, + `${theme.heading("Skills")} ${theme.muted(`(${ready.length}/${skills.length} ready)`)}`, ); lines.push( renderTable({ @@ -186,13 +196,15 @@ export function formatSkillInfo( const lines: string[] = []; const emoji = normalizeSkillEmoji(skill.emoji); - const status = skill.eligible - ? theme.success("✓ Ready") - : skill.disabled - ? theme.warn("⏸ Disabled") - : skill.blockedByAllowlist - ? theme.warn("🚫 Blocked by allowlist") - : theme.warn("△ Needs setup"); + const status = skill.disabled + ? theme.warn("⏸ Disabled") + : skill.blockedByAllowlist + ? theme.warn("🚫 Blocked by allowlist") + : skill.blockedByAgentFilter + ? theme.warn("🚫 Excluded by agent allowlist") + : skill.eligible + ? theme.success("✓ Ready") + : theme.warn("△ Needs setup"); const safeName = sanitizeForLog(skill.name); const safeHomepage = skill.homepage ? sanitizeForLog(skill.homepage) : undefined; @@ -209,6 +221,15 @@ export function formatSkillInfo( if (safeHomepage) { lines.push(`${theme.muted(" Homepage:")} ${safeHomepage}`); } + lines.push( + `${theme.muted(" Visible to model:")} ${skill.modelVisible ? theme.success("yes") : theme.warn("no")}`, + ); + lines.push( + `${theme.muted(" Available as command:")} ${skill.commandVisible ? theme.success("yes") : theme.warn("no")}`, + ); + if (skill.blockedByAgentFilter) { + lines.push(`${theme.muted(" Agent allowlist:")} excludes this skill`); + } if (skill.primaryEnv) { lines.push(`${theme.muted(" Primary env:")} ${skill.primaryEnv}`); } @@ -291,25 +312,47 @@ export function formatSkillInfo( export function formatSkillsCheck(report: SkillStatusReport, opts: SkillsCheckOptions): string { const eligible = report.skills.filter((s) => s.eligible); + const modelVisible = report.skills.filter((s) => s.modelVisible); + const commandVisible = report.skills.filter((s) => s.commandVisible); const disabled = report.skills.filter((s) => s.disabled); const blocked = report.skills.filter((s) => s.blockedByAllowlist && !s.disabled); - const missingReqs = report.skills.filter( - (s) => !s.eligible && !s.disabled && !s.blockedByAllowlist, + const agentFiltered = report.skills.filter((s) => s.eligible && s.blockedByAgentFilter); + const promptHidden = report.skills.filter( + (s) => s.eligible && !s.blockedByAgentFilter && !s.modelVisible, ); + const missingReqs = report.skills.filter( + (s) => !s.eligible && !s.disabled && !s.blockedByAllowlist && !s.blockedByAgentFilter, + ); + const agentId = report.agentId ?? opts.agent; if (opts.json) { return JSON.stringify( sanitizeJsonValue({ + agentId, + agentSkillFilter: report.agentSkillFilter, + workspaceDir: report.workspaceDir, + managedSkillsDir: report.managedSkillsDir, summary: { total: report.skills.length, eligible: eligible.length, + modelVisible: modelVisible.length, + commandVisible: commandVisible.length, disabled: disabled.length, blocked: blocked.length, + agentFiltered: agentFiltered.length, + notInjected: promptHidden.length, missingRequirements: missingReqs.length, }, eligible: eligible.map((s) => s.name), + modelVisible: modelVisible.map((s) => s.name), + commandVisible: commandVisible.map((s) => s.name), disabled: disabled.map((s) => s.name), blocked: blocked.map((s) => s.name), + agentFiltered: agentFiltered.map((s) => s.name), + notInjected: promptHidden.map((s) => ({ + name: s.name, + reason: "disable-model-invocation", + })), missingRequirements: missingReqs.map((s) => ({ name: s.name, missing: s.missing, @@ -323,22 +366,85 @@ export function formatSkillsCheck(report: SkillStatusReport, opts: SkillsCheckOp const lines: string[] = []; lines.push(theme.heading("Skills Status Check")); + if (agentId) { + lines.push(`${theme.muted("Agent:")} ${sanitizeForLog(agentId)}`); + } lines.push(""); lines.push(`${theme.muted("Total:")} ${report.skills.length}`); lines.push(`${theme.success("✓")} ${theme.muted("Eligible:")} ${eligible.length}`); + lines.push(`${theme.success("✓")} ${theme.muted("Visible to model:")} ${modelVisible.length}`); + lines.push( + `${theme.success("✓")} ${theme.muted("Available as command:")} ${commandVisible.length}`, + ); lines.push(`${theme.warn("⏸")} ${theme.muted("Disabled:")} ${disabled.length}`); lines.push(`${theme.warn("🚫")} ${theme.muted("Blocked by allowlist:")} ${blocked.length}`); + if (agentId || agentFiltered.length > 0) { + lines.push( + `${theme.warn("🚫")} ${theme.muted("Excluded by agent allowlist:")} ${agentFiltered.length}`, + ); + } + if (promptHidden.length > 0) { + lines.push( + `${theme.warn("△")} ${theme.muted("Ready but hidden from model prompt:")} ${promptHidden.length}`, + ); + } lines.push(`${theme.error("✗")} ${theme.muted("Missing requirements:")} ${missingReqs.length}`); - if (eligible.length > 0) { + if (modelVisible.length > 0 || commandVisible.length > 0 || promptHidden.length > 0) { lines.push(""); - lines.push(theme.heading("Ready to use:")); - for (const skill of eligible) { + lines.push(theme.heading("What this means:")); + lines.push( + ` ${theme.muted("Eligible:")} installed and requirements pass; the agent may still exclude it.`, + ); + if (modelVisible.length > 0) { + lines.push( + ` ${theme.muted("Visible to model:")} the agent can see the skill instructions during normal chat.`, + ); + } + if (commandVisible.length > 0) { + lines.push( + ` ${theme.muted("Available as command:")} people, scripts, or cron jobs can call the skill explicitly.`, + ); + } + if (promptHidden.length > 0) { + lines.push( + ` ${theme.muted("Hidden from model prompt:")} installed and ready, but kept out of normal chat.`, + ); + } + } + + if (modelVisible.length > 0) { + lines.push(""); + lines.push(theme.heading("Ready and visible to model:")); + for (const skill of modelVisible) { const emoji = normalizeSkillEmoji(skill.emoji); lines.push(` ${emoji} ${sanitizeForLog(skill.name)}`); } } + if (promptHidden.length > 0) { + lines.push(""); + lines.push(theme.heading("Ready but hidden from model prompt:")); + for (const skill of promptHidden) { + const emoji = normalizeSkillEmoji(skill.emoji); + const reason = skill.commandVisible + ? "skill hides its instructions from the model; commands/cron may still use it" + : "skill hides its instructions from the model and is not exposed as a command"; + lines.push(` ${emoji} ${sanitizeForLog(skill.name)} ${theme.muted(`(${reason})`)}`); + } + } + + if (agentFiltered.length > 0) { + lines.push(""); + lines.push(theme.heading("Excluded by agent allowlist:")); + for (const skill of agentFiltered) { + const emoji = normalizeSkillEmoji(skill.emoji); + lines.push( + ` ${emoji} ${sanitizeForLog(skill.name)} ${theme.muted("(loaded, but this agent is not allowed to see/use it)")}`, + ); + } + } + if (missingReqs.length > 0) { lines.push(""); lines.push(theme.heading("Missing requirements:")); diff --git a/src/cli/skills-cli.test.ts b/src/cli/skills-cli.test.ts index 4e2263f536f..509c1831005 100644 --- a/src/cli/skills-cli.test.ts +++ b/src/cli/skills-cli.test.ts @@ -10,7 +10,7 @@ vi.mock("@mariozechner/pi-coding-agent", () => ({ })); function createMockSkill(overrides: Partial = {}): SkillStatusEntry { - return { + const skill: SkillStatusEntry = { name: "test-skill", description: "A test skill", source: "bundled", @@ -23,10 +23,21 @@ function createMockSkill(overrides: Partial = {}): SkillStatus always: false, disabled: false, blockedByAllowlist: false, + blockedByAgentFilter: false, eligible: true, + modelVisible: true, + userInvocable: true, + commandVisible: true, ...createEmptyInstallChecks(), ...overrides, }; + if (overrides.modelVisible === undefined) { + skill.modelVisible = skill.eligible && !skill.blockedByAgentFilter; + } + if (overrides.commandVisible === undefined) { + skill.commandVisible = skill.eligible && !skill.blockedByAgentFilter && skill.userInvocable; + } + return skill; } function createMockReport(skills: SkillStatusEntry[]): SkillStatusReport { @@ -108,6 +119,26 @@ describe("skills-cli", () => { expect(output).toContain("eligible-one"); expect(output).not.toContain("not-eligible"); }); + + it("does not label agent-excluded skills as ready", () => { + const report = createMockReport([ + createMockSkill({ name: "ready-one", eligible: true }), + createMockSkill({ + name: "agent-excluded", + eligible: true, + blockedByAgentFilter: true, + }), + ]); + + const output = formatSkillsList(report, {}); + expect(output).toContain("1/2 ready"); + expect(output).toContain("agent-excluded"); + expect(output).toContain("excluded"); + + const eligibleOnly = formatSkillsList(report, { eligible: true }); + expect(eligibleOnly).toContain("ready-one"); + expect(eligibleOnly).not.toContain("agent-excluded"); + }); }); describe("formatSkillInfo", () => { @@ -190,6 +221,22 @@ describe("skills-cli", () => { const output = formatSkillInfo(report, "info-emoji", {}); expect(output).toContain("🎛️"); }); + + it("shows agent exclusion and visibility details in skill info", () => { + const report = createMockReport([ + createMockSkill({ + name: "agent-excluded", + eligible: true, + blockedByAgentFilter: true, + }), + ]); + + const output = formatSkillInfo(report, "agent-excluded", {}); + expect(output).toContain("Excluded by agent allowlist"); + expect(output).toContain("Visible to model"); + expect(output).toContain("Available as command"); + expect(output).toContain("excludes this skill"); + }); }); describe("formatSkillsCheck", () => { @@ -228,6 +275,128 @@ describe("skills-cli", () => { expect(output).toContain("🎛️ ready-emoji"); expect(output).toContain("🎙️ missing-emoji"); }); + + it("shows agent-filtered and loaded-but-not-injected skills", () => { + const report = { + ...createMockReport([ + createMockSkill({ name: "visible", eligible: true, modelVisible: true }), + createMockSkill({ + name: "prompt-hidden", + eligible: true, + modelVisible: false, + commandVisible: true, + }), + createMockSkill({ + name: "not-assigned", + eligible: true, + blockedByAgentFilter: true, + }), + ]), + agentId: "specialist", + agentSkillFilter: ["visible", "prompt-hidden"], + }; + + const output = formatSkillsCheck(report, {}); + expect(output).toContain("Agent:"); + expect(output).toContain("specialist"); + expect(output).toContain("Ready and visible to model"); + expect(output).toContain("visible"); + expect(output).toContain("Ready but hidden from model prompt"); + expect(output).toContain("prompt-hidden"); + expect(output).toContain("Excluded by agent allowlist"); + expect(output).toContain("not-assigned"); + expect(output).toContain("What this means"); + expect(output).toContain("the agent may still exclude it"); + expect(output).toContain("people, scripts, or cron jobs can call the skill explicitly"); + expect(output).toContain("kept out of normal chat"); + expect(output).toContain("commands/cron may still use it"); + }); + + it("does not imply prompt-hidden non-command skills can be called explicitly", () => { + const report = createMockReport([ + createMockSkill({ + name: "internal-hidden", + eligible: true, + modelVisible: false, + commandVisible: false, + userInvocable: false, + }), + ]); + + const output = formatSkillsCheck(report, {}); + expect(output).toContain("internal-hidden"); + expect(output).toContain("is not exposed as a command"); + expect(output).not.toContain("commands/cron may still use it"); + }); + + it("summarizes a mixed bad skill pack in JSON", () => { + const output = formatSkillsCheck( + { + ...createMockReport([ + createMockSkill({ name: "ready", eligible: true }), + createMockSkill({ + name: "prompt-hidden", + eligible: true, + modelVisible: false, + commandVisible: true, + }), + createMockSkill({ + name: "slash-hidden", + eligible: true, + modelVisible: true, + userInvocable: false, + commandVisible: false, + }), + createMockSkill({ + name: "agent-filtered", + eligible: true, + blockedByAgentFilter: true, + }), + createMockSkill({ + name: "missing-bin", + eligible: false, + missing: { bins: ["missing-tool"], anyBins: [], env: [], config: [], os: [] }, + }), + createMockSkill({ name: "disabled", eligible: false, disabled: true }), + createMockSkill({ + name: "blocked-bundled", + eligible: false, + blockedByAllowlist: true, + }), + ]), + agentId: "specialist", + agentSkillFilter: ["ready", "prompt-hidden", "slash-hidden", "missing-bin"], + }, + { json: true }, + ); + + const parsed = JSON.parse(output) as { + summary: Record; + modelVisible: string[]; + commandVisible: string[]; + agentFiltered: string[]; + notInjected: Array<{ name: string; reason: string }>; + missingRequirements: Array<{ name: string }>; + }; + expect(parsed.summary).toMatchObject({ + total: 7, + eligible: 4, + modelVisible: 2, + commandVisible: 2, + disabled: 1, + blocked: 1, + agentFiltered: 1, + notInjected: 1, + missingRequirements: 1, + }); + expect(parsed.modelVisible).toEqual(["ready", "slash-hidden"]); + expect(parsed.commandVisible).toEqual(["ready", "prompt-hidden"]); + expect(parsed.agentFiltered).toEqual(["agent-filtered"]); + expect(parsed.notInjected).toEqual([ + { name: "prompt-hidden", reason: "disable-model-invocation" }, + ]); + expect(parsed.missingRequirements.map((entry) => entry.name)).toEqual(["missing-bin"]); + }); }); describe("JSON output", () => { @@ -266,6 +435,7 @@ describe("skills-cli", () => { assert: (parsed: Record) => { const summary = parsed.summary as Record; expect(summary.eligible).toBe(1); + expect(summary.modelVisible).toBe(1); expect(summary.total).toBe(2); }, }, diff --git a/src/cli/skills-cli.ts b/src/cli/skills-cli.ts index c1bbe34860d..d716ba9c9d2 100644 --- a/src/cli/skills-cli.ts +++ b/src/cli/skills-cli.ts @@ -37,6 +37,7 @@ type ResolveSkillsWorkspaceOptions = { function resolveSkillsWorkspace(options?: ResolveSkillsWorkspaceOptions): { config: ReturnType; workspaceDir: string; + agentId: string; } { const config = getRuntimeConfig(); const explicitAgentId = normalizeOptionalString(options?.agentId); @@ -46,6 +47,7 @@ function resolveSkillsWorkspace(options?: ResolveSkillsWorkspaceOptions): { const agentId = explicitAgentId ?? inferredAgentId ?? resolveDefaultAgentId(config); return { config, + agentId, workspaceDir: resolveAgentWorkspaceDir(config, agentId), }; } @@ -60,9 +62,9 @@ function resolveAgentOption( async function loadSkillsStatusReport( options?: ResolveSkillsWorkspaceOptions, ): Promise { - const { config, workspaceDir } = resolveSkillsWorkspace(options); + const { config, workspaceDir, agentId } = resolveSkillsWorkspace(options); const { buildWorkspaceSkillStatus } = await import("../agents/skills-status.js"); - return buildWorkspaceSkillStatus(workspaceDir, { config }); + return buildWorkspaceSkillStatus(workspaceDir, { config, agentId }); } async function runSkillsAction( @@ -72,6 +74,7 @@ async function runSkillsAction( try { const report = await loadSkillsStatusReport(options); defaultRuntime.writeStdout(render(report)); + defaultRuntime.exit(0); } catch (err) { defaultRuntime.error(String(err)); defaultRuntime.exit(1); @@ -256,9 +259,9 @@ export function registerSkillsCli(program: Command) { skills .command("check") - .description("Check which skills are ready vs missing requirements") - .option("--json", "Output as JSON", false) + .description("Check which skills are ready, visible, or missing requirements") .option("--agent ", "Target agent workspace (defaults to cwd-inferred, then default agent)") + .option("--json", "Output as JSON", false) .action(async (opts: { json?: boolean; agent?: string }, command: Command) => { await runSkillsAction((report) => formatSkillsCheck(report, opts), { agentId: resolveAgentOption(command, opts), 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..35c21608ef7 --- /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",