diff --git a/docs/cli/skills.md b/docs/cli/skills.md index 7d279cf522f..e65a0a0c2a6 100644 --- a/docs/cli/skills.md +++ b/docs/cli/skills.md @@ -38,6 +38,7 @@ openclaw skills info openclaw skills info --json openclaw skills info --agent openclaw skills check +openclaw skills check --agent openclaw skills check --json openclaw skills check --agent ``` @@ -63,6 +64,8 @@ Notes: - `--agent ` targets one configured agent workspace and overrides current working directory inference. - `update --all` only updates tracked ClawHub installs in the active workspace. +- `check --agent ` checks the selected agent's workspace and reports which + ready skills are actually visible to that agent's prompt or command surface. - `list` is the default action when no subcommand is provided. - `list`, `info`, and `check` write their rendered output to stdout. With `--json`, that means the machine-readable payload stays on stdout for pipes diff --git a/src/agents/skills-status.test.ts b/src/agents/skills-status.test.ts index bef7d1acec6..3a2129153c9 100644 --- a/src/agents/skills-status.test.ts +++ b/src/agents/skills-status.test.ts @@ -74,8 +74,229 @@ 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("reports skills blocked by an agent skill filter", () => { + const alpha: SkillEntry = { + skill: createFixtureSkill({ + name: "alpha", + description: "test", + filePath: "/tmp/alpha/SKILL.md", + baseDir: "/tmp/alpha", + source: "test", + }), + frontmatter: {}, + }; + const beta: SkillEntry = { + skill: createFixtureSkill({ + name: "beta", + description: "test", + filePath: "/tmp/beta/SKILL.md", + baseDir: "/tmp/beta", + source: "test", + }), + frontmatter: {}, + }; + + const report = buildWorkspaceSkillStatus("/tmp/ws", { + entries: [alpha, beta], + agentId: "specialist", + config: { + agents: { + list: [{ id: "specialist", skills: ["alpha"] }], + }, + }, + }); + + expect(report.agentId).toBe("specialist"); + expect(report.agentSkillFilter).toEqual(["alpha"]); + expect(report.skills.find((skill) => skill.name === "alpha")?.blockedByAgentFilter).toBe(false); + expect(report.skills.find((skill) => skill.name === "alpha")?.modelVisible).toBe(true); + expect(report.skills.find((skill) => skill.name === "beta")?.blockedByAgentFilter).toBe(true); + expect(report.skills.find((skill) => skill.name === "beta")?.modelVisible).toBe(false); + }); + + it("classifies a mixed broken skill pack without flattening visibility reasons", () => { + const missingBin = "openclaw-test-definitely-missing-skill-bin"; + const report = buildWorkspaceSkillStatus("/tmp/ws", { + agentId: "specialist", + config: { + agents: { + list: [ + { + id: "specialist", + skills: [ + "ready", + "needs-bin", + "needs-env", + "prompt-hidden", + "slash-hidden", + "disabled", + "bundled-blocked", + ], + }, + ], + }, + skills: { + allowBundled: ["some-other-bundled-skill"], + entries: { + disabled: { enabled: false }, + }, + install: { + nodeManager: "pnpm", + }, + }, + }, + entries: [ + createEntry("ready"), + createEntry("needs-bin", { + metadata: { + requires: { bins: [missingBin] }, + install: [ + { + kind: "node", + package: "@openclaw/missing-skill-bin", + bins: [missingBin], + }, + ], + }, + }), + createEntry("needs-env", { + metadata: { + primaryEnv: "OPENCLAW_TEST_MISSING_SKILL_KEY", + requires: { env: ["OPENCLAW_TEST_MISSING_SKILL_KEY"] }, + }, + }), + createEntry("prompt-hidden", { + invocation: { + userInvocable: true, + disableModelInvocation: true, + }, + }), + createEntry("slash-hidden", { + invocation: { + userInvocable: false, + disableModelInvocation: false, + }, + }), + createEntry("agent-filtered"), + createEntry("disabled"), + createEntry("bundled-blocked", { source: "openclaw-bundled" }), + ], + }); + + const byName = new Map(report.skills.map((skill) => [skill.name, skill])); + expect(report.agentSkillFilter).toEqual([ + "ready", + "needs-bin", + "needs-env", + "prompt-hidden", + "slash-hidden", + "disabled", + "bundled-blocked", + ]); + expect(byName.get("ready")).toMatchObject({ + eligible: true, + modelVisible: true, + commandVisible: true, + }); + expect(byName.get("needs-bin")).toMatchObject({ + eligible: false, + modelVisible: false, + commandVisible: false, + missing: { bins: [missingBin] }, + install: [ + { + kind: "node", + label: "Install @openclaw/missing-skill-bin (pnpm)", + bins: [missingBin], + }, + ], + }); + expect(byName.get("needs-env")).toMatchObject({ + eligible: false, + primaryEnv: "OPENCLAW_TEST_MISSING_SKILL_KEY", + missing: { env: ["OPENCLAW_TEST_MISSING_SKILL_KEY"] }, + }); + expect(byName.get("prompt-hidden")).toMatchObject({ + eligible: true, + modelVisible: false, + commandVisible: true, + }); + expect(byName.get("slash-hidden")).toMatchObject({ + eligible: true, + modelVisible: true, + userInvocable: false, + commandVisible: false, + }); + expect(byName.get("agent-filtered")).toMatchObject({ + eligible: true, + blockedByAgentFilter: true, + modelVisible: false, + commandVisible: false, + }); + expect(byName.get("disabled")).toMatchObject({ + eligible: false, + disabled: true, + modelVisible: false, + commandVisible: false, + }); + expect(byName.get("bundled-blocked")).toMatchObject({ + eligible: false, + blockedByAllowlist: true, + modelVisible: false, + commandVisible: false, + }); + }); }); +function createEntry( + name: string, + params: { + description?: string; + source?: string; + metadata?: SkillEntry["metadata"]; + invocation?: SkillEntry["invocation"]; + } = {}, +): SkillEntry { + const baseDir = `/tmp/${name}`; + return { + skill: createFixtureSkill({ + name, + description: params.description ?? `${name} skill`, + filePath: `${baseDir}/SKILL.md`, + baseDir, + source: params.source ?? "test", + }), + frontmatter: {}, + metadata: params.metadata, + invocation: params.invocation, + }; +} + function createFixtureSkill(params: { name: string; description: string; diff --git a/src/agents/skills-status.ts b/src/agents/skills-status.ts index fb839f56d60..664974b4d56 100644 --- a/src/agents/skills-status.ts +++ b/src/agents/skills-status.ts @@ -16,6 +16,7 @@ import { type SkillInstallSpec, type SkillsInstallPreferences, } from "./skills.js"; +import { resolveEffectiveAgentSkillFilter } from "./skills/agent-filter.js"; import { resolveBundledSkillsContext } from "./skills/bundled-context.js"; import { resolveSkillSource } from "./skills/source.js"; @@ -42,7 +43,11 @@ export type SkillStatusEntry = { always: boolean; disabled: boolean; blockedByAllowlist: boolean; + blockedByAgentFilter: boolean; eligible: boolean; + modelVisible: boolean; + userInvocable: boolean; + commandVisible: boolean; requirements: Requirements; missing: Requirements; configChecks: SkillStatusConfigCheck[]; @@ -52,6 +57,8 @@ export type SkillStatusEntry = { export type SkillStatusReport = { workspaceDir: string; managedSkillsDir: string; + agentId?: string; + agentSkillFilter?: string[]; skills: SkillStatusEntry[]; }; @@ -167,18 +174,41 @@ function normalizeInstallOptions( return [toOption(preferred.spec, preferred.index)]; } +function isSkillVisibleInAvailableSkillsPrompt(entry: SkillEntry): boolean { + if (entry.exposure) { + return entry.exposure.includeInAvailableSkillsPrompt; + } + if (entry.invocation) { + return !entry.invocation.disableModelInvocation; + } + return !entry.skill.disableModelInvocation; +} + +function isSkillUserInvocable(entry: SkillEntry): boolean { + if (entry.exposure) { + return entry.exposure.userInvocable; + } + if (entry.invocation) { + return entry.invocation.userInvocable; + } + return true; +} + function buildSkillStatus( entry: SkillEntry, config?: OpenClawConfig, prefs?: SkillsInstallPreferences, eligibility?: SkillEligibilityContext, bundledNames?: Set, + agentSkillFilter?: string[], ): SkillStatusEntry { const skillKey = resolveSkillKey(entry); const skillConfig = resolveSkillConfig(config, skillKey); const disabled = skillConfig?.enabled === false; const allowBundled = resolveBundledAllowlist(config); const blockedByAllowlist = !isBundledSkillAllowed(entry, allowBundled); + const blockedByAgentFilter = + agentSkillFilter !== undefined && !agentSkillFilter.includes(entry.skill.name); const always = entry.metadata?.always === true; const isEnvSatisfied = (envName: string) => Boolean( @@ -202,6 +232,8 @@ function buildSkillStatus( isConfigSatisfied, }); const eligible = !disabled && !blockedByAllowlist && requirementsSatisfied; + const availableToAgent = eligible && !blockedByAgentFilter; + const userInvocable = isSkillUserInvocable(entry); return { name: entry.skill.name, @@ -217,7 +249,11 @@ function buildSkillStatus( always, disabled, blockedByAllowlist, + blockedByAgentFilter, eligible, + modelVisible: availableToAgent && isSkillVisibleInAvailableSkillsPrompt(entry), + userInvocable, + commandVisible: availableToAgent && userInvocable, requirements: required, missing, configChecks, @@ -232,10 +268,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 +287,17 @@ export function buildWorkspaceSkillStatus( return { workspaceDir, managedSkillsDir, + agentId: opts?.agentId, + agentSkillFilter, skills: skillEntries.map((entry) => - buildSkillStatus(entry, opts?.config, prefs, opts?.eligibility, bundledContext.names), + buildSkillStatus( + entry, + opts?.config, + prefs, + opts?.eligibility, + bundledContext.names, + agentSkillFilter, + ), ), }; } diff --git a/src/cli/command-path-policy.test.ts b/src/cli/command-path-policy.test.ts index bd95dbc224b..ba77dffdc5b 100644 --- a/src/cli/command-path-policy.test.ts +++ b/src/cli/command-path-policy.test.ts @@ -184,6 +184,8 @@ describe("command-path-policy", () => { expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "skills", "info", "browser"])).toBe( "bypass", ); + expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "skills", "check"])).toBe("bypass"); + expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "skills", "list"])).toBe("bypass"); expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "skills", "search", "browser"])).toBe( "default", ); diff --git a/src/cli/run-main.exit.test.ts b/src/cli/run-main.exit.test.ts index 63a858c8b96..1be30a2a709 100644 --- a/src/cli/run-main.exit.test.ts +++ b/src/cli/run-main.exit.test.ts @@ -362,6 +362,9 @@ describe("runCli exit behavior", () => { ["agents list", ["node", "openclaw", "agents", "list"]], ["models list", ["node", "openclaw", "models", "list"]], ["models status without live probe", ["node", "openclaw", "models", "status"]], + ["skills check", ["node", "openclaw", "skills", "check"]], + ["skills info", ["node", "openclaw", "skills", "info", "weather"]], + ["skills list", ["node", "openclaw", "skills", "list"]], ["tasks list", ["node", "openclaw", "tasks", "list"]], ["legacy singular tool namespace", ["node", "openclaw", "tool", "image_generate"]], ["gateway tools namespace typo", ["node", "openclaw", "tools", "effective"]], @@ -386,6 +389,16 @@ describe("runCli exit behavior", () => { expect(startProxyMock).toHaveBeenCalledWith(undefined); }); + it("does not install the env proxy dispatcher for bypassed skills inspection commands", async () => { + hasEnvHttpProxyAgentConfiguredMock.mockReturnValue(true); + tryRouteCliMock.mockResolvedValueOnce(true); + + await runCli(["node", "openclaw", "skills", "check"]); + + expect(hasEnvHttpProxyAgentConfiguredMock).not.toHaveBeenCalled(); + expect(ensureGlobalUndiciEnvProxyDispatcherMock).not.toHaveBeenCalled(); + }); + it.each([ ["tool", ["node", "openclaw", "tool", "image_generate"]], ["tools", ["node", "openclaw", "tools", "effective"]], diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index f27c6b10c96..84e703ac919 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -265,6 +265,7 @@ function shouldBootstrapCliProxyBeforeFastPath(env: NodeJS.ProcessEnv = process. async function bootstrapCliProxyCaptureAndDispatcher( startupTrace: ReturnType, + options: { ensureDispatcher?: boolean } = {}, ): Promise { const [ { initializeDebugProxyCapture, finalizeDebugProxyCapture }, @@ -276,7 +277,9 @@ async function bootstrapCliProxyCaptureAndDispatcher( process.once("exit", () => { finalizeDebugProxyCapture(); }); - await startupTrace.measure("proxy-dispatcher", () => ensureCliEnvProxyDispatcher()); + if (options.ensureDispatcher !== false) { + await startupTrace.measure("proxy-dispatcher", () => ensureCliEnvProxyDispatcher()); + } maybeWarnAboutDebugProxyCoverage(); } @@ -440,7 +443,9 @@ export async function runCli(argv: string[] = process.argv) { return; } - const bootstrapProxyBeforeFastPath = shouldBootstrapCliProxyBeforeFastPath(); + const shouldUseCliEnvProxy = shouldStartProxyForCli(normalizedArgv); + const bootstrapProxyBeforeFastPath = + shouldUseCliEnvProxy && shouldBootstrapCliProxyBeforeFastPath(); if ( !bootstrapProxyBeforeFastPath && (await tryRunGatewayRunFastPath(normalizedArgv, startupTrace)) @@ -448,7 +453,9 @@ export async function runCli(argv: string[] = process.argv) { return; } - await bootstrapCliProxyCaptureAndDispatcher(startupTrace); + await bootstrapCliProxyCaptureAndDispatcher(startupTrace, { + ensureDispatcher: shouldUseCliEnvProxy, + }); if ( bootstrapProxyBeforeFastPath && diff --git a/src/cli/skills-cli.commands.test.ts b/src/cli/skills-cli.commands.test.ts index b1792f29293..2829f8a418e 100644 --- a/src/cli/skills-cli.commands.test.ts +++ b/src/cli/skills-cli.commands.test.ts @@ -59,6 +59,9 @@ const mocks = vi.hoisted(() => { runtimeStdout.push(JSON.stringify(value, null, space > 0 ? space : undefined)); }), exit: vi.fn((code: number) => { + if (code === 0) { + return; + } throw new Error(`__exit__:${code}`); }), }; @@ -142,7 +145,16 @@ describe("skills cli commands", () => { return program; }; - const runCommand = (argv: string[]) => createProgram().parseAsync(argv, { from: "user" }); + const runCommand = async (argv: string[]) => { + try { + await createProgram().parseAsync(argv, { from: "user" }); + } catch (error) { + if (error instanceof Error && error.message === "__exit__:0") { + return; + } + throw error; + } + }; beforeEach(() => { runtimeLogs.length = 0; @@ -414,9 +426,10 @@ describe("skills cli commands", () => { ])("routes skills $label JSON output through stdout", async ({ argv, assert }) => { await runCommand(argv); - expect(buildWorkspaceSkillStatusMock).toHaveBeenCalledWith("/tmp/workspace", { - config: {}, - }); + expect(buildWorkspaceSkillStatusMock).toHaveBeenCalledWith( + "/tmp/workspace", + expect.objectContaining({ config: {} }), + ); expect( defaultRuntime.writeStdout.mock.calls.length + defaultRuntime.writeJson.mock.calls.length, ).toBeGreaterThan(0); @@ -441,9 +454,10 @@ describe("skills cli commands", () => { await runCommand(argv); }); - expect(buildWorkspaceSkillStatusMock).toHaveBeenCalledWith("/tmp/workspace-writer", { - config: {}, - }); + expect(buildWorkspaceSkillStatusMock).toHaveBeenCalledWith( + "/tmp/workspace-writer", + expect.objectContaining({ config: {} }), + ); }); it.each([ @@ -460,9 +474,10 @@ describe("skills cli commands", () => { }); expect(resolveAgentIdByWorkspacePathMock).not.toHaveBeenCalled(); - expect(buildWorkspaceSkillStatusMock).toHaveBeenCalledWith("/tmp/workspace-writer", { - config: {}, - }); + expect(buildWorkspaceSkillStatusMock).toHaveBeenCalledWith( + "/tmp/workspace-writer", + expect.objectContaining({ config: {} }), + ); }); it("falls back to the default agent outside configured workspaces", async () => { @@ -476,9 +491,10 @@ describe("skills cli commands", () => { expect(resolveAgentIdByWorkspacePathMock).toHaveBeenCalledWith({}, "/tmp/unrelated"); expect(resolveDefaultAgentIdMock).toHaveBeenCalledWith({}); - expect(buildWorkspaceSkillStatusMock).toHaveBeenCalledWith("/tmp/workspace-main", { - config: {}, - }); + expect(buildWorkspaceSkillStatusMock).toHaveBeenCalledWith( + "/tmp/workspace-main", + expect.objectContaining({ config: {} }), + ); }); it("keeps non-JSON skills list output on stdout with human-readable formatting", async () => { diff --git a/src/cli/skills-cli.format.ts b/src/cli/skills-cli.format.ts index cce9b148f02..107620efe3e 100644 --- a/src/cli/skills-cli.format.ts +++ b/src/cli/skills-cli.format.ts @@ -17,6 +17,7 @@ export type SkillInfoOptions = { export type SkillsCheckOptions = { json?: boolean; + agent?: string; }; function appendClawHubHint(output: string, json?: boolean): string { @@ -108,6 +109,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, @@ -291,25 +296,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 +350,61 @@ 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) { lines.push(""); - lines.push(theme.heading("Ready to use:")); - for (const skill of eligible) { + 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); + lines.push( + ` ${emoji} ${sanitizeForLog(skill.name)} ${theme.muted("(skill hides its instructions from the model; commands/cron may still use it)")}`, + ); + } + } + + if (agentFiltered.length > 0) { + lines.push(""); + lines.push(theme.heading("Excluded by agent allowlist:")); + for (const skill of agentFiltered) { + const emoji = normalizeSkillEmoji(skill.emoji); + lines.push( + ` ${emoji} ${sanitizeForLog(skill.name)} ${theme.muted("(loaded, but this agent is not allowed to see/use it)")}`, + ); + } + } + if (missingReqs.length > 0) { lines.push(""); lines.push(theme.heading("Missing requirements:")); diff --git a/src/cli/skills-cli.test.ts b/src/cli/skills-cli.test.ts index 4e2263f536f..becc702b874 100644 --- a/src/cli/skills-cli.test.ts +++ b/src/cli/skills-cli.test.ts @@ -10,7 +10,7 @@ vi.mock("@mariozechner/pi-coding-agent", () => ({ })); function createMockSkill(overrides: Partial = {}): SkillStatusEntry { - return { + const skill: SkillStatusEntry = { name: "test-skill", description: "A test skill", source: "bundled", @@ -23,10 +23,21 @@ function createMockSkill(overrides: Partial = {}): SkillStatus always: false, disabled: false, blockedByAllowlist: false, + blockedByAgentFilter: false, eligible: true, + modelVisible: true, + userInvocable: true, + commandVisible: true, ...createEmptyInstallChecks(), ...overrides, }; + if (overrides.modelVisible === undefined) { + skill.modelVisible = skill.eligible && !skill.blockedByAgentFilter; + } + if (overrides.commandVisible === undefined) { + skill.commandVisible = skill.eligible && !skill.blockedByAgentFilter && skill.userInvocable; + } + return skill; } function createMockReport(skills: SkillStatusEntry[]): SkillStatusReport { @@ -228,6 +239,106 @@ 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"); + }); + + it("summarizes a mixed bad skill pack in JSON", () => { + const output = formatSkillsCheck( + { + ...createMockReport([ + createMockSkill({ name: "ready", eligible: true }), + createMockSkill({ + name: "prompt-hidden", + eligible: true, + modelVisible: false, + commandVisible: true, + }), + createMockSkill({ + name: "slash-hidden", + eligible: true, + modelVisible: true, + userInvocable: false, + commandVisible: false, + }), + createMockSkill({ + name: "agent-filtered", + eligible: true, + blockedByAgentFilter: true, + }), + createMockSkill({ + name: "missing-bin", + eligible: false, + missing: { bins: ["missing-tool"], anyBins: [], env: [], config: [], os: [] }, + }), + createMockSkill({ name: "disabled", eligible: false, disabled: true }), + createMockSkill({ + name: "blocked-bundled", + eligible: false, + blockedByAllowlist: true, + }), + ]), + agentId: "specialist", + agentSkillFilter: ["ready", "prompt-hidden", "slash-hidden", "missing-bin"], + }, + { json: true }, + ); + + const parsed = JSON.parse(output) as { + summary: Record; + modelVisible: string[]; + commandVisible: string[]; + agentFiltered: string[]; + notInjected: Array<{ name: string; reason: string }>; + missingRequirements: Array<{ name: string }>; + }; + expect(parsed.summary).toMatchObject({ + total: 7, + eligible: 4, + modelVisible: 2, + commandVisible: 2, + disabled: 1, + blocked: 1, + agentFiltered: 1, + notInjected: 1, + missingRequirements: 1, + }); + expect(parsed.modelVisible).toEqual(["ready", "slash-hidden"]); + expect(parsed.commandVisible).toEqual(["ready", "prompt-hidden"]); + expect(parsed.agentFiltered).toEqual(["agent-filtered"]); + expect(parsed.notInjected).toEqual([ + { name: "prompt-hidden", reason: "disable-model-invocation" }, + ]); + expect(parsed.missingRequirements.map((entry) => entry.name)).toEqual(["missing-bin"]); + }); }); describe("JSON output", () => { @@ -266,6 +377,7 @@ describe("skills-cli", () => { assert: (parsed: Record) => { const summary = parsed.summary as Record; expect(summary.eligible).toBe(1); + expect(summary.modelVisible).toBe(1); expect(summary.total).toBe(2); }, }, diff --git a/src/cli/skills-cli.ts b/src/cli/skills-cli.ts index c1bbe34860d..d716ba9c9d2 100644 --- a/src/cli/skills-cli.ts +++ b/src/cli/skills-cli.ts @@ -37,6 +37,7 @@ type ResolveSkillsWorkspaceOptions = { function resolveSkillsWorkspace(options?: ResolveSkillsWorkspaceOptions): { config: ReturnType; workspaceDir: string; + agentId: string; } { const config = getRuntimeConfig(); const explicitAgentId = normalizeOptionalString(options?.agentId); @@ -46,6 +47,7 @@ function resolveSkillsWorkspace(options?: ResolveSkillsWorkspaceOptions): { const agentId = explicitAgentId ?? inferredAgentId ?? resolveDefaultAgentId(config); return { config, + agentId, workspaceDir: resolveAgentWorkspaceDir(config, agentId), }; } @@ -60,9 +62,9 @@ function resolveAgentOption( async function loadSkillsStatusReport( options?: ResolveSkillsWorkspaceOptions, ): Promise { - const { config, workspaceDir } = resolveSkillsWorkspace(options); + const { config, workspaceDir, agentId } = resolveSkillsWorkspace(options); const { buildWorkspaceSkillStatus } = await import("../agents/skills-status.js"); - return buildWorkspaceSkillStatus(workspaceDir, { config }); + return buildWorkspaceSkillStatus(workspaceDir, { config, agentId }); } async function runSkillsAction( @@ -72,6 +74,7 @@ async function runSkillsAction( try { const report = await loadSkillsStatusReport(options); defaultRuntime.writeStdout(render(report)); + defaultRuntime.exit(0); } catch (err) { defaultRuntime.error(String(err)); defaultRuntime.exit(1); @@ -256,9 +259,9 @@ export function registerSkillsCli(program: Command) { skills .command("check") - .description("Check which skills are ready vs missing requirements") - .option("--json", "Output as JSON", false) + .description("Check which skills are ready, visible, or missing requirements") .option("--agent ", "Target agent workspace (defaults to cwd-inferred, then default agent)") + .option("--json", "Output as JSON", false) .action(async (opts: { json?: boolean; agent?: string }, command: Command) => { await runSkillsAction((report) => formatSkillsCheck(report, opts), { agentId: resolveAgentOption(command, opts),