Add agent visibility to skills check (#75983)

Merged via squash.

Prepared head SHA: 63bac4340f
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
This commit is contained in:
Mariano
2026-05-02 20:50:38 +02:00
committed by GitHub
parent 91cc1df128
commit 3b347d1c7e
18 changed files with 903 additions and 42 deletions

View File

@@ -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

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

@@ -38,8 +38,8 @@ openclaw skills info <name>
openclaw skills info <name> --json
openclaw skills info <name> --agent <id>
openclaw skills check
openclaw skills check --json
openclaw skills check --agent <id>
openclaw skills check --json
```
`search`/`install`/`update` use ClawHub directly and install into the active
@@ -63,6 +63,8 @@ Notes:
- `--agent <id>` targets one configured agent workspace and overrides current
working directory inference.
- `update --all` only updates tracked ClawHub installs in the active workspace.
- `check --agent <id>` 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

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

@@ -188,7 +188,9 @@ instructions to reference the skill folder path.
When `true`, the skill is exposed as a user slash command.
</ParamField>
<ParamField path="disable-model-invocation" type="boolean" default="false">
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`.
</ParamField>
<ParamField path="command-dispatch" type='"tool"'>
When set to `tool`, the slash command bypasses the model and dispatches directly to a tool.

View File

@@ -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;

View File

@@ -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<string>,
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,
),
),
};
}

View File

@@ -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",
);

View File

@@ -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"]],

View File

@@ -265,6 +265,7 @@ function shouldBootstrapCliProxyBeforeFastPath(env: NodeJS.ProcessEnv = process.
async function bootstrapCliProxyCaptureAndDispatcher(
startupTrace: ReturnType<typeof createGatewayCliMainStartupTrace>,
options: { ensureDispatcher?: boolean } = {},
): Promise<void> {
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 &&

View File

@@ -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 () => {

View File

@@ -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:"));

View File

@@ -10,7 +10,7 @@ vi.mock("@mariozechner/pi-coding-agent", () => ({
}));
function createMockSkill(overrides: Partial<SkillStatusEntry> = {}): SkillStatusEntry {
return {
const skill: SkillStatusEntry = {
name: "test-skill",
description: "A test skill",
source: "bundled",
@@ -23,10 +23,21 @@ function createMockSkill(overrides: Partial<SkillStatusEntry> = {}): 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<string, number>;
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<string, unknown>) => {
const summary = parsed.summary as Record<string, unknown>;
expect(summary.eligible).toBe(1);
expect(summary.modelVisible).toBe(1);
expect(summary.total).toBe(2);
},
},

View File

@@ -37,6 +37,7 @@ type ResolveSkillsWorkspaceOptions = {
function resolveSkillsWorkspace(options?: ResolveSkillsWorkspaceOptions): {
config: ReturnType<typeof getRuntimeConfig>;
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<SkillStatusReport> {
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 <id>", "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),

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",