diff --git a/src/agents/skills-status.test.ts b/src/agents/skills-status.test.ts index 3a2129153c9..8ce80855c96 100644 --- a/src/agents/skills-status.test.ts +++ b/src/agents/skills-status.test.ts @@ -99,6 +99,29 @@ describe("buildWorkspaceSkillStatus", () => { 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({ diff --git a/src/agents/skills-status.ts b/src/agents/skills-status.ts index 664974b4d56..4e584d24f53 100644 --- a/src/agents/skills-status.ts +++ b/src/agents/skills-status.ts @@ -176,20 +176,20 @@ function normalizeInstallOptions( function isSkillVisibleInAvailableSkillsPrompt(entry: SkillEntry): boolean { if (entry.exposure) { - return entry.exposure.includeInAvailableSkillsPrompt; + return entry.exposure.includeInAvailableSkillsPrompt !== false; } if (entry.invocation) { - return !entry.invocation.disableModelInvocation; + return entry.invocation.disableModelInvocation !== true; } - return !entry.skill.disableModelInvocation; + return entry.skill.disableModelInvocation !== true; } function isSkillUserInvocable(entry: SkillEntry): boolean { if (entry.exposure) { - return entry.exposure.userInvocable; + return entry.exposure.userInvocable !== false; } if (entry.invocation) { - return entry.invocation.userInvocable; + return entry.invocation.userInvocable !== false; } return true; } diff --git a/src/cli/skills-cli.format.ts b/src/cli/skills-cli.format.ts index acbeb8d6b13..43a5239f32d 100644 --- a/src/cli/skills-cli.format.ts +++ b/src/cli/skills-cli.format.ts @@ -28,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"); } @@ -96,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({ @@ -130,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); @@ -155,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({ @@ -191,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; @@ -214,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}`); } @@ -377,6 +393,9 @@ export function formatSkillsCheck(report: SkillStatusReport, opts: SkillsCheckOp if (modelVisible.length > 0 || commandVisible.length > 0 || promptHidden.length > 0) { lines.push(""); 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.`, @@ -389,7 +408,7 @@ export function formatSkillsCheck(report: SkillStatusReport, opts: SkillsCheckOp } if (promptHidden.length > 0) { lines.push( - ` ${theme.muted("Hidden from model prompt:")} installed and ready, but kept out of normal chat unless called explicitly.`, + ` ${theme.muted("Hidden from model prompt:")} installed and ready, but kept out of normal chat.`, ); } } @@ -408,9 +427,10 @@ export function formatSkillsCheck(report: SkillStatusReport, opts: SkillsCheckOp 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)")}`, - ); + 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})`)}`); } } diff --git a/src/cli/skills-cli.test.ts b/src/cli/skills-cli.test.ts index 9c445b5185e..509c1831005 100644 --- a/src/cli/skills-cli.test.ts +++ b/src/cli/skills-cli.test.ts @@ -119,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", () => { @@ -201,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", () => { @@ -270,8 +306,27 @@ describe("skills-cli", () => { 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 unless called 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", () => {