mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 17:31:06 +00:00
Add agent visibility to skills check
This commit is contained in:
@@ -38,6 +38,7 @@ openclaw skills info <name>
|
||||
openclaw skills info <name> --json
|
||||
openclaw skills info <name> --agent <id>
|
||||
openclaw skills check
|
||||
openclaw skills check --agent <id>
|
||||
openclaw skills check --json
|
||||
openclaw skills check --agent <id>
|
||||
```
|
||||
@@ -63,6 +64,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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<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 +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,
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -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:"));
|
||||
|
||||
@@ -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 {
|
||||
@@ -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<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 +377,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