Harden skills visibility reporting

This commit is contained in:
Mariano Belinky
2026-05-02 19:44:03 +02:00
parent 5f93106ed2
commit 971624792a
4 changed files with 121 additions and 23 deletions

View File

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

View File

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

View File

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

View File

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