From a9a80bbf6d9aa64f27157cb2ffb9bf64160aef38 Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Sat, 25 Jul 2026 08:59:25 -0500 Subject: [PATCH] feat(cli): support skills.sh install references (#3226) * feat(cli): support skills.sh install references * fix(cli): preserve repo sync alias installs * fix(cli): verify skills.sh artifact identity on update * fix(cli): bind scanned verification to canonical alias --- packages/clawhub/src/cli.ts | 4 +- .../clawhub/src/cli/commands/inspect.test.ts | 164 +++- packages/clawhub/src/cli/commands/inspect.ts | 85 ++- .../src/cli/commands/installTelemetry.ts | 14 + .../clawhub/src/cli/commands/skills.test.ts | 703 +++++++++++++++++- packages/clawhub/src/cli/commands/skills.ts | 440 ++++++++--- packages/clawhub/src/cli/skillReference.ts | 69 ++ packages/clawhub/src/schema/schemas.ts | 7 + packages/clawhub/src/skills.test.ts | 26 + packages/clawhub/src/skills.ts | 21 + 10 files changed, 1376 insertions(+), 157 deletions(-) create mode 100644 packages/clawhub/src/cli/skillReference.ts diff --git a/packages/clawhub/src/cli.ts b/packages/clawhub/src/cli.ts index bdbf232f..b0ba0ed7 100644 --- a/packages/clawhub/src/cli.ts +++ b/packages/clawhub/src/cli.ts @@ -290,7 +290,7 @@ registerCommand(program, ["search"]) registerCommand(program, ["install"]) .description("Install a skill into ") - .argument("", "Skill to install, e.g. @openclaw/demo") + .argument("", "Skill to install, e.g. @openclaw/demo or skills-sh:owner/repo/slug") .option("--version ", "Version to install") .option("--force", "Overwrite existing folder") .option("--force-install", "Install a pending GitHub-backed skill before ClawHub scan completes") @@ -301,7 +301,7 @@ registerCommand(program, ["install"]) registerCommand(program, ["update"]) .description("Update installed skills") - .argument("[skill]", "Skill to update, e.g. @openclaw/demo") + .argument("[skill]", "Skill to update, e.g. @openclaw/demo or skills-sh:owner/repo/slug") .option("--all", "Update all installed skills") .option("--version ", "Update to specific version (single slug only)") .option("--force", "Overwrite when local files do not match any version") diff --git a/packages/clawhub/src/cli/commands/inspect.test.ts b/packages/clawhub/src/cli/commands/inspect.test.ts index b5008567..b4ba0670 100644 --- a/packages/clawhub/src/cli/commands/inspect.test.ts +++ b/packages/clawhub/src/cli/commands/inspect.test.ts @@ -360,13 +360,13 @@ describe("cmdInspect", () => { }); describe("cmdVerifySkill", () => { - it("prints exact skills.sh catalog verification from the standard verify route", async () => { - const sourceRef = "skills-sh/patrick-erichsen/skills/html"; + it("prints an explicit unscanned skills.sh verification from the standard verify route", async () => { + const sourceRef = "skills-sh:patrick-erichsen/skills/html"; const payload = { schema: "clawhub.skill.verify.v1", - ok: true, - decision: "pass", - reasons: [], + ok: false, + decision: "fail", + reasons: ["Not scanned by ClawHub"], slug: sourceRef, displayName: "HTML Artifact Chooser", pageUrl: "https://clawhub.ai/skills-sh/patrick-erichsen/skills/html", @@ -385,8 +385,14 @@ describe("cmdVerifySkill", () => { bundleFingerprints: ["c".repeat(64)], files: [{ path: "SKILL.md", size: 42, sha256: "d".repeat(64) }], }, - provenance: { source: "skills-sh-catalog" }, - security: { status: "clean", passed: true }, + provenance: { + source: "skills.sh", + reference: sourceRef, + }, + security: { + clawhubScan: "unscanned", + label: "Not scanned by ClawHub", + }, signature: { status: "unsigned" }, }; httpMocks.apiRequest.mockResolvedValueOnce(payload); @@ -398,12 +404,150 @@ describe("cmdVerifySkill", () => { expect(url.pathname).toBe(`${ApiRoutes.skills}/html/verify`); expect(url.searchParams.get("reference")).toBe(sourceRef); expect(JSON.parse(String(mockLog.mock.calls[0]?.[0]))).toEqual(payload); + expect(process.exitCode).toBe(1); }); - it("rejects colon-form skills.sh verification references", async () => { + it("rejects legacy slash-form skills.sh verification references before network access", async () => { await expect( - cmdVerifySkill(makeGlobalOpts(), "skills-sh:patrick-erichsen/skills/html"), - ).rejects.toThrow("Invalid skills.sh ref: use skills-sh/owner/repo/slug"); + cmdVerifySkill(makeGlobalOpts(), "skills-sh/patrick-erichsen/skills/html"), + ).rejects.toThrow("Invalid skills.sh ref: use skills-sh:owner/repo/slug"); + expect(authTokenMocks.getOptionalAuthToken).not.toHaveBeenCalled(); + expect(registryMocks.getRegistry).not.toHaveBeenCalled(); + expect(httpMocks.apiRequest).not.toHaveBeenCalled(); + }); + + it("rejects an unscanned skills.sh verification that fabricates a pass", async () => { + const sourceRef = "skills-sh:patrick-erichsen/skills/html"; + httpMocks.apiRequest.mockResolvedValueOnce({ + schema: "clawhub.skill.verify.v1", + ok: true, + decision: "pass", + reasons: [], + slug: sourceRef, + displayName: "HTML", + pageUrl: "https://clawhub.ai/skills-sh/patrick-erichsen/skills/html", + publisherHandle: null, + publisherDisplayName: null, + publisherProfileUrl: null, + version: "a".repeat(40), + resolvedFrom: "latest", + tag: null, + createdAt: 123, + card: {}, + artifact: {}, + provenance: { source: "skills.sh", reference: sourceRef }, + security: { clawhubScan: "unscanned", label: "Not scanned by ClawHub" }, + signature: {}, + }); + + await expect(cmdVerifySkill(makeGlobalOpts(), sourceRef)).rejects.toThrow( + 'skills.sh verification must report "Not scanned by ClawHub" rather than pass', + ); + }); + + it("rejects scanned skills.sh verification without a canonical native alias", async () => { + const sourceRef = "skills-sh:patrick-erichsen/skills/html"; + httpMocks.apiRequest.mockResolvedValueOnce({ + schema: "clawhub.skill.verify.v1", + ok: true, + decision: "pass", + reasons: [], + slug: "html", + displayName: "HTML", + pageUrl: "https://clawhub.ai/openclaw/html", + publisherHandle: "openclaw", + publisherDisplayName: "OpenClaw", + publisherProfileUrl: "https://clawhub.ai/openclaw", + version: "a".repeat(40), + resolvedFrom: "skills-sh-alias", + tag: null, + createdAt: 123, + card: {}, + artifact: {}, + provenance: { source: "skills.sh", reference: sourceRef }, + security: { clawhubScan: "scanned", label: "Scanned by ClawHub" }, + signature: {}, + }); + + await expect(cmdVerifySkill(makeGlobalOpts(), sourceRef)).rejects.toThrow( + "scanned skills.sh verification must return a canonical native reference", + ); + }); + + it("rejects scanned skills.sh verification with an invalid trust label", async () => { + const sourceRef = "skills-sh:patrick-erichsen/skills/html"; + httpMocks.apiRequest.mockResolvedValueOnce({ + schema: "clawhub.skill.verify.v1", + ok: true, + decision: "pass", + reasons: [], + slug: "html", + displayName: "HTML", + pageUrl: "https://clawhub.ai/openclaw/html", + publisherHandle: "openclaw", + publisherDisplayName: "OpenClaw", + publisherProfileUrl: "https://clawhub.ai/openclaw", + version: "a".repeat(40), + resolvedFrom: "skills-sh-alias", + tag: null, + createdAt: 123, + card: {}, + artifact: {}, + provenance: { source: "skills.sh", reference: sourceRef }, + security: { clawhubScan: "scanned", label: "Trusted" }, + canonicalRef: "@openclaw/html", + signature: {}, + }); + + await expect(cmdVerifySkill(makeGlobalOpts(), sourceRef)).rejects.toThrow( + 'scanned skills.sh verification must report "Scanned by ClawHub"', + ); + }); + + it("prints scanned Repo Sync alias provenance and canonical verification", async () => { + const sourceRef = "skills-sh:patrick-erichsen/skills/html"; + const payload = { + schema: "clawhub.skill.verify.v1", + ok: true, + decision: "pass", + reasons: [], + slug: "html", + displayName: "HTML", + pageUrl: "https://clawhub.ai/openclaw/html", + publisherHandle: "openclaw", + publisherDisplayName: "OpenClaw", + publisherProfileUrl: "https://clawhub.ai/openclaw", + version: "a".repeat(40), + resolvedFrom: "skills-sh-alias", + tag: null, + createdAt: 123, + card: {}, + artifact: { + sourceFingerprint: "b".repeat(64), + bundleFingerprints: ["c".repeat(64)], + files: [{ path: "SKILL.md", size: 42, sha256: "d".repeat(64) }], + }, + provenance: { + source: "skills.sh", + reference: sourceRef, + repository: "patrick-erichsen/skills", + path: "skills/html", + commit: "a".repeat(40), + contentHash: "b".repeat(64), + }, + security: { + clawhubScan: "scanned", + label: "Scanned by ClawHub", + }, + canonicalRef: "@openclaw/html", + signature: {}, + }; + httpMocks.apiRequest.mockResolvedValueOnce(payload); + + await cmdVerifySkill(makeGlobalOpts(), sourceRef); + + expect(JSON.parse(String(mockLog.mock.calls[0]?.[0]))).toEqual(payload); + expect(process.exitCode).not.toBe(1); }); it("fetches and prints JSON verification by default", async () => { diff --git a/packages/clawhub/src/cli/commands/inspect.ts b/packages/clawhub/src/cli/commands/inspect.ts index e092dbdb..641e4077 100644 --- a/packages/clawhub/src/cli/commands/inspect.ts +++ b/packages/clawhub/src/cli/commands/inspect.ts @@ -12,6 +12,11 @@ import { } from "../../schema/index.js"; import { getOptionalAuthToken } from "../authToken.js"; import { getRegistry } from "../registry.js"; +import { + parseSkillsShCliReference, + SKILLS_SH_SCANNED_LABEL, + SKILLS_SH_UNSCANNED_LABEL, +} from "../skillReference.js"; import type { GlobalOpts } from "../types.js"; import { createCrabLoader, fail, formatError, styleText } from "../ui.js"; @@ -261,9 +266,6 @@ export async function cmdVerifySkill( slug: string, options: VerifySkillOptions = {}, ) { - if (slug.trim().toLowerCase().startsWith("skills-sh:")) { - fail("Invalid skills.sh ref: use skills-sh/owner/repo/slug"); - } const skillsShRef = parseSkillsShCatalogRef(slug); if (skillsShRef && (options.version || options.tag || options.card)) { fail("skills.sh verification does not support --version, --tag, or --card"); @@ -279,7 +281,7 @@ export async function cmdVerifySkill( try { const url = registryUrl(`${ApiRoutes.skills}/${encodeURIComponent(trimmed)}/verify`, registry); if (skillsShRef) { - url.searchParams.set("reference", slug.trim().toLowerCase()); + url.searchParams.set("reference", skillsShRef.sourceRef); } else if (requested.ownerHandle) { url.searchParams.set("ownerHandle", requested.ownerHandle); } @@ -294,6 +296,7 @@ export async function cmdVerifySkill( { method: "GET", url: url.toString(), token }, ApiV1SkillVerifyResponseSchema, ); + if (skillsShRef) validateSkillsShVerification(result, skillsShRef.sourceRef); if (options.card) { const cardUrl = readSkillCardUrl(result); @@ -318,21 +321,67 @@ export async function cmdVerifySkill( } function parseSkillsShCatalogRef(raw: string) { - const value = raw.trim().toLowerCase(); - if (!value.startsWith("skills-sh/")) return null; - const segments = value.split("/"); - if ( - segments.length !== 4 || - segments[0] !== "skills-sh" || - segments.slice(1).some((segment) => !segment || segment.includes(":") || segment.includes("..")) - ) { - fail("Invalid skills.sh ref: use skills-sh/owner/repo/slug"); + return parseSkillsShCliReference(raw); +} + +function validateSkillsShVerification(result: unknown, requestedRef: string) { + const record = asRecord(result); + const provenance = asRecord(record.provenance); + const security = asRecord(record.security); + if (provenance.source !== "skills.sh" || provenance.reference !== requestedRef) { + fail("skills.sh verification did not preserve the requested external provenance"); } - return { - owner: segments[1]!, - repo: segments[2]!, - slug: segments[3]!, - }; + const scanState = security.clawhubScan; + const label = typeof security.label === "string" ? security.label.trim() : ""; + if (scanState !== "unscanned" && scanState !== "scanned") { + fail("skills.sh verification did not return a ClawHub scan state"); + } + if (!label) fail("skills.sh verification did not return a trust label"); + if (scanState === "unscanned") { + const reasons = Array.isArray(record.reasons) ? record.reasons : []; + if ( + record.ok !== false || + record.decision !== "fail" || + label !== SKILLS_SH_UNSCANNED_LABEL || + !reasons.includes(SKILLS_SH_UNSCANNED_LABEL) + ) { + fail(`skills.sh verification must report "${SKILLS_SH_UNSCANNED_LABEL}" rather than pass`); + } + } else { + if (label !== SKILLS_SH_SCANNED_LABEL) { + fail(`scanned skills.sh verification must report "${SKILLS_SH_SCANNED_LABEL}"`); + } + if (!isCanonicalNativeSkillRef(record.canonicalRef)) { + fail("scanned skills.sh verification must return a canonical native reference"); + } + } +} + +function isCanonicalNativeSkillRef(value: unknown) { + if (typeof value !== "string") return false; + const canonicalRef = value.trim(); + if (!canonicalRef.startsWith("@")) return false; + try { + const parsed = parseSkillRef(canonicalRef); + return Boolean( + parsed.ownerHandle && + isSafeNativeSkillSegment(parsed.ownerHandle) && + isSafeNativeSkillSegment(parsed.slug) && + canonicalRef === `@${parsed.ownerHandle}/${parsed.slug}`, + ); + } catch { + return false; + } +} + +function isSafeNativeSkillSegment(value: string) { + return Boolean(value) && !value.includes("/") && !value.includes("\\") && !value.includes(".."); +} + +function asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; } function parseSkillRef(raw: string) { diff --git a/packages/clawhub/src/cli/commands/installTelemetry.ts b/packages/clawhub/src/cli/commands/installTelemetry.ts index 181a4e8f..9e5e2428 100644 --- a/packages/clawhub/src/cli/commands/installTelemetry.ts +++ b/packages/clawhub/src/cli/commands/installTelemetry.ts @@ -7,6 +7,13 @@ export async function reportInstalledSkillsTelemetryIfEnabled(params: { slug: string; ownerHandle?: string | null; sourceRef?: string | null; + sourceKind?: "skills-sh" | null; + sourceRepository?: string | null; + sourcePath?: string | null; + sourceUrl?: string | null; + canonicalRef?: string | null; + clawhubScan?: "unscanned" | "scanned" | null; + trustLabel?: string | null; version?: string | null; }) { if (!params.token || isTelemetryDisabled()) return; @@ -25,6 +32,13 @@ export async function reportInstalledSkillsTelemetryIfEnabled(params: { slug, ownerHandle: params.ownerHandle ?? undefined, sourceRef: params.sourceRef ?? undefined, + sourceKind: params.sourceKind ?? undefined, + sourceRepository: params.sourceRepository ?? undefined, + sourcePath: params.sourcePath ?? undefined, + sourceUrl: params.sourceUrl ?? undefined, + canonicalRef: params.canonicalRef ?? undefined, + clawhubScan: params.clawhubScan ?? undefined, + trustLabel: params.trustLabel ?? undefined, version: params.version ?? undefined, }, }, diff --git a/packages/clawhub/src/cli/commands/skills.test.ts b/packages/clawhub/src/cli/commands/skills.test.ts index 1a7fbf5d..c6fdd3ed 100644 --- a/packages/clawhub/src/cli/commands/skills.test.ts +++ b/packages/clawhub/src/cli/commands/skills.test.ts @@ -106,6 +106,14 @@ function makeOpts() { return makeGlobalOpts(); } +function githubArtifactIdentity(repo: string, path: string, commit: string, contentHash: string) { + return JSON.stringify({ installKind: "github", repo, path, commit, contentHash }); +} + +function archiveArtifactIdentity(canonicalRef: string, version: string) { + return JSON.stringify({ installKind: "archive", canonicalRef, version }); +} + beforeEach(() => { mkdtempMock.mockImplementation(async (prefix: string) => `${prefix}123`); mkdirMock.mockResolvedValue(undefined); @@ -490,24 +498,37 @@ describe("skill moderation commands", () => { }); describe("cmdUpdate", () => { - it("updates a legacy slug-keyed skills.sh install through its stored sourceRef", async () => { - const sourceRef = "skills-sh/patrick-erichsen/skills/html"; + it("updates and canonicalizes a legacy slash-form skills.sh lock entry", async () => { + const sourceRef = "skills-sh:patrick-erichsen/skills/html"; + const legacySourceRef = "skills-sh/patrick-erichsen/skills/html"; const previousCommit = "a".repeat(40); const nextCommit = "b".repeat(40); const installedFiles = [{ path: "SKILL.md", sha256: "c".repeat(64), size: 1 }]; const contentHash = skillStore.buildGitHubFolderContentHash(installedFiles); - mockApiRequest.mockResolvedValueOnce({ - ok: true, - slug: sourceRef, - installKind: "github", - github: { - repo: "patrick-erichsen/skills", - path: "skills/html", - commit: nextCommit, - contentHash, - sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${nextCommit}/skills/html`, - }, - }); + mockGetOptionalAuthToken.mockResolvedValue("tkn"); + mockApiRequest + .mockResolvedValueOnce({ + ok: true, + slug: sourceRef, + installKind: "github", + github: { + repo: "patrick-erichsen/skills", + path: "skills/html", + commit: nextCommit, + contentHash, + sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${nextCommit}/skills/html`, + }, + provenance: { + source: "skills.sh", + reference: sourceRef, + }, + trust: { + clawhubScan: "unscanned", + label: "Not scanned by ClawHub", + }, + canonicalRef: null, + }) + .mockResolvedValueOnce({ ok: true }); mockFetchBinary.mockResolvedValue(new Uint8Array([1, 2, 3])); vi.mocked(readLockfile).mockResolvedValue({ version: 1, @@ -515,7 +536,7 @@ describe("cmdUpdate", () => { html: { version: previousCommit, installedAt: 123, - sourceRef, + sourceRef: legacySourceRef, }, }, }); @@ -523,7 +544,7 @@ describe("cmdUpdate", () => { version: 1, registry: "https://clawhub.ai", slug: "html", - sourceRef, + sourceRef: legacySourceRef, installedVersion: previousCommit, installedAt: 123, fingerprint: "hash", @@ -541,7 +562,7 @@ describe("cmdUpdate", () => { { method: "GET", path: `${ApiRoutes.skillsSh}/patrick-erichsen/skills/html/install`, - token: undefined, + token: "tkn", }, expect.anything(), ); @@ -553,6 +574,18 @@ describe("cmdUpdate", () => { registry: "https://clawhub.ai", slug: "html", sourceRef, + sourceKind: "skills-sh", + sourceRepository: "patrick-erichsen/skills", + sourcePath: "skills/html", + sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${nextCommit}/skills/html`, + clawhubScan: "unscanned", + trustLabel: "Not scanned by ClawHub", + artifactIdentity: githubArtifactIdentity( + "patrick-erichsen/skills", + "skills/html", + nextCommit, + contentHash, + ), installedVersion: nextCommit, installedAt: 123, fingerprint: "hash", @@ -560,13 +593,269 @@ describe("cmdUpdate", () => { expect(writeLockfile).toHaveBeenCalledWith("/work", { version: 1, skills: { - html: { + [sourceRef]: { version: nextCommit, installedAt: 123, sourceRef, + sourceKind: "skills-sh", + sourceRepository: "patrick-erichsen/skills", + sourcePath: "skills/html", + sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${nextCommit}/skills/html`, + clawhubScan: "unscanned", + trustLabel: "Not scanned by ClawHub", }, }, }); + expect(mockApiRequest).toHaveBeenNthCalledWith( + 2, + "https://clawhub.ai", + expect.objectContaining({ + path: LegacyApiRoutes.cliTelemetryInstall, + body: expect.objectContaining({ + event: "install", + slug: "html", + sourceRef, + sourceKind: "skills-sh", + sourceRepository: "patrick-erichsen/skills", + sourcePath: "skills/html", + clawhubScan: "unscanned", + trustLabel: "Not scanned by ClawHub", + version: nextCommit, + }), + }), + expect.anything(), + ); + }); + + it("updates an external install through a scanned Repo Sync alias", async () => { + const sourceRef = "skills-sh:patrick-erichsen/skills/html"; + const previousCommit = "a".repeat(40); + const nextCommit = "b".repeat(40); + const installedFiles = [{ path: "SKILL.md", sha256: "c".repeat(64), size: 1 }]; + const contentHash = skillStore.buildGitHubFolderContentHash(installedFiles); + mockGetOptionalAuthToken.mockResolvedValue("tkn"); + mockApiRequest + .mockResolvedValueOnce({ + ok: true, + slug: "html", + installKind: "github", + github: { + repo: "patrick-erichsen/skills", + path: "skills/html", + commit: nextCommit, + contentHash, + sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${nextCommit}/skills/html`, + }, + provenance: { + source: "skills.sh", + reference: sourceRef, + repository: "patrick-erichsen/skills", + path: "skills/html", + sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${nextCommit}/skills/html`, + }, + trust: { + clawhubScan: "scanned", + label: "Scanned by ClawHub", + }, + canonicalRef: "@openclaw/html", + }) + .mockResolvedValueOnce({ ok: true }); + mockFetchBinary.mockResolvedValue(new Uint8Array([1, 2, 3])); + vi.mocked(readLockfile).mockResolvedValue({ + version: 1, + skills: { + [sourceRef]: { + version: previousCommit, + installedAt: 123, + sourceRef, + sourceKind: "skills-sh", + sourceRepository: "patrick-erichsen/skills", + sourcePath: "skills/html", + sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${previousCommit}/skills/html`, + clawhubScan: "unscanned", + trustLabel: "Not scanned by ClawHub", + }, + }, + }); + vi.mocked(readSkillOrigin).mockResolvedValue({ + version: 1, + registry: "https://clawhub.ai", + slug: "html", + sourceRef, + sourceKind: "skills-sh", + sourceRepository: "patrick-erichsen/skills", + sourcePath: "skills/html", + sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${previousCommit}/skills/html`, + clawhubScan: "unscanned", + trustLabel: "Not scanned by ClawHub", + installedVersion: previousCommit, + installedAt: 123, + fingerprint: "hash", + }); + vi.mocked(stat).mockResolvedValue({} as unknown as Awaited>); + vi.mocked(listSkillFiles).mockResolvedValue([ + { relPath: "SKILL.md", bytes: new Uint8Array([1]) }, + ]); + hashSkillFilesMock.mockReturnValue({ fingerprint: "hash", files: installedFiles }); + + await cmdUpdate(makeOpts(), sourceRef, {}, false); + + expect(mockFetchBinary).toHaveBeenCalledWith("https://clawhub.ai", { + url: `https://codeload.github.com/patrick-erichsen/skills/zip/${nextCommit}`, + }); + expect(writeSkillOrigin).toHaveBeenCalledWith("/work/skills/html", { + version: 1, + registry: "https://clawhub.ai", + slug: "html", + sourceRef, + sourceKind: "skills-sh", + sourceRepository: "patrick-erichsen/skills", + sourcePath: "skills/html", + sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${nextCommit}/skills/html`, + canonicalRef: "@openclaw/html", + clawhubScan: "scanned", + trustLabel: "Scanned by ClawHub", + artifactIdentity: githubArtifactIdentity( + "patrick-erichsen/skills", + "skills/html", + nextCommit, + contentHash, + ), + installedVersion: nextCommit, + installedAt: 123, + fingerprint: "hash", + }); + expect(writeLockfile).toHaveBeenCalledWith("/work", { + version: 1, + skills: { + [sourceRef]: { + version: nextCommit, + installedAt: 123, + sourceRef, + sourceKind: "skills-sh", + sourceRepository: "patrick-erichsen/skills", + sourcePath: "skills/html", + sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${nextCommit}/skills/html`, + canonicalRef: "@openclaw/html", + clawhubScan: "scanned", + trustLabel: "Scanned by ClawHub", + }, + }, + }); + expect(mockApiRequest).toHaveBeenNthCalledWith( + 2, + "https://clawhub.ai", + expect.objectContaining({ + path: LegacyApiRoutes.cliTelemetryInstall, + body: { + event: "install", + slug: "html", + sourceRef, + sourceKind: "skills-sh", + sourceRepository: "patrick-erichsen/skills", + sourcePath: "skills/html", + sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${nextCommit}/skills/html`, + canonicalRef: "@openclaw/html", + clawhubScan: "scanned", + trustLabel: "Scanned by ClawHub", + version: nextCommit, + }, + }), + expect.anything(), + ); + }); + + it("reinstalls before promoting same-version bytes to a scanned alias", async () => { + const sourceRef = "skills-sh:patrick-erichsen/skills/html"; + const commit = "a".repeat(40); + const installedFiles = [{ path: "SKILL.md", sha256: "c".repeat(64), size: 1 }]; + const contentHash = skillStore.buildGitHubFolderContentHash(installedFiles); + mockGetOptionalAuthToken.mockResolvedValue("tkn"); + mockApiRequest.mockResolvedValueOnce({ + ok: true, + slug: "html", + installKind: "github", + github: { + repo: "openclaw/skills", + path: "skills/html", + commit, + contentHash, + sourceUrl: `https://github.com/openclaw/skills/tree/${commit}/skills/html`, + }, + provenance: { + source: "skills.sh", + reference: sourceRef, + repository: "openclaw/skills", + path: "skills/html", + sourceUrl: `https://github.com/openclaw/skills/tree/${commit}/skills/html`, + }, + trust: { + clawhubScan: "scanned", + label: "Scanned by ClawHub", + }, + canonicalRef: "@openclaw/html", + }); + mockFetchBinary.mockResolvedValue(new Uint8Array([1, 2, 3])); + vi.mocked(readLockfile).mockResolvedValue({ + version: 1, + skills: { + [sourceRef]: { + version: commit, + installedAt: 123, + sourceRef, + sourceKind: "skills-sh", + sourceRepository: "patrick-erichsen/skills", + sourcePath: "skills/html", + clawhubScan: "unscanned", + trustLabel: "Not scanned by ClawHub", + }, + }, + }); + vi.mocked(readSkillOrigin).mockResolvedValue({ + version: 1, + registry: "https://clawhub.ai", + slug: "html", + sourceRef, + sourceKind: "skills-sh", + sourceRepository: "patrick-erichsen/skills", + sourcePath: "skills/html", + clawhubScan: "unscanned", + trustLabel: "Not scanned by ClawHub", + artifactIdentity: githubArtifactIdentity( + "patrick-erichsen/skills", + "skills/html", + commit, + contentHash, + ), + installedVersion: commit, + installedAt: 123, + fingerprint: "hash", + }); + vi.mocked(stat).mockResolvedValue({} as unknown as Awaited>); + vi.mocked(listSkillFiles).mockResolvedValue([ + { relPath: "SKILL.md", bytes: new Uint8Array([1]) }, + ]); + hashSkillFilesMock.mockReturnValue({ fingerprint: "hash", files: installedFiles }); + + await cmdUpdate(makeOpts(), sourceRef, {}, false); + + expect(mockFetchBinary).toHaveBeenCalledWith("https://clawhub.ai", { + url: `https://codeload.github.com/openclaw/skills/zip/${commit}`, + }); + expect(extractGitHubZipPathToDir).toHaveBeenCalledWith( + new Uint8Array([1, 2, 3]), + expect.stringContaining("/.html.tmp-"), + "skills/html", + ); + expect(writeSkillOrigin).toHaveBeenCalledWith( + "/work/skills/html", + expect.objectContaining({ + sourceRepository: "openclaw/skills", + canonicalRef: "@openclaw/html", + clawhubScan: "scanned", + trustLabel: "Scanned by ClawHub", + }), + ); }); it("fails when directly updating a pinned skill", async () => { @@ -1413,11 +1702,9 @@ describe("pin commands", () => { }); expect(mockLog).toHaveBeenCalledWith("Unpinned demo"); }); -}); -describe("cmdList", () => { - it("does not report a tracked skills.sh install as a manual skill", async () => { - const sourceRef = "skills-sh/patrick-erichsen/skills/html"; + it("unpin preserves skills.sh provenance and trust state", async () => { + const sourceRef = "skills-sh:patrick-erichsen/skills/html"; vi.mocked(readLockfile).mockResolvedValue({ version: 1, skills: { @@ -1425,13 +1712,59 @@ describe("cmdList", () => { version: "a".repeat(40), installedAt: 123, sourceRef, + sourceKind: "skills-sh", + sourceRepository: "patrick-erichsen/skills", + sourcePath: "skills/html", + sourceUrl: "https://github.com/patrick-erichsen/skills/tree/abc/skills/html", + clawhubScan: "unscanned", + trustLabel: "Not scanned by ClawHub", + pinned: true, + pinReason: "hold", + }, + }, + }); + + await cmdUnpin(makeOpts(), sourceRef); + + expect(writeLockfile).toHaveBeenCalledWith("/work", { + version: 1, + skills: { + [sourceRef]: { + version: "a".repeat(40), + installedAt: 123, + sourceRef, + sourceKind: "skills-sh", + sourceRepository: "patrick-erichsen/skills", + sourcePath: "skills/html", + sourceUrl: "https://github.com/patrick-erichsen/skills/tree/abc/skills/html", + clawhubScan: "unscanned", + trustLabel: "Not scanned by ClawHub", + }, + }, + }); + }); +}); + +describe("cmdList", () => { + it("does not report a tracked skills.sh install as a manual skill", async () => { + const sourceRef = "skills-sh:patrick-erichsen/skills/html"; + vi.mocked(readLockfile).mockResolvedValue({ + version: 1, + skills: { + [sourceRef]: { + version: "a".repeat(40), + installedAt: 123, + sourceRef, + sourceKind: "skills-sh", + clawhubScan: "unscanned", + trustLabel: "Not scanned by ClawHub", }, }, }); await cmdList(makeOpts()); expect(skillStore.listManualSkills).toHaveBeenCalledWith("/work/skills", new Set(["html"])); - expect(mockLog).toHaveBeenCalledWith(`${sourceRef} ${"a".repeat(40)}`); + expect(mockLog).toHaveBeenCalledWith(`${sourceRef} ${"a".repeat(40)} Not scanned by ClawHub`); }); it("shows pinned state in list output", async () => { @@ -1451,8 +1784,8 @@ describe("cmdList", () => { }); describe("cmdInstall", () => { - it("installs a skills.sh catalog ref from the approved pinned GitHub resolver", async () => { - const sourceRef = "skills-sh/patrick-erichsen/skills/html"; + it("installs a skills.sh catalog ref from the exact synchronized GitHub resolver", async () => { + const sourceRef = "skills-sh:patrick-erichsen/skills/html"; const commit = "a".repeat(40); const installedFiles = [{ path: "SKILL.md", sha256: "b".repeat(64), size: 1 }]; const contentHash = skillStore.buildGitHubFolderContentHash(installedFiles); @@ -1469,6 +1802,15 @@ describe("cmdInstall", () => { contentHash, sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${commit}/skills/html`, }, + provenance: { + source: "skills.sh", + reference: sourceRef, + }, + trust: { + clawhubScan: "unscanned", + label: "Not scanned by ClawHub", + }, + canonicalRef: null, }) .mockResolvedValueOnce({ ok: true }); mockFetchBinary.mockResolvedValue(new Uint8Array([1, 2, 3])); @@ -1503,6 +1845,18 @@ describe("cmdInstall", () => { registry: "https://clawhub.ai", slug: "html", sourceRef, + sourceKind: "skills-sh", + sourceRepository: "patrick-erichsen/skills", + sourcePath: "skills/html", + sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${commit}/skills/html`, + clawhubScan: "unscanned", + trustLabel: "Not scanned by ClawHub", + artifactIdentity: githubArtifactIdentity( + "patrick-erichsen/skills", + "skills/html", + commit, + contentHash, + ), installedVersion: commit, installedAt: expect.any(Number), fingerprint: "hash", @@ -1514,6 +1868,12 @@ describe("cmdInstall", () => { version: commit, installedAt: expect.any(Number), sourceRef, + sourceKind: "skills-sh", + sourceRepository: "patrick-erichsen/skills", + sourcePath: "skills/html", + sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${commit}/skills/html`, + clawhubScan: "unscanned", + trustLabel: "Not scanned by ClawHub", }, }, }); @@ -1526,6 +1886,215 @@ describe("cmdInstall", () => { event: "install", slug: "html", sourceRef, + sourceKind: "skills-sh", + sourceRepository: "patrick-erichsen/skills", + sourcePath: "skills/html", + sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${commit}/skills/html`, + clawhubScan: "unscanned", + trustLabel: "Not scanned by ClawHub", + version: commit, + }, + }), + expect.anything(), + ); + expect(mockSpinner.succeed).toHaveBeenCalledWith( + expect.stringContaining("(Not scanned by ClawHub)"), + ); + }); + + it("resolves a scanned Hosted Mode alias through its canonical native archive", async () => { + const sourceRef = "skills-sh:patrick-erichsen/skills/html"; + mockGetOptionalAuthToken.mockResolvedValue("tkn"); + mockApiRequest + .mockResolvedValueOnce({ + ok: true, + slug: "html", + installKind: "archive", + archive: { + version: "2.0.0", + downloadUrl: "https://clawhub.ai/api/v1/download?slug=html&version=2.0.0", + }, + provenance: { + source: "skills.sh", + reference: sourceRef, + repository: "patrick-erichsen/skills", + path: "skills/html", + sourceUrl: "https://skills.sh/patrick-erichsen/skills/html", + }, + trust: { + clawhubScan: "scanned", + label: "Scanned by ClawHub", + }, + canonicalRef: "@openclaw/html", + }) + .mockResolvedValueOnce({ ok: true }); + mockDownloadZip.mockResolvedValue(new Uint8Array([1, 2, 3])); + + await cmdInstall(makeOpts(), sourceRef); + + expect(mockDownloadZip).toHaveBeenCalledWith("https://clawhub.ai", { + slug: "html", + ownerHandle: "openclaw", + version: "2.0.0", + token: "tkn", + }); + expect(writeSkillOrigin).toHaveBeenCalledWith("/work/skills/html", { + version: 1, + registry: "https://clawhub.ai", + slug: "html", + sourceRef, + sourceKind: "skills-sh", + sourceRepository: "patrick-erichsen/skills", + sourcePath: "skills/html", + sourceUrl: "https://skills.sh/patrick-erichsen/skills/html", + canonicalRef: "@openclaw/html", + clawhubScan: "scanned", + trustLabel: "Scanned by ClawHub", + artifactIdentity: archiveArtifactIdentity("@openclaw/html", "2.0.0"), + installedVersion: "2.0.0", + installedAt: expect.any(Number), + fingerprint: undefined, + }); + expect(writeLockfile).toHaveBeenCalledWith("/work", { + version: 1, + skills: { + [sourceRef]: { + version: "2.0.0", + installedAt: expect.any(Number), + sourceRef, + sourceKind: "skills-sh", + sourceRepository: "patrick-erichsen/skills", + sourcePath: "skills/html", + sourceUrl: "https://skills.sh/patrick-erichsen/skills/html", + canonicalRef: "@openclaw/html", + clawhubScan: "scanned", + trustLabel: "Scanned by ClawHub", + }, + }, + }); + }); + + it("rejects a skills.sh resolver response that omits external trust metadata", async () => { + const sourceRef = "skills-sh:patrick-erichsen/skills/html"; + mockApiRequest.mockResolvedValueOnce({ + ok: true, + slug: sourceRef, + installKind: "github", + github: { + repo: "patrick-erichsen/skills", + path: "skills/html", + commit: "a".repeat(40), + contentHash: "b".repeat(64), + sourceUrl: "https://github.com/patrick-erichsen/skills/tree/main/skills/html", + }, + }); + + await expect(cmdInstall(makeOpts(), sourceRef)).rejects.toThrow( + "skills.sh catalog resolver did not preserve the requested external provenance", + ); + expect(mockFetchBinary).not.toHaveBeenCalled(); + expect(writeSkillOrigin).not.toHaveBeenCalled(); + expect(writeLockfile).not.toHaveBeenCalled(); + }); + + it("installs a scanned Repo Sync alias from its canonical pinned GitHub descriptor", async () => { + const sourceRef = "skills-sh:patrick-erichsen/skills/html"; + const commit = "a".repeat(40); + const installedFiles = [{ path: "SKILL.md", sha256: "b".repeat(64), size: 1 }]; + const contentHash = skillStore.buildGitHubFolderContentHash(installedFiles); + mockGetOptionalAuthToken.mockResolvedValue("tkn"); + mockApiRequest + .mockResolvedValueOnce({ + ok: true, + slug: "html", + installKind: "github", + github: { + repo: "patrick-erichsen/skills", + path: "skills/html", + commit, + contentHash, + sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${commit}/skills/html`, + }, + provenance: { + source: "skills.sh", + reference: sourceRef, + repository: "patrick-erichsen/skills", + path: "skills/html", + sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${commit}/skills/html`, + }, + trust: { + clawhubScan: "scanned", + label: "Scanned by ClawHub", + }, + canonicalRef: "@openclaw/html", + }) + .mockResolvedValueOnce({ ok: true }); + mockFetchBinary.mockResolvedValue(new Uint8Array([1, 2, 3])); + vi.mocked(listSkillFiles).mockResolvedValue([ + { relPath: "SKILL.md", bytes: new Uint8Array([1]) }, + ]); + hashSkillFilesMock.mockReturnValue({ fingerprint: "hash", files: installedFiles }); + + await cmdInstall(makeOpts(), sourceRef); + + expect(mockFetchBinary).toHaveBeenCalledWith("https://clawhub.ai", { + url: `https://codeload.github.com/patrick-erichsen/skills/zip/${commit}`, + }); + expect(writeSkillOrigin).toHaveBeenCalledWith("/work/skills/html", { + version: 1, + registry: "https://clawhub.ai", + slug: "html", + sourceRef, + sourceKind: "skills-sh", + sourceRepository: "patrick-erichsen/skills", + sourcePath: "skills/html", + sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${commit}/skills/html`, + canonicalRef: "@openclaw/html", + clawhubScan: "scanned", + trustLabel: "Scanned by ClawHub", + artifactIdentity: githubArtifactIdentity( + "patrick-erichsen/skills", + "skills/html", + commit, + contentHash, + ), + installedVersion: commit, + installedAt: expect.any(Number), + fingerprint: "hash", + }); + expect(writeLockfile).toHaveBeenCalledWith("/work", { + version: 1, + skills: { + [sourceRef]: { + version: commit, + installedAt: expect.any(Number), + sourceRef, + sourceKind: "skills-sh", + sourceRepository: "patrick-erichsen/skills", + sourcePath: "skills/html", + sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${commit}/skills/html`, + canonicalRef: "@openclaw/html", + clawhubScan: "scanned", + trustLabel: "Scanned by ClawHub", + }, + }, + }); + expect(mockApiRequest).toHaveBeenNthCalledWith( + 2, + "https://clawhub.ai", + expect.objectContaining({ + path: LegacyApiRoutes.cliTelemetryInstall, + body: { + event: "install", + slug: "html", + sourceRef, + sourceKind: "skills-sh", + sourceRepository: "patrick-erichsen/skills", + sourcePath: "skills/html", + sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${commit}/skills/html`, + canonicalRef: "@openclaw/html", + clawhubScan: "scanned", + trustLabel: "Scanned by ClawHub", version: commit, }, }), @@ -1533,33 +2102,53 @@ describe("cmdInstall", () => { ); }); - it("rejects the unsupported colon-form skills.sh reference", async () => { - await expect(cmdInstall(makeOpts(), "skills-sh:patrick-erichsen/skills/html")).rejects.toThrow( - "Invalid skills.sh ref: use skills-sh/owner/repo/slug", + it("rejects the legacy slash-form skills.sh reference before auth or network access", async () => { + await expect(cmdInstall(makeOpts(), "skills-sh/patrick-erichsen/skills/html")).rejects.toThrow( + "Invalid skills.sh ref: use skills-sh:owner/repo/slug", ); + expect(mockGetOptionalAuthToken).not.toHaveBeenCalled(); + expect(registryMocks.getRegistry).not.toHaveBeenCalled(); + expect(mockApiRequest).not.toHaveBeenCalled(); + }); + + it.each([ + "skills-sh:", + "skills-sh:owner/repo", + "skills-sh:owner/repo/slug/extra", + "skills-sh:owner/../slug", + "skills-sh:./repo/slug", + "skills-sh:owner/./slug", + "skills-sh:owner/repo/.", + "skills-sh:owner/repo/slug?ref=main", + ])("rejects invalid skills.sh reference %s before network access", async (sourceRef) => { + await expect(cmdInstall(makeOpts(), sourceRef)).rejects.toThrow( + "Invalid skills.sh ref: use skills-sh:owner/repo/slug", + ); + expect(mockGetOptionalAuthToken).not.toHaveBeenCalled(); + expect(registryMocks.getRegistry).not.toHaveBeenCalled(); expect(mockApiRequest).not.toHaveBeenCalled(); }); it("blocks a skills.sh install when the slug target belongs to another source", async () => { - const sourceRef = "skills-sh/patrick-erichsen/skills/html"; + const sourceRef = "skills-sh:patrick-erichsen/skills/html"; statMock.mockResolvedValue({} as Awaited>); readSkillOriginMock.mockResolvedValue({ version: 1, registry: "https://clawhub.ai", slug: "html", - sourceRef: "skills-sh/other/repo/html", + sourceRef: "skills-sh:other/repo/html", installedVersion: "a".repeat(40), installedAt: 1, }); await expect(cmdInstall(makeOpts(), sourceRef, undefined, true)).rejects.toThrow( - `Install target collision: /work/skills/html is owned by skills-sh/other/repo/html`, + `Install target collision: /work/skills/html is owned by skills-sh:other/repo/html`, ); expect(mockApiRequest).not.toHaveBeenCalled(); }); it("rejects a skills.sh install whose extracted folder hash differs from the resolver", async () => { - const sourceRef = "skills-sh/patrick-erichsen/skills/html"; + const sourceRef = "skills-sh:patrick-erichsen/skills/html"; const commit = "a".repeat(40); mockApiRequest.mockResolvedValueOnce({ ok: true, @@ -1572,6 +2161,56 @@ describe("cmdInstall", () => { contentHash: "b".repeat(64), sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${commit}/skills/html`, }, + provenance: { + source: "skills.sh", + reference: sourceRef, + }, + trust: { + clawhubScan: "unscanned", + label: "Not scanned by ClawHub", + }, + canonicalRef: null, + }); + mockFetchBinary.mockResolvedValue(new Uint8Array([1, 2, 3])); + listTextFilesMock.mockResolvedValue([{ relPath: "SKILL.md", bytes: new Uint8Array([1]) }]); + hashSkillFilesMock.mockReturnValue({ + fingerprint: "local-fingerprint", + files: [{ path: "SKILL.md", sha256: "c".repeat(64), size: 1 }], + }); + + await expect(cmdInstall(makeOpts(), sourceRef)).rejects.toThrow( + "Downloaded skills.sh folder hash does not match the approved ClawHub resolver", + ); + expect(writeSkillOrigin).not.toHaveBeenCalled(); + expect(writeLockfile).not.toHaveBeenCalled(); + }); + + it("rejects a scanned Repo Sync alias whose pinned GitHub bytes differ from the resolver", async () => { + const sourceRef = "skills-sh:patrick-erichsen/skills/html"; + const commit = "a".repeat(40); + mockApiRequest.mockResolvedValueOnce({ + ok: true, + slug: "html", + installKind: "github", + github: { + repo: "patrick-erichsen/skills", + path: "skills/html", + commit, + contentHash: "b".repeat(64), + sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${commit}/skills/html`, + }, + provenance: { + source: "skills.sh", + reference: sourceRef, + repository: "patrick-erichsen/skills", + path: "skills/html", + sourceUrl: `https://github.com/patrick-erichsen/skills/tree/${commit}/skills/html`, + }, + trust: { + clawhubScan: "scanned", + label: "Scanned by ClawHub", + }, + canonicalRef: "@openclaw/html", }); mockFetchBinary.mockResolvedValue(new Uint8Array([1, 2, 3])); listTextFilesMock.mockResolvedValue([{ relPath: "SKILL.md", bytes: new Uint8Array([1]) }]); diff --git a/packages/clawhub/src/cli/commands/skills.ts b/packages/clawhub/src/cli/commands/skills.ts index 48bc4b2b..38594468 100644 --- a/packages/clawhub/src/cli/commands/skills.ts +++ b/packages/clawhub/src/cli/commands/skills.ts @@ -14,6 +14,7 @@ import { ApiV1SkillResponseSchema, ApiV1SkillVersionResponseSchema, type ApiV1SkillInstallResolveResponse, + type Lockfile, type SkillReportFinalAction, type SkillReportListStatus, type SkillReportStatus, @@ -32,6 +33,13 @@ import { } from "../../skills.js"; import { getOptionalAuthToken, requireAuthToken } from "../authToken.js"; import { getRegistry } from "../registry.js"; +import { + parseSkillsShCliReference, + parseStoredSkillsShReference, + SKILLS_SH_SCANNED_LABEL, + SKILLS_SH_UNSCANNED_LABEL, + type SkillsShReference, +} from "../skillReference.js"; import type { GlobalOpts, ResolveResult } from "../types.js"; import { createCrabLoader, @@ -70,10 +78,7 @@ type SkillRef = { slug: string; ownerHandle?: string; sourceRef?: string; - skillsSh?: { - owner: string; - repo: string; - }; + skillsSh?: SkillsShReference; }; function normalizeOwnerHandle(raw: string | null | undefined) { @@ -89,6 +94,18 @@ type GitHubInstallResolution = Extract< ApiV1SkillInstallResolveResponse, { ok: true; installKind: "github" } >; +type SuccessfulInstallResolution = Extract; + +type SkillsShState = { + sourceRef: string; + sourceKind: "skills-sh"; + sourceRepository?: string; + sourcePath?: string; + sourceUrl?: string; + canonicalRef?: string; + clawhubScan: "unscanned" | "scanned"; + trustLabel: string; +}; function normalizeSkillSlugOrFail(raw: string) { const slug = raw.trim(); @@ -110,21 +127,8 @@ function normalizeSkillSlugForRemote(raw: unknown) { function parseSkillRefOrFail(raw: string): SkillRef { const ref = raw.trim(); if (!ref) fail("Slug required"); - if (ref.toLowerCase().startsWith("skills-sh:")) { - fail("Invalid skills.sh ref: use skills-sh/owner/repo/slug"); - } - if (ref.toLowerCase().startsWith("skills-sh/")) { - const segments = ref.split("/"); - if (segments.length !== 4 || segments[0]?.toLowerCase() !== "skills-sh") { - fail("Invalid skills.sh ref: use skills-sh/owner/repo/slug"); - } - const [, rawOwner, rawRepo, rawSlug] = segments; - const owner = normalizeSkillsShSegment(rawOwner, ref); - const repo = normalizeSkillsShSegment(rawRepo, ref); - const slug = normalizeSkillsShSegment(rawSlug, ref); - const sourceRef = `skills-sh/${owner}/${repo}/${slug}`; - return { slug, sourceRef, skillsSh: { owner, repo } }; - } + const skillsSh = parseSkillsShCliReference(ref); + if (skillsSh) return { slug: skillsSh.slug, sourceRef: skillsSh.sourceRef, skillsSh }; const slashIndex = ref.indexOf("/"); if (slashIndex < 0) { return { slug: normalizeSkillSlugOrFail(ref) }; @@ -142,18 +146,10 @@ function parseSkillRefOrFail(raw: string): SkillRef { return { slug, ownerHandle }; } -function normalizeSkillsShSegment(raw: string | undefined, ref: string) { - const segment = raw?.trim().toLowerCase() ?? ""; - if ( - !segment || - segment.includes("\\") || - segment.includes(":") || - segment.includes("..") || - !isSafeSkillSlug(segment) - ) { - fail(`Invalid skills.sh ref: ${ref}`); - } - return segment; +function parseStoredSkillRefOrFail(raw: string): SkillRef { + const skillsSh = parseStoredSkillsShReference(raw); + if (skillsSh) return { slug: skillsSh.slug, sourceRef: skillsSh.sourceRef, skillsSh }; + return parseSkillRefOrFail(raw); } function isSafeSkillSlug(slug: string) { @@ -171,13 +167,10 @@ function skillTarget(dir: string, ref: SkillRef) { } function isSafeSkillIdentity(value: string) { - if (value.toLowerCase().startsWith("skills-sh/")) { - const segments = value.split("/"); - return ( - segments.length === 4 && - segments[0]?.toLowerCase() === "skills-sh" && - segments.slice(1).every((segment) => isSafeSkillSlug(segment) && !segment.includes(":")) - ); + try { + if (parseStoredSkillsShReference(value)) return true; + } catch { + return false; } const slashIndex = value.indexOf("/"); if (slashIndex < 0) return isSafeSkillSlug(value); @@ -194,8 +187,16 @@ function findExistingLockKey( const key = skillIdentity(ref); if (lock.skills[key]) return key; if (ref.sourceRef) { - const legacyEntry = lock.skills[ref.slug]; - if (legacyEntry?.sourceRef === ref.sourceRef) return ref.slug; + for (const [candidateKey, entry] of Object.entries(lock.skills)) { + const candidate = entry.sourceRef ?? candidateKey; + try { + if (parseStoredSkillsShReference(candidate)?.sourceRef === ref.sourceRef) { + return candidateKey; + } + } catch { + continue; + } + } return key; } if (ref.ownerHandle) { @@ -207,6 +208,16 @@ function findExistingLockKey( return key; } +function replaceLockEntry( + lock: Lockfile, + previousKey: string, + canonicalKey: string, + entry: Lockfile["skills"][string], +) { + if (previousKey !== canonicalKey) delete lock.skills[previousKey]; + lock.skills[canonicalKey] = entry; +} + function ownerScopedUrl(registry: string, path: string, ownerHandle?: string) { if (!ownerHandle) return null; const url = registryUrl(path, registry); @@ -381,7 +392,10 @@ export async function cmdInstall( await mkdir(opts.dir, { recursive: true }); const lock = await readLockfile(opts.workdir); const lockKey = findExistingLockKey(lock, requested); - const localRef = lockKey === skillIdentity(requested) ? requested : parseSkillRefOrFail(lockKey); + const localRef = + requested.sourceRef || lockKey === skillIdentity(requested) + ? requested + : parseStoredSkillRefOrFail(lockKey); const target = skillTarget(opts.dir, localRef); const targetExists = await fileExists(target); const existingOrigin = requested.sourceRef && targetExists ? await readSkillOrigin(target) : null; @@ -410,12 +424,25 @@ export async function cmdInstall( const spinner = createCrabLoader(`Resolving ${trimmed}`); try { if (requested.sourceRef && requested.skillsSh) { - const resolvedInstall = await resolveSkillsShCatalogInstall(registry, requested, token); - spinner.text = `Downloading ${trimmed} ${formatGitHubVersion(resolvedInstall.github.commit)}`; + const { resolution: resolvedInstall, state } = await resolveSkillsShCatalogInstall( + registry, + requested, + token, + ); + const resolvedVersion = getInstallResolutionVersion(resolvedInstall); + const artifactIdentity = getInstallResolutionArtifactIdentity( + resolvedInstall, + state.canonicalRef, + ); + spinner.text = `Downloading ${trimmed} ${formatInstallResolutionVersion(resolvedInstall)}`; await installSkillWithOptionalStaging(target, targetExists, (installTarget) => - installGitHubSkill(registry, resolvedInstall, installTarget, { - expectedContentHash: resolvedInstall.github.contentHash, - }), + installSkillsShResolution( + registry, + resolvedInstall, + state.canonicalRef, + installTarget, + token, + ), ); const installedFiles = await listSkillFiles(target); const installedFingerprint = @@ -425,31 +452,32 @@ export async function cmdInstall( version: 1, registry, slug: trimmed, - sourceRef: requested.sourceRef, - installedVersion: resolvedInstall.github.commit, + ...state, + artifactIdentity, + installedVersion: resolvedVersion, installedAt, fingerprint: installedFingerprint, }); - lock.skills[lockKey] = { - ...withPinnedMetadata(resolvedInstall.github.commit, installedAt, existingEntry), - sourceRef: requested.sourceRef, - }; + replaceLockEntry(lock, lockKey, requested.sourceRef, { + ...withPinnedMetadata(resolvedVersion, installedAt, existingEntry), + ...state, + }); await writeLockfile(opts.workdir, lock); await reportInstalledSkillsTelemetryIfEnabled({ token, registry, slug: trimmed, - sourceRef: requested.sourceRef, - version: resolvedInstall.github.commit, + ...state, + version: resolvedVersion, }); spinner.succeed( `${styleText("Installed", "brand")} ${styleText( requested.sourceRef, "strong", - )} ${styleText(formatGitHubVersion(resolvedInstall.github.commit), "muted")} -> ${styleText( + )} ${styleText(formatInstallResolutionVersion(resolvedInstall), "muted")} -> ${styleText( target, "muted", - )}`, + )} (${state.trustLabel})`, ); return; } @@ -639,7 +667,7 @@ export async function cmdUpdate( for (const entry of slugs) { const entryLock = lock.skills[entry]; - const entryRef = parseSkillRefOrFail(entryLock?.sourceRef ?? entry); + const entryRef = parseStoredSkillRefOrFail(entryLock?.sourceRef ?? entry); const spinner = createCrabLoader(`Checking ${entry}`); try { const target = skillTarget(opts.dir, entryRef); @@ -658,21 +686,30 @@ export async function cmdUpdate( const filesOnDisk = exists ? await listSkillFiles(target) : []; const localFingerprint = filesOnDisk.length > 0 ? hashSkillFiles(filesOnDisk).fingerprint : null; - const latestInstall = await resolveSkillsShCatalogInstall(registry, entryRef, token); - const targetVersion = latestInstall.github.commit; - const originFingerprint = - existingOrigin?.sourceRef === entryRef.sourceRef ? existingOrigin.fingerprint : undefined; + const { resolution: latestInstall, state } = await resolveSkillsShCatalogInstall( + registry, + entryRef, + token, + ); + const targetVersion = getInstallResolutionVersion(latestInstall); + const targetArtifactIdentity = getInstallResolutionArtifactIdentity( + latestInstall, + state.canonicalRef, + ); + const originFingerprint = sameSkillsShSource(existingOrigin?.sourceRef, entryRef.sourceRef) + ? existingOrigin?.fingerprint + : undefined; const hasLocalChanges = Boolean( exists && localFingerprint && (!originFingerprint || originFingerprint !== localFingerprint), ); const matched = - existingOrigin?.sourceRef === entryRef.sourceRef && + sameSkillsShSource(existingOrigin?.sourceRef, entryRef.sourceRef) && originFingerprint && localFingerprint && originFingerprint === localFingerprint - ? existingOrigin.installedVersion + ? existingOrigin?.installedVersion : null; if (hasLocalChanges && !options.force) { @@ -682,42 +719,83 @@ export async function cmdUpdate( continue; } const confirm = await promptConfirm( - `${entry}: local changes (no match). Overwrite with ${formatGitHubVersion( - targetVersion, + `${entry}: local changes (no match). Overwrite with ${formatInstallResolutionVersion( + latestInstall, )}?`, ); if (!confirm) { console.log(`${entry}: skipped`); continue; } - spinner.start(`Updating ${entry} -> ${formatGitHubVersion(targetVersion)}`); + spinner.start(`Updating ${entry} -> ${formatInstallResolutionVersion(latestInstall)}`); } - if (matched === targetVersion && !options.force && !hasLocalChanges) { + if ( + matched === targetVersion && + existingOrigin?.artifactIdentity === targetArtifactIdentity && + !options.force && + !hasLocalChanges + ) { + const installedAt = existingOrigin?.installedAt ?? lock.skills[entry]?.installedAt; + if ( + exists && + installedAt && + (!existingOrigin || + !sameSkillsShSource(existingOrigin.sourceRef, state.sourceRef) || + existingOrigin.sourceRepository !== state.sourceRepository || + existingOrigin.sourcePath !== state.sourcePath || + existingOrigin.sourceUrl !== state.sourceUrl || + existingOrigin.canonicalRef !== state.canonicalRef || + existingOrigin.clawhubScan !== state.clawhubScan || + existingOrigin.trustLabel !== state.trustLabel) + ) { + await writeSkillOrigin(target, { + version: 1, + registry: existingOrigin?.registry ?? registry, + slug: entryRef.slug, + ...state, + artifactIdentity: targetArtifactIdentity, + installedVersion: targetVersion, + installedAt, + fingerprint: localFingerprint ?? existingOrigin?.fingerprint, + }); + } if ( lock.skills[entry]?.version !== targetVersion || - lock.skills[entry]?.sourceRef !== entryRef.sourceRef + lock.skills[entry]?.sourceRef !== entryRef.sourceRef || + lock.skills[entry]?.sourceRepository !== state.sourceRepository || + lock.skills[entry]?.sourcePath !== state.sourcePath || + lock.skills[entry]?.sourceUrl !== state.sourceUrl || + lock.skills[entry]?.canonicalRef !== state.canonicalRef || + lock.skills[entry]?.clawhubScan !== state.clawhubScan || + lock.skills[entry]?.trustLabel !== state.trustLabel ) { - lock.skills[entry] = { + replaceLockEntry(lock, entry, entryRef.sourceRef, { ...withPinnedMetadata( targetVersion, lock.skills[entry]?.installedAt ?? Date.now(), lock.skills[entry], ), - sourceRef: entryRef.sourceRef, - }; + ...state, + }); markLockDirty(); await flushLockfile(); } - spinner.succeed(`${entry}: up to date (${formatGitHubVersion(targetVersion)})`); + spinner.succeed( + `${entry}: up to date (${formatInstallResolutionVersion(latestInstall)})`, + ); continue; } - spinner.text = `Updating ${entry} -> ${formatGitHubVersion(targetVersion)}`; + spinner.text = `Updating ${entry} -> ${formatInstallResolutionVersion(latestInstall)}`; await installSkillWithOptionalStaging(target, exists, (installTarget) => - installGitHubSkill(registry, latestInstall, installTarget, { - expectedContentHash: latestInstall.github.contentHash, - }), + installSkillsShResolution( + registry, + latestInstall, + state.canonicalRef, + installTarget, + token, + ), ); const installedFiles = await listSkillFiles(target); const installedFingerprint = @@ -727,18 +805,28 @@ export async function cmdUpdate( version: 1, registry: existingOrigin?.registry ?? registry, slug: entryRef.slug, - sourceRef: entryRef.sourceRef, + ...state, + artifactIdentity: targetArtifactIdentity, installedVersion: targetVersion, installedAt, fingerprint: installedFingerprint, }); - lock.skills[entry] = { + replaceLockEntry(lock, entry, entryRef.sourceRef, { ...withPinnedMetadata(targetVersion, installedAt, lock.skills[entry]), - sourceRef: entryRef.sourceRef, - }; + ...state, + }); markLockDirty(); await flushLockfile(); - spinner.succeed(`${entry}: updated -> ${formatGitHubVersion(targetVersion)}`); + await reportInstalledSkillsTelemetryIfEnabled({ + token, + registry, + slug: entryRef.slug, + ...state, + version: targetVersion, + }); + spinner.succeed( + `${entry}: updated -> ${formatInstallResolutionVersion(latestInstall)} (${state.trustLabel})`, + ); continue; } @@ -1049,7 +1137,7 @@ export async function cmdList(opts: GlobalOpts) { const entries = Object.entries(lock.skills); const trackedTargets = new Set( Object.keys(lock.skills).map((entry) => { - const ref = parseSkillRefOrFail(entry); + const ref = parseStoredSkillRefOrFail(lock.skills[entry]?.sourceRef ?? entry); return ref.sourceRef ? ref.slug : entry; }), ); @@ -1059,8 +1147,11 @@ export async function cmdList(opts: GlobalOpts) { return; } for (const [slug, entry] of entries) { + const storedRef = parseStoredSkillsShReference(entry.sourceRef ?? slug); + const displaySlug = storedRef?.sourceRef ?? slug; const pinned = isPinnedSkillEntry(entry) ? ` pinned${formatPinnedDetails(entry)}` : ""; - console.log(`${slug} ${entry.version ?? "latest"}${pinned}`); + const trust = entry.clawhubScan === "unscanned" ? ` ${entry.trustLabel}` : ""; + console.log(`${displaySlug} ${entry.version ?? "latest"}${pinned}${trust}`); } if (manualSkills.length > 0) { if (entries.length > 0) console.log(); @@ -1103,11 +1194,8 @@ export async function cmdUnpin(opts: GlobalOpts, slug: string) { if (!existing) fail(`Not installed: ${label}`); if (!isPinnedSkillEntry(existing)) fail(`Skill "${label}" is not pinned`); - lock.skills[lockKey] = { - version: existing.version, - installedAt: existing.installedAt, - ...(existing.ownerHandle ? { ownerHandle: existing.ownerHandle } : {}), - }; + const { pinned: _pinned, pinReason: _pinReason, ...unpinned } = existing; + lock.skills[lockKey] = unpinned; await writeLockfile(opts.workdir, lock); console.log(`Unpinned ${label}`); } @@ -1136,7 +1224,8 @@ export async function cmdUninstall( } } - const localRef = lockKey === skillIdentity(requested) ? requested : parseSkillRefOrFail(lockKey); + const localRef = + lockKey === skillIdentity(requested) ? requested : parseStoredSkillRefOrFail(lockKey); const spinner = createCrabLoader(`Uninstalling ${skillIdentity(localRef)}`); try { const target = skillTarget(opts.dir, localRef); @@ -1458,7 +1547,7 @@ async function resolveLatestSkillInstall( async function resolveSkillsShCatalogInstall(registry: string, ref: SkillRef, token?: string) { if (!ref.sourceRef || !ref.skillsSh) { - fail("Invalid skills.sh ref: use skills-sh/owner/repo/slug"); + fail("Invalid skills.sh ref: use skills-sh:owner/repo/slug"); } const path = `${ApiRoutes.skillsSh}/${encodeURIComponent( ref.skillsSh.owner, @@ -1469,10 +1558,168 @@ async function resolveSkillsShCatalogInstall(registry: string, ref: SkillRef, to ApiV1SkillInstallResolveResponseSchema, ); if (!resolution.ok) fail(resolution.message); - if (resolution.installKind !== "github") { - fail("skills.sh catalog resolver did not return a GitHub install"); + const metadata = readSkillsShResolverMetadata(resolution, ref.sourceRef); + if (metadata.clawhubScan === "unscanned") { + if (resolution.installKind !== "github") { + fail("unscanned skills.sh catalog entries must resolve to an exact GitHub install"); + } + } else { + if (!metadata.canonicalRef) { + fail("adopted skills.sh aliases must return their canonical native reference"); + } + validateCanonicalSkillsShAliasRef(metadata.canonicalRef); } - return resolution; + return { + resolution, + state: { + sourceRef: ref.sourceRef, + sourceKind: "skills-sh", + ...(resolution.installKind === "github" + ? { + sourceRepository: resolution.github.repo, + sourcePath: resolution.github.path, + sourceUrl: resolution.github.sourceUrl, + } + : { + ...(metadata.sourceRepository ? { sourceRepository: metadata.sourceRepository } : {}), + ...(metadata.sourcePath ? { sourcePath: metadata.sourcePath } : {}), + ...(metadata.sourceUrl ? { sourceUrl: metadata.sourceUrl } : {}), + }), + ...(metadata.canonicalRef ? { canonicalRef: metadata.canonicalRef } : {}), + clawhubScan: metadata.clawhubScan, + trustLabel: metadata.trustLabel, + } satisfies SkillsShState, + }; +} + +function readSkillsShResolverMetadata( + resolution: ApiV1SkillInstallResolveResponse, + requestedRef: string, +): { + clawhubScan: "unscanned" | "scanned"; + trustLabel: string; + canonicalRef?: string; + sourceRepository?: string; + sourcePath?: string; + sourceUrl?: string; +} { + const record = asRecord(resolution); + const provenance = asRecord(record.provenance); + const trust = asRecord(record.trust); + if (provenance.source !== "skills.sh" || provenance.reference !== requestedRef) { + fail("skills.sh catalog resolver did not preserve the requested external provenance"); + } + const clawhubScan = trust.clawhubScan; + const trustLabel = typeof trust.label === "string" ? trust.label.trim() : ""; + if (clawhubScan !== "unscanned" && clawhubScan !== "scanned") { + fail("skills.sh catalog resolver did not return a ClawHub scan state"); + } + if (!trustLabel) { + fail("skills.sh catalog resolver did not return a trust label"); + } + if (clawhubScan === "unscanned" && trustLabel !== SKILLS_SH_UNSCANNED_LABEL) { + fail(`skills.sh catalog resolver must label unscanned sources "${SKILLS_SH_UNSCANNED_LABEL}"`); + } + if (clawhubScan === "scanned" && trustLabel !== SKILLS_SH_SCANNED_LABEL) { + fail(`skills.sh catalog resolver must label scanned sources "${SKILLS_SH_SCANNED_LABEL}"`); + } + const canonicalRef = + record.canonicalRef === null || record.canonicalRef === undefined + ? undefined + : typeof record.canonicalRef === "string" + ? record.canonicalRef.trim() + : fail("skills.sh catalog resolver returned an invalid canonical reference"); + const sourceRepository = + typeof provenance.repository === "string" ? provenance.repository.trim() : undefined; + const sourcePath = typeof provenance.path === "string" ? provenance.path.trim() : undefined; + const sourceUrl = + typeof provenance.sourceUrl === "string" ? provenance.sourceUrl.trim() : undefined; + return { + clawhubScan, + trustLabel, + canonicalRef, + sourceRepository, + sourcePath, + sourceUrl, + }; +} + +function asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function sameSkillsShSource(left: string | undefined, right: string | undefined) { + if (!left || !right) return false; + try { + return parseStoredSkillsShReference(left)?.sourceRef === right; + } catch { + return false; + } +} + +function validateCanonicalSkillsShAliasRef(canonicalRef: string) { + const canonical = parseSkillRefOrFail(canonicalRef); + if (!canonical.ownerHandle || canonical.sourceRef) { + fail("adopted skills.sh alias returned an invalid canonical native reference"); + } + return canonical; +} + +function getInstallResolutionVersion(resolution: SuccessfulInstallResolution) { + return resolution.installKind === "github" + ? resolution.github.commit + : resolution.archive.version; +} + +function getInstallResolutionArtifactIdentity( + resolution: SuccessfulInstallResolution, + canonicalRef: string | undefined, +) { + return resolution.installKind === "github" + ? JSON.stringify({ + installKind: resolution.installKind, + repo: resolution.github.repo, + path: resolution.github.path, + commit: resolution.github.commit, + contentHash: resolution.github.contentHash, + }) + : JSON.stringify({ + installKind: resolution.installKind, + canonicalRef, + version: resolution.archive.version, + }); +} + +function formatInstallResolutionVersion(resolution: SuccessfulInstallResolution) { + return resolution.installKind === "github" + ? formatGitHubVersion(resolution.github.commit) + : `v${resolution.archive.version}`; +} + +async function installSkillsShResolution( + registry: string, + resolution: SuccessfulInstallResolution, + canonicalRef: string | undefined, + target: string, + token: string | undefined, +) { + if (resolution.installKind === "github") { + await installGitHubSkill(registry, resolution, target, { + expectedContentHash: resolution.github.contentHash, + }); + return; + } + if (!canonicalRef) fail("adopted skills.sh alias did not return a canonical native reference"); + const canonical = validateCanonicalSkillsShAliasRef(canonicalRef); + const zip = await downloadZip(registry, { + slug: canonical.slug, + ownerHandle: canonical.ownerHandle, + version: resolution.archive.version, + token, + }); + await extractZipToDir(zip, target); } async function installGitHubSkill( @@ -1518,11 +1765,14 @@ function assertSkillsShTargetOwnership(args: { const lockedSource = args.lock.skills[args.lockKey]?.sourceRef; if ( args.existingOrigin?.sourceRef && - args.existingOrigin.sourceRef !== args.requested.sourceRef + !sameSkillsShSource(args.existingOrigin.sourceRef, args.requested.sourceRef) ) { fail(`Install target collision: ${args.target} is owned by ${args.existingOrigin.sourceRef}`); } - if (!args.existingOrigin?.sourceRef && lockedSource !== args.requested.sourceRef) { + if ( + !args.existingOrigin?.sourceRef && + !sameSkillsShSource(lockedSource, args.requested.sourceRef) + ) { const owner = args.existingOrigin?.ownerHandle ? `@${args.existingOrigin.ownerHandle}/${args.existingOrigin.slug}` : (args.existingOrigin?.slug ?? "another local skill"); diff --git a/packages/clawhub/src/cli/skillReference.ts b/packages/clawhub/src/cli/skillReference.ts new file mode 100644 index 00000000..7294fe9c --- /dev/null +++ b/packages/clawhub/src/cli/skillReference.ts @@ -0,0 +1,69 @@ +const SKILLS_SH_PREFIX = "skills-sh:"; +const SKILLS_SH_LEGACY_PREFIX = "skills-sh/"; +export const SKILLS_SH_UNSCANNED_LABEL = "Not scanned by ClawHub"; +export const SKILLS_SH_SCANNED_LABEL = "Scanned by ClawHub"; + +export type SkillsShReference = { + owner: string; + repo: string; + slug: string; + sourceRef: string; +}; + +export function parseSkillsShCliReference(raw: string): SkillsShReference | null { + return parseSkillsShReference(raw, false); +} + +export function parseStoredSkillsShReference(raw: string): SkillsShReference | null { + return parseSkillsShReference(raw, true); +} + +function parseSkillsShReference(raw: string, allowLegacySlash: boolean) { + const value = raw.trim(); + const lower = value.toLowerCase(); + let payload: string; + if (lower.startsWith(SKILLS_SH_PREFIX)) { + payload = value.slice(SKILLS_SH_PREFIX.length); + } else if (lower.startsWith(SKILLS_SH_LEGACY_PREFIX)) { + if (!allowLegacySlash) { + throw new Error(`Invalid skills.sh ref: use ${SKILLS_SH_PREFIX}owner/repo/slug`); + } + payload = value.slice(SKILLS_SH_LEGACY_PREFIX.length); + } else { + return null; + } + + const segments = payload.split("/"); + if (segments.length !== 3) { + throw new Error(`Invalid skills.sh ref: use ${SKILLS_SH_PREFIX}owner/repo/slug`); + } + const [rawOwner, rawRepo, rawSlug] = segments; + const owner = normalizeSegment(rawOwner); + const repo = normalizeSegment(rawRepo); + const slug = normalizeSegment(rawSlug); + if (!owner || !repo || !slug) { + throw new Error(`Invalid skills.sh ref: use ${SKILLS_SH_PREFIX}owner/repo/slug`); + } + return { + owner, + repo, + slug, + sourceRef: `${SKILLS_SH_PREFIX}${owner}/${repo}/${slug}`, + }; +} + +function normalizeSegment(raw: string | undefined) { + const segment = raw?.trim().toLowerCase() ?? ""; + if ( + !segment || + !/^[a-z0-9._-]+$/.test(segment) || + segment === "." || + segment.includes("/") || + segment.includes("\\") || + segment.includes(":") || + segment.includes("..") + ) { + return null; + } + return segment; +} diff --git a/packages/clawhub/src/schema/schemas.ts b/packages/clawhub/src/schema/schemas.ts index 9f9a9fc3..4a6e7822 100644 --- a/packages/clawhub/src/schema/schemas.ts +++ b/packages/clawhub/src/schema/schemas.ts @@ -25,6 +25,13 @@ export const LockfileSchema = type({ installedAt: "number", ownerHandle: "string?", sourceRef: "string?", + sourceKind: '"skills-sh"?', + sourceRepository: "string?", + sourcePath: "string?", + sourceUrl: "string?", + canonicalRef: "string?", + clawhubScan: '"unscanned"|"scanned"?', + trustLabel: "string?", pinned: "boolean?", pinReason: "string?", }, diff --git a/packages/clawhub/src/skills.test.ts b/packages/clawhub/src/skills.test.ts index e60e407c..ba8e9303 100644 --- a/packages/clawhub/src/skills.test.ts +++ b/packages/clawhub/src/skills.test.ts @@ -81,6 +81,13 @@ describe("skills", () => { installedAt: 1, pinned: true, pinReason: "awaiting moderation review", + sourceRef: "skills-sh:openclaw/skills/demo", + sourceKind: "skills-sh", + sourceRepository: "openclaw/skills", + sourcePath: "skills/demo", + sourceUrl: "https://github.com/openclaw/skills/tree/abc/skills/demo", + clawhubScan: "unscanned", + trustLabel: "Not scanned by ClawHub", }, }, }); @@ -88,6 +95,15 @@ describe("skills", () => { 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"); + expect(read.skills.demo).toMatchObject({ + sourceRef: "skills-sh:openclaw/skills/demo", + sourceKind: "skills-sh", + sourceRepository: "openclaw/skills", + sourcePath: "skills/demo", + sourceUrl: "https://github.com/openclaw/skills/tree/abc/skills/demo", + clawhubScan: "unscanned", + trustLabel: "Not scanned by ClawHub", + }); }); it("returns empty lockfile on invalid json", async () => { @@ -300,6 +316,16 @@ describe("skills", () => { version: 1, registry: "https://example.com", slug: "demo", + sourceRef: "skills-sh:openclaw/skills/demo", + sourceKind: "skills-sh", + sourceRepository: "openclaw/skills", + sourcePath: "skills/demo", + sourceUrl: "https://github.com/openclaw/skills/tree/abc/skills/demo", + canonicalRef: "@openclaw/demo", + clawhubScan: "unscanned", + trustLabel: "Not scanned by ClawHub", + artifactIdentity: + '{"installKind":"github","repo":"openclaw/skills","path":"skills/demo","commit":"abc","contentHash":"def"}', installedVersion: "1.2.3", installedAt: 123, }; diff --git a/packages/clawhub/src/skills.ts b/packages/clawhub/src/skills.ts index 90f54b12..c6d48f32 100644 --- a/packages/clawhub/src/skills.ts +++ b/packages/clawhub/src/skills.ts @@ -17,6 +17,14 @@ export type SkillOrigin = { slug: string; ownerHandle?: string; sourceRef?: string; + sourceKind?: "skills-sh"; + sourceRepository?: string; + sourcePath?: string; + sourceUrl?: string; + canonicalRef?: string; + clawhubScan?: "unscanned" | "scanned"; + trustLabel?: string; + artifactIdentity?: string; installedVersion: string; installedAt: number; fingerprint?: string; @@ -217,6 +225,19 @@ export async function readSkillOrigin(skillFolder: string): Promise