Files
openclaw/src/cli/skills-cli.curator.test.ts
Peter Steinberger 1a307be2e0 feat(skills): add lifecycle curator for workshop-created skills (#101214)
Tracks per-skill usage from the skill.used diagnostic event (trusted-only
delivery, file-scoped identity), sweeps workshop-created skills daily from
gateway maintenance (active -> stale 30d -> archived 90d, pinned bypass,
restore-only unarchive, files never touched), filters archived skills from
snapshots fail-open, reports workspace-scoped overlap candidates, and adds
openclaw skills curator CLI, additive gateway methods, and a warn-only
doctor finding. Zero new config keys; SQLite/Kysely state only.
2026-07-07 03:25:44 +01:00

138 lines
4.3 KiB
TypeScript

import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { registerSkillsCli } from "./skills-cli.js";
const mocks = vi.hoisted(() => {
const output: unknown[] = [];
return {
callGateway: vi.fn(),
config: {} as { gateway?: { mode: "local" | "remote" } },
output,
defaultRuntime: {
log: vi.fn(),
error: vi.fn(),
writeStdout: vi.fn(),
writeJson: vi.fn((value: unknown) => output.push(value)),
exit: vi.fn((code: number) => {
throw new Error(`__exit__:${code}`);
}),
},
};
});
vi.mock("../runtime.js", () => ({ defaultRuntime: mocks.defaultRuntime }));
vi.mock("../gateway/call.js", () => ({ callGateway: mocks.callGateway }));
vi.mock("../config/config.js", () => ({
getRuntimeConfig: () => mocks.config,
resetConfigRuntimeState: () => undefined,
}));
vi.mock("../terminal/links.js", () => ({ formatDocsLink: () => "docs.openclaw.ai/cli/skills" }));
vi.mock("../terminal/theme.js", () => ({
theme: {
command: (value: string) => value,
error: (value: string) => value,
heading: (value: string) => value,
muted: (value: string) => value,
success: (value: string) => value,
warn: (value: string) => value,
},
}));
const status = {
lastAttemptAtMs: 1,
lastSuccessAtMs: 1,
lastError: null,
counts: { active: 1, stale: 0, archived: 0 },
skills: [
{
skillFile: "/workspace/skills/daily-brief/SKILL.md",
skillKey: "daily-brief",
skillName: "Daily Brief",
state: "active",
pinned: false,
createdAtMs: 1,
stateChangedAtMs: 1,
lastUsedAtMs: null,
useCount: 0,
archivedReason: null,
},
],
overlaps: [],
};
function createProgram(): Command {
const program = new Command();
program.exitOverride();
registerSkillsCli(program);
return program;
}
describe("skills curator cli", () => {
beforeEach(() => {
delete mocks.config.gateway;
mocks.output.length = 0;
mocks.callGateway.mockReset().mockImplementation(async (request: { method: string }) => {
if (request.method === "skills.curator.status") {
return status;
}
return { ...status.skills[0], pinned: request.method === "skills.curator.pin" };
});
mocks.defaultRuntime.writeJson.mockClear();
mocks.defaultRuntime.writeStdout.mockClear();
});
it("supports status, pin, unpin, and restore JSON paths", async () => {
for (const argv of [
["skills", "curator", "status", "--json"],
["skills", "curator", "pin", "daily-brief", "--json"],
["skills", "curator", "unpin", "daily-brief", "--json"],
["skills", "curator", "restore", "daily-brief", "--json"],
]) {
await createProgram().parseAsync(argv, { from: "user" });
}
expect(mocks.callGateway.mock.calls.map(([request]) => request.method)).toEqual([
"skills.curator.status",
"skills.curator.pin",
"skills.curator.unpin",
"skills.curator.restore",
]);
expect(mocks.defaultRuntime.writeJson).toHaveBeenCalledTimes(4);
expect(mocks.output).toHaveLength(4);
});
it("surfaces remote gateway failures without mutating local state", async () => {
mocks.config.gateway = { mode: "remote" };
mocks.callGateway.mockRejectedValue(new Error("remote unavailable"));
await expect(
createProgram().parseAsync(["skills", "curator", "pin", "daily-brief", "--json"], {
from: "user",
}),
).rejects.toThrow("__exit__:1");
expect(mocks.defaultRuntime.error).toHaveBeenCalledWith("Error: remote unavailable");
});
it("disambiguates duplicate skill keys in text status", async () => {
mocks.callGateway.mockResolvedValue({
...status,
skills: [
status.skills[0],
{
...status.skills[0],
skillFile: "/other-workspace/skills/daily-brief/SKILL.md",
},
],
});
await createProgram().parseAsync(["skills", "curator", "status"], { from: "user" });
expect(mocks.defaultRuntime.writeStdout).toHaveBeenCalledWith(
expect.stringContaining("daily-brief (/workspace/skills/daily-brief/SKILL.md) active"),
);
expect(mocks.defaultRuntime.writeStdout).toHaveBeenCalledWith(
expect.stringContaining("daily-brief (/other-workspace/skills/daily-brief/SKILL.md) active"),
);
});
});