mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:00:43 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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"]],
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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:"));
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
|
||||
110
src/commands/doctor-skills.test.ts
Normal file
110
src/commands/doctor-skills.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SkillStatusEntry, SkillStatusReport } from "../agents/skills-status.js";
|
||||
import { createEmptyInstallChecks } from "../cli/requirements-test-fixtures.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import {
|
||||
collectUnavailableAgentSkills,
|
||||
disableUnavailableSkillsInConfig,
|
||||
formatUnavailableSkillDoctorLines,
|
||||
} from "./doctor-skills.js";
|
||||
|
||||
function createSkill(overrides: Partial<SkillStatusEntry>): SkillStatusEntry {
|
||||
return {
|
||||
name: "demo",
|
||||
description: "Demo",
|
||||
source: "test",
|
||||
bundled: false,
|
||||
filePath: "/tmp/demo/SKILL.md",
|
||||
baseDir: "/tmp/demo",
|
||||
skillKey: overrides.name ?? "demo",
|
||||
always: false,
|
||||
disabled: false,
|
||||
blockedByAllowlist: false,
|
||||
blockedByAgentFilter: false,
|
||||
eligible: true,
|
||||
modelVisible: true,
|
||||
userInvocable: true,
|
||||
commandVisible: true,
|
||||
...createEmptyInstallChecks(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createReport(skills: SkillStatusEntry[]): SkillStatusReport {
|
||||
return {
|
||||
workspaceDir: "/tmp/ws",
|
||||
managedSkillsDir: "/tmp/managed",
|
||||
agentId: "main",
|
||||
skills,
|
||||
};
|
||||
}
|
||||
|
||||
describe("doctor skills", () => {
|
||||
it("collects only unavailable skills that this agent is allowed to use", () => {
|
||||
const unavailable = createSkill({
|
||||
name: "missing-bin",
|
||||
eligible: false,
|
||||
modelVisible: false,
|
||||
commandVisible: false,
|
||||
missing: { bins: ["tool"], anyBins: [], env: [], config: [], os: [] },
|
||||
});
|
||||
const report = createReport([
|
||||
createSkill({ name: "ready" }),
|
||||
unavailable,
|
||||
createSkill({ name: "disabled", eligible: false, disabled: true }),
|
||||
createSkill({ name: "agent-filtered", eligible: true, blockedByAgentFilter: true }),
|
||||
createSkill({ name: "bundled-blocked", eligible: false, blockedByAllowlist: true }),
|
||||
]);
|
||||
|
||||
expect(collectUnavailableAgentSkills(report)).toEqual([unavailable]);
|
||||
});
|
||||
|
||||
it("formats actionable missing requirement lines without secret values", () => {
|
||||
const lines = formatUnavailableSkillDoctorLines([
|
||||
createSkill({
|
||||
name: "places",
|
||||
eligible: false,
|
||||
missing: {
|
||||
bins: ["goplaces"],
|
||||
anyBins: [],
|
||||
env: ["GOOGLE_MAPS_API_KEY"],
|
||||
config: [],
|
||||
os: [],
|
||||
},
|
||||
install: [
|
||||
{
|
||||
id: "brew",
|
||||
kind: "brew",
|
||||
label: "Install goplaces (brew)",
|
||||
bins: ["goplaces"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(lines.join("\n")).toContain("places: bins: goplaces; env: GOOGLE_MAPS_API_KEY");
|
||||
expect(lines.join("\n")).toContain("install option: Install goplaces (brew)");
|
||||
expect(lines.join("\n")).toContain("openclaw doctor --fix");
|
||||
});
|
||||
|
||||
it("disables unavailable skills through skills.entries without dropping existing config", () => {
|
||||
const config: OpenClawConfig = {
|
||||
skills: {
|
||||
entries: {
|
||||
gog: { env: { EXISTING: "1" } },
|
||||
other: { enabled: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const next = disableUnavailableSkillsInConfig(config, [
|
||||
createSkill({ name: "gog", skillKey: "gog", eligible: false }),
|
||||
createSkill({ name: "wacli", skillKey: "wacli", eligible: false }),
|
||||
]);
|
||||
|
||||
expect(next.skills?.entries?.gog).toEqual({ env: { EXISTING: "1" }, enabled: false });
|
||||
expect(next.skills?.entries?.wacli).toEqual({ enabled: false });
|
||||
expect(next.skills?.entries?.other).toEqual({ enabled: true });
|
||||
expect(config.skills?.entries?.gog).toEqual({ env: { EXISTING: "1" } });
|
||||
});
|
||||
});
|
||||
111
src/commands/doctor-skills.ts
Normal file
111
src/commands/doctor-skills.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import type { SkillStatusEntry, SkillStatusReport } from "../agents/skills-status.js";
|
||||
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { note } from "../terminal/note.js";
|
||||
import type { DoctorPrompter } from "./doctor-prompter.js";
|
||||
|
||||
export function collectUnavailableAgentSkills(report: SkillStatusReport): SkillStatusEntry[] {
|
||||
return report.skills.filter(
|
||||
(skill) =>
|
||||
!skill.eligible &&
|
||||
!skill.disabled &&
|
||||
!skill.blockedByAllowlist &&
|
||||
!skill.blockedByAgentFilter,
|
||||
);
|
||||
}
|
||||
|
||||
function formatMissingSummary(skill: SkillStatusEntry): string {
|
||||
const missing: string[] = [];
|
||||
if (skill.missing.bins.length > 0) {
|
||||
missing.push(`bins: ${skill.missing.bins.join(", ")}`);
|
||||
}
|
||||
if (skill.missing.anyBins.length > 0) {
|
||||
missing.push(`any bins: ${skill.missing.anyBins.join(", ")}`);
|
||||
}
|
||||
if (skill.missing.env.length > 0) {
|
||||
missing.push(`env: ${skill.missing.env.join(", ")}`);
|
||||
}
|
||||
if (skill.missing.config.length > 0) {
|
||||
missing.push(`config: ${skill.missing.config.join(", ")}`);
|
||||
}
|
||||
if (skill.missing.os.length > 0) {
|
||||
missing.push(`os: ${skill.missing.os.join(", ")}`);
|
||||
}
|
||||
return missing.join("; ") || "unknown requirement";
|
||||
}
|
||||
|
||||
function formatInstallHints(skill: SkillStatusEntry): string[] {
|
||||
if (skill.install.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return skill.install.slice(0, 2).map((entry) => ` install option: ${entry.label}`);
|
||||
}
|
||||
|
||||
export function formatUnavailableSkillDoctorLines(skills: SkillStatusEntry[]): string[] {
|
||||
const lines: string[] = [
|
||||
"Some skills are allowed for this agent but are not usable in the current runtime environment.",
|
||||
];
|
||||
for (const skill of skills) {
|
||||
lines.push(`- ${skill.name}: ${formatMissingSummary(skill)}`);
|
||||
lines.push(...formatInstallHints(skill));
|
||||
}
|
||||
lines.push(`Disable unused skills: ${formatCliCommand("openclaw doctor --fix")}`);
|
||||
lines.push(
|
||||
`Inspect details: ${formatCliCommand("openclaw skills check --agent <id>")} or ${formatCliCommand("openclaw skills info <name> --agent <id>")}`,
|
||||
);
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function disableUnavailableSkillsInConfig(
|
||||
config: OpenClawConfig,
|
||||
skills: readonly SkillStatusEntry[],
|
||||
): OpenClawConfig {
|
||||
if (skills.length === 0) {
|
||||
return config;
|
||||
}
|
||||
const entries = { ...config.skills?.entries };
|
||||
for (const skill of skills) {
|
||||
entries[skill.skillKey] = {
|
||||
...entries[skill.skillKey],
|
||||
enabled: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...config,
|
||||
skills: {
|
||||
...config.skills,
|
||||
entries,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function maybeRepairSkillReadiness(params: {
|
||||
cfg: OpenClawConfig;
|
||||
prompter: DoctorPrompter;
|
||||
}): Promise<OpenClawConfig> {
|
||||
const agentId = resolveDefaultAgentId(params.cfg);
|
||||
const workspaceDir = resolveAgentWorkspaceDir(params.cfg, agentId);
|
||||
const report = buildWorkspaceSkillStatus(workspaceDir, {
|
||||
config: params.cfg,
|
||||
agentId,
|
||||
});
|
||||
const unavailable = collectUnavailableAgentSkills(report);
|
||||
if (unavailable.length === 0) {
|
||||
return params.cfg;
|
||||
}
|
||||
|
||||
note(formatUnavailableSkillDoctorLines(unavailable).join("\n"), "Skills");
|
||||
const shouldDisable = await params.prompter.confirmAutoFix({
|
||||
message: `Disable ${unavailable.length} unavailable skill${unavailable.length === 1 ? "" : "s"} in config?`,
|
||||
initialValue: false,
|
||||
});
|
||||
if (!shouldDisable) {
|
||||
return params.cfg;
|
||||
}
|
||||
|
||||
const next = disableUnavailableSkillsInConfig(params.cfg, unavailable);
|
||||
note(unavailable.map((skill) => `- Disabled ${skill.name}`).join("\n"), "Doctor changes");
|
||||
return next;
|
||||
}
|
||||
@@ -96,6 +96,13 @@ describe("doctor health contributions", () => {
|
||||
expect(ids.indexOf("doctor:command-owner")).toBeLessThan(ids.indexOf("doctor:write-config"));
|
||||
});
|
||||
|
||||
it("checks skill readiness before final config writes", () => {
|
||||
const ids = resolveDoctorHealthContributions().map((entry) => entry.id);
|
||||
|
||||
expect(ids.indexOf("doctor:skills")).toBeGreaterThan(-1);
|
||||
expect(ids.indexOf("doctor:skills")).toBeLessThan(ids.indexOf("doctor:write-config"));
|
||||
});
|
||||
|
||||
it("skips doctor config writes under legacy update parents", () => {
|
||||
expect(
|
||||
shouldSkipLegacyUpdateDoctorConfigWrite({
|
||||
|
||||
@@ -468,6 +468,14 @@ async function runWorkspaceStatusHealth(ctx: DoctorHealthFlowContext): Promise<v
|
||||
noteWorkspaceStatus(ctx.cfg);
|
||||
}
|
||||
|
||||
async function runSkillsHealth(ctx: DoctorHealthFlowContext): Promise<void> {
|
||||
const { maybeRepairSkillReadiness } = await import("../commands/doctor-skills.js");
|
||||
ctx.cfg = await maybeRepairSkillReadiness({
|
||||
cfg: ctx.cfg,
|
||||
prompter: ctx.prompter,
|
||||
});
|
||||
}
|
||||
|
||||
async function runBootstrapSizeHealth(ctx: DoctorHealthFlowContext): Promise<void> {
|
||||
const { noteBootstrapFileSize } = await import("../commands/doctor-bootstrap-size.js");
|
||||
await noteBootstrapFileSize(ctx.cfg);
|
||||
@@ -712,6 +720,11 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] {
|
||||
label: "Workspace status",
|
||||
run: runWorkspaceStatusHealth,
|
||||
}),
|
||||
createDoctorHealthContribution({
|
||||
id: "doctor:skills",
|
||||
label: "Skills",
|
||||
run: runSkillsHealth,
|
||||
}),
|
||||
createDoctorHealthContribution({
|
||||
id: "doctor:bootstrap-size",
|
||||
label: "Bootstrap size",
|
||||
|
||||
Reference in New Issue
Block a user