mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:10:44 +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:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user