feat(cli): add per-skill pinning

Add per-skill CLI pinning and harden install semantics so pinned skills cannot be overwritten by forced installs.\n\nTests:\n- bunx vitest run packages/clawhub/src/cli/commands/skills.test.ts packages/clawhub/src/skills.test.ts packages/schema/src/schemas.test.ts\n- bun run --cwd packages/clawhub verify:build\n- bun run ci:static\n- bunx tsc -p packages/schema/tsconfig.json --noEmit\n- bunx tsc -p packages/clawhub/tsconfig.json --noEmit\n\nCo-authored-by: deepujain <deepujain@users.noreply.github.com>
This commit is contained in:
Deepak Jain
2026-05-09 08:35:41 -04:00
committed by GitHub
parent ada5556752
commit ab3ede496d
10 changed files with 311 additions and 15 deletions
+1
View File
@@ -4,6 +4,7 @@
### Changes
- CLI: add per-skill pinning so installed skills can be frozen against direct updates, bulk updates, and force reinstalls (#1806) (thanks @deepujain).
- Web: polish browse/listing surfaces across skills, plugins, and search, including plugin card view parity, clearer search controls, visible safety filtering, and more consistent card metadata treatment (#2084) (thanks @vyctorbrzezowski).
- Web: rename the skills and plugins browse alternate view from Cards to Grid while keeping legacy `view=cards` URLs compatible (#2090) (thanks @vyctorbrzezowski).
- Web: allow skill owners and publisher admins to edit a skill summary from the detail page (#1411) (thanks @SylvanXiao).
+16
View File
@@ -126,6 +126,7 @@ Stores your API token + cached registry URL.
- Resolves latest version via `/api/v1/skills/<slug>`.
- Downloads zip via `/api/v1/download`.
- Extracts into `<workdir>/<dir>/<slug>`.
- Refuses to overwrite pinned skills; run `clawhub unpin <slug>` first.
- Writes:
- `<workdir>/.clawhub/lock.json` (legacy `.clawdhub`)
- `<skill>/.clawhub/origin.json` (legacy `.clawdhub`)
@@ -139,6 +140,18 @@ Stores your API token + cached registry URL.
### `list`
- Reads `<workdir>/.clawhub/lock.json` (legacy `.clawdhub`).
- Shows `pinned` next to skills frozen with `clawhub pin`, including the optional reason.
### `pin <slug>`
- Marks an installed skill as pinned in the lockfile.
- `--reason <text>` records why the skill is frozen.
- Pinned skills are skipped by `update --all` and rejected by direct `update <slug>`.
- Pinned skills also reject `install --force` so the local bytes cannot be replaced accidentally.
### `unpin <slug>`
- Removes the lockfile pin from an installed skill so future updates can modify it.
### `update [slug]` / `update --all`
@@ -147,6 +160,9 @@ Stores your API token + cached registry URL.
- If fingerprint does not match:
- refuses by default
- overwrites with `--force` (or prompt, if interactive)
- Pinned skills are never updated by `--force`.
- `update <slug>` fails fast for pinned slugs and tells you to run `clawhub unpin <slug>` first.
- `update --all` skips pinned slugs and prints a summary of what stayed frozen.
### `skill publish <path>`
+2
View File
@@ -39,8 +39,10 @@ Notes:
```bash
clawhub search "postgres backups"
clawhub install my-skill-pack
clawhub pin bear-notes --reason "scanner-flagged while awaiting moderation"
clawhub update --all
clawhub update --all --no-input --force
clawhub unpin bear-notes
clawhub skill publish ./my-skill-pack --slug my-skill-pack --name "My Skill Pack" --version 1.2.0 --changelog "Fixes + docs"
clawhub skill publish ./org-skill --owner openclaw --version 1.2.0 --changelog "Org publish"
clawhub package explore --family skill
+19
View File
@@ -35,8 +35,10 @@ import {
cmdExplore,
cmdInstall,
cmdList,
cmdPin,
cmdSearch,
cmdUninstall,
cmdUnpin,
cmdUpdate,
} from "./cli/commands/skills.js";
import { cmdStarSkill } from "./cli/commands/star.js";
@@ -243,6 +245,23 @@ registerCommand(program, ["list"])
await cmdList(opts);
});
registerCommand(program, ["pin"])
.description("Pin an installed skill so update commands skip it")
.argument("<slug>", "Skill slug")
.option("--reason <text>", "Optional pin reason")
.action(async (slug, options) => {
const opts = await resolveGlobalOpts();
await cmdPin(opts, slug, options);
});
registerCommand(program, ["unpin"])
.description("Remove a skill pin so updates can change it again")
.argument("<slug>", "Skill slug")
.action(async (slug) => {
const opts = await resolveGlobalOpts();
await cmdUnpin(opts, slug);
});
registerCommand(program, ["explore"])
.description("Browse latest updated skills from the registry")
.option(
@@ -68,13 +68,16 @@ const {
clampLimit,
cmdExplore,
cmdInstall,
cmdList,
cmdListSkillAppeals,
cmdListSkillReports,
cmdPin,
cmdReportSkill,
cmdResolveSkillAppeal,
cmdSearch,
cmdTriageSkillReport,
cmdUninstall,
cmdUnpin,
cmdUpdate,
formatExploreLine,
} = await import("./skills.js");
@@ -440,6 +443,59 @@ describe("skill moderation commands", () => {
});
describe("cmdUpdate", () => {
it("fails when directly updating a pinned skill", async () => {
vi.mocked(readLockfile).mockResolvedValue({
version: 1,
skills: {
demo: { version: "0.1.0", installedAt: 123, pinned: true, pinReason: "hold" },
},
});
await expect(cmdUpdate(makeOpts(), "demo", { force: true }, false)).rejects.toThrow(
/is pinned/i,
);
expect(mockApiRequest).not.toHaveBeenCalled();
expect(mockDownloadZip).not.toHaveBeenCalled();
});
it("skips pinned skills during update --all and reports them in the summary", async () => {
mockApiRequest.mockResolvedValue({
latestVersion: { version: "2.0.0" },
moderation: null,
});
mockDownloadZip.mockResolvedValue(new Uint8Array([1, 2, 3]));
vi.mocked(readLockfile).mockResolvedValue({
version: 1,
skills: {
demo: { version: "0.1.0", installedAt: 123, pinned: true, pinReason: "hold" },
other: { version: "1.0.0", installedAt: 456 },
},
});
vi.mocked(writeLockfile).mockResolvedValue();
vi.mocked(readSkillOrigin).mockResolvedValue(null);
vi.mocked(writeSkillOrigin).mockResolvedValue();
vi.mocked(extractZipToDir).mockResolvedValue();
vi.mocked(listTextFiles).mockResolvedValue([]);
vi.mocked(hashSkillFiles).mockReturnValue({ fingerprint: "hash", files: [] });
vi.mocked(stat).mockRejectedValue(new Error("missing"));
vi.mocked(rm).mockResolvedValue();
await cmdUpdate(makeOpts(), undefined, { all: true }, false);
expect(mockApiRequest).toHaveBeenCalledTimes(1);
const [, args] = mockApiRequest.mock.calls[0] ?? [];
expect(args?.path).toBe(`${ApiRoutes.skills}/${encodeURIComponent("other")}`);
expect(writeLockfile).toHaveBeenCalledWith("/work", {
version: 1,
skills: {
demo: { version: "0.1.0", installedAt: 123, pinned: true, pinReason: "hold" },
other: { version: "2.0.0", installedAt: expect.any(Number) },
},
});
expect(mockLog).toHaveBeenCalledWith("Skipped 1 pinned skill: demo");
});
it("uses path-based skill lookup when no local fingerprint is available", async () => {
mockApiRequest.mockResolvedValue({ latestVersion: { version: "1.0.0" } });
mockDownloadZip.mockResolvedValue(new Uint8Array([1, 2, 3]));
@@ -464,6 +520,85 @@ describe("cmdUpdate", () => {
});
});
describe("pin commands", () => {
it("pins an installed skill and preserves its version metadata", async () => {
vi.mocked(readLockfile).mockResolvedValue({
version: 1,
skills: { demo: { version: "1.0.0", installedAt: 123 } },
});
vi.mocked(writeLockfile).mockResolvedValue();
await cmdPin(makeOpts(), "demo", { reason: "scanner hold" });
expect(writeLockfile).toHaveBeenCalledWith("/work", {
version: 1,
skills: {
demo: {
version: "1.0.0",
installedAt: 123,
pinned: true,
pinReason: "scanner hold",
},
},
});
expect(mockLog).toHaveBeenCalledWith("Pinned demo: scanner hold");
});
it("reports when an installed skill is already pinned without changes", async () => {
vi.mocked(readLockfile).mockResolvedValue({
version: 1,
skills: {
demo: { version: "1.0.0", installedAt: 123, pinned: true, pinReason: "scanner hold" },
},
});
await cmdPin(makeOpts(), "demo");
expect(writeLockfile).not.toHaveBeenCalled();
expect(mockLog).toHaveBeenCalledWith('Skill "demo" is already pinned: scanner hold');
});
it("unpinned skills clear pin metadata and keep the installed version", async () => {
vi.mocked(readLockfile).mockResolvedValue({
version: 1,
skills: {
demo: { version: "1.0.0", installedAt: 123, pinned: true, pinReason: "scanner hold" },
},
});
vi.mocked(writeLockfile).mockResolvedValue();
await cmdUnpin(makeOpts(), "demo");
expect(writeLockfile).toHaveBeenCalledWith("/work", {
version: 1,
skills: {
demo: {
version: "1.0.0",
installedAt: 123,
},
},
});
expect(mockLog).toHaveBeenCalledWith("Unpinned demo");
});
});
describe("cmdList", () => {
it("shows pinned state in list output", async () => {
vi.mocked(readLockfile).mockResolvedValue({
version: 1,
skills: {
demo: { version: "1.0.0", installedAt: 123, pinned: true, pinReason: "scanner hold" },
other: { version: "2.0.0", installedAt: 456 },
},
});
await cmdList(makeOpts());
expect(mockLog).toHaveBeenCalledWith("demo 1.0.0 pinned (scanner hold)");
expect(mockLog).toHaveBeenCalledWith("other 2.0.0");
});
});
describe("cmdInstall", () => {
it("passes optional auth token to API + download requests", async () => {
mockGetOptionalAuthToken.mockResolvedValue("tkn");
@@ -497,6 +632,21 @@ describe("cmdInstall", () => {
expect(zipArgs?.token).toBe("tkn");
});
it("blocks force reinstall when a skill is pinned", async () => {
vi.mocked(readLockfile).mockResolvedValue({
version: 1,
skills: { demo: { version: "0.9.0", installedAt: 123, pinned: true, pinReason: "hold" } },
});
vi.mocked(stat).mockRejectedValue(new Error("missing"));
await expect(cmdInstall(makeOpts(), "demo", undefined, true)).rejects.toThrow(/is pinned/i);
expect(mockApiRequest).not.toHaveBeenCalled();
expect(mockDownloadZip).not.toHaveBeenCalled();
expect(rm).not.toHaveBeenCalled();
expect(writeLockfile).not.toHaveBeenCalled();
});
it("does not rm local directory when skill is malware-blocked (--force)", async () => {
vi.mocked(stat).mockResolvedValue({} as unknown as Awaited<ReturnType<typeof stat>>); // target exists
mockApiRequest.mockResolvedValue({
+96 -13
View File
@@ -100,6 +100,27 @@ function isSafeSkillSlug(slug: string) {
return Boolean(slug) && !slug.includes("/") && !slug.includes("\\") && !slug.includes("..");
}
function isPinnedSkillEntry(entry?: { pinned?: boolean | null }) {
return entry?.pinned === true;
}
function withPinnedMetadata(
version: string | null,
installedAt: number,
existing?: { pinned?: boolean; pinReason?: string },
) {
return {
version,
installedAt,
...(existing?.pinned ? { pinned: true } : {}),
...(existing?.pinned && existing.pinReason ? { pinReason: existing.pinReason } : {}),
};
}
function formatPinnedDetails(entry?: { pinReason?: string }) {
return entry?.pinReason ? ` (${entry.pinReason})` : "";
}
export async function cmdSearch(opts: GlobalOpts, query: string, limit?: number) {
if (!query) fail("Query required");
@@ -148,6 +169,12 @@ export async function cmdInstall(
if (exists) fail(`Already installed: ${target} (use --force)`);
}
const lock = await readLockfile(opts.workdir);
const existingEntry = lock.skills[trimmed];
if (isPinnedSkillEntry(existingEntry)) {
fail(`skill "${trimmed}" is pinned; run \`clawhub unpin ${trimmed}\` first`);
}
const spinner = createSpinner(`Resolving ${trimmed}`);
try {
// Fetch skill metadata including moderation status
@@ -212,11 +239,7 @@ export async function cmdInstall(
installedAt: Date.now(),
});
const lock = await readLockfile(opts.workdir);
lock.skills[trimmed] = {
version: resolvedVersion,
installedAt: Date.now(),
};
lock.skills[trimmed] = withPinnedMetadata(resolvedVersion, Date.now(), existingEntry);
await writeLockfile(opts.workdir, lock);
spinner.succeed(`OK. Installed ${trimmed} -> ${target}`);
} catch (error) {
@@ -237,14 +260,30 @@ export async function cmdUpdate(
if (slug && all) fail("Use either <slug> or --all");
if (options.version && !slug) fail("--version requires a single <slug>");
if (options.version && !semver.valid(options.version)) fail("--version must be valid semver");
const lock = await readLockfile(opts.workdir);
if (slug && isPinnedSkillEntry(lock.skills[slug])) {
fail(`skill "${slug}" is pinned; run \`clawhub unpin ${slug}\` first`);
}
const allowPrompt = isInteractive() && inputAllowed;
const token = await getOptionalAuthToken();
const registry = await getRegistry(opts, { cache: true });
const lock = await readLockfile(opts.workdir);
const slugs = slug ? [slug] : Object.keys(lock.skills).filter(isSafeSkillSlug);
const requestedSlugs = slug ? [slug] : Object.keys(lock.skills).filter(isSafeSkillSlug);
const skippedPinned = slug
? []
: requestedSlugs.filter((entry) => isPinnedSkillEntry(lock.skills[entry]));
const slugs = slug
? requestedSlugs
: requestedSlugs.filter((entry) => !isPinnedSkillEntry(lock.skills[entry]));
if (slugs.length === 0) {
if (skippedPinned.length > 0) {
const suffix = skippedPinned.length === 1 ? "" : "s";
console.log(
`Skipped ${skippedPinned.length} pinned skill${suffix}: ${skippedPinned.join(", ")}`,
);
return;
}
console.log("No installed skills.");
return;
}
@@ -308,10 +347,11 @@ export async function cmdUpdate(
const matched = resolveResult.match?.version ?? null;
if (matched && lock.skills[entry]?.version !== matched) {
lock.skills[entry] = {
version: matched,
installedAt: lock.skills[entry]?.installedAt ?? Date.now(),
};
lock.skills[entry] = withPinnedMetadata(
matched,
lock.skills[entry]?.installedAt ?? Date.now(),
lock.skills[entry],
);
}
if (!latest) {
@@ -364,7 +404,7 @@ export async function cmdUpdate(
installedAt: existingOrigin?.installedAt ?? Date.now(),
});
lock.skills[entry] = { version: targetVersion, installedAt: Date.now() };
lock.skills[entry] = withPinnedMetadata(targetVersion, Date.now(), lock.skills[entry]);
spinner.succeed(`${entry}: updated -> ${targetVersion}`);
} catch (error) {
spinner.fail(formatError(error));
@@ -373,6 +413,12 @@ export async function cmdUpdate(
}
await writeLockfile(opts.workdir, lock);
if (skippedPinned.length > 0) {
const suffix = skippedPinned.length === 1 ? "" : "s";
console.log(
`Skipped ${skippedPinned.length} pinned skill${suffix}: ${skippedPinned.join(", ")}`,
);
}
}
export async function cmdList(opts: GlobalOpts) {
@@ -384,7 +430,8 @@ export async function cmdList(opts: GlobalOpts) {
return;
}
for (const [slug, entry] of entries) {
console.log(`${slug} ${entry.version ?? "latest"}`);
const pinned = isPinnedSkillEntry(entry) ? ` pinned${formatPinnedDetails(entry)}` : "";
console.log(`${slug} ${entry.version ?? "latest"}${pinned}`);
}
if (manualSkills.length > 0) {
if (entries.length > 0) console.log();
@@ -395,6 +442,42 @@ export async function cmdList(opts: GlobalOpts) {
}
}
export async function cmdPin(opts: GlobalOpts, slug: string, options: { reason?: string } = {}) {
const trimmed = normalizeSkillSlugOrFail(slug);
const lock = await readLockfile(opts.workdir);
const existing = lock.skills[trimmed];
if (!existing) fail(`Not installed: ${trimmed}`);
const reason = options.reason?.trim() || existing.pinReason;
if (isPinnedSkillEntry(existing) && reason === existing.pinReason) {
console.log(`Skill "${trimmed}" is already pinned${reason ? `: ${reason}` : ""}`);
return;
}
lock.skills[trimmed] = {
...existing,
pinned: true,
...(reason ? { pinReason: reason } : {}),
};
await writeLockfile(opts.workdir, lock);
console.log(`Pinned ${trimmed}${reason ? `: ${reason}` : ""}`);
}
export async function cmdUnpin(opts: GlobalOpts, slug: string) {
const trimmed = normalizeSkillSlugOrFail(slug);
const lock = await readLockfile(opts.workdir);
const existing = lock.skills[trimmed];
if (!existing) fail(`Not installed: ${trimmed}`);
if (!isPinnedSkillEntry(existing)) fail(`Skill "${trimmed}" is not pinned`);
lock.skills[trimmed] = {
version: existing.version,
installedAt: existing.installedAt,
};
await writeLockfile(opts.workdir, lock);
console.log(`Unpinned ${trimmed}`);
}
export async function cmdUninstall(
opts: GlobalOpts,
slug: string,
+2
View File
@@ -23,6 +23,8 @@ export const LockfileSchema = type({
"[string]": {
version: "string|null",
installedAt: "number",
pinned: "boolean?",
pinReason: "string?",
},
},
});
+10 -1
View File
@@ -39,10 +39,19 @@ describe("skills", () => {
const workdir = await mkdtemp(join(tmpdir(), "clawhub-work-"));
await writeLockfile(workdir, {
version: 1,
skills: { demo: { version: "1.0.0", installedAt: 1 } },
skills: {
demo: {
version: "1.0.0",
installedAt: 1,
pinned: true,
pinReason: "awaiting moderation review",
},
},
});
const read = await readLockfile(workdir);
expect(read.skills.demo?.version).toBe("1.0.0");
expect(read.skills.demo?.pinned).toBe(true);
expect(read.skills.demo?.pinReason).toBe("awaiting moderation review");
});
it("returns empty lockfile on invalid json", async () => {
+13 -1
View File
@@ -16,10 +16,22 @@ describe("clawhub-schema", () => {
it("parses lockfile records", () => {
const lock = parseArk(
LockfileSchema,
{ version: 1, skills: { demo: { version: "1.0.0", installedAt: 123 } } },
{
version: 1,
skills: {
demo: {
version: "1.0.0",
installedAt: 123,
pinned: true,
pinReason: "scanner-flagged",
},
},
},
"Lockfile",
);
expect(lock.skills.demo?.version).toBe("1.0.0");
expect(lock.skills.demo?.pinned).toBe(true);
expect(lock.skills.demo?.pinReason).toBe("scanner-flagged");
});
it("allows publish payload without tags", () => {
+2
View File
@@ -24,6 +24,8 @@ export const LockfileSchema = type({
"[string]": {
version: "string|null",
installedAt: "number",
pinned: "boolean?",
pinReason: "string?",
},
},
});