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
This commit is contained in:
Patrick Erichsen
2026-07-25 08:59:25 -05:00
committed by GitHub
parent cb9c6d8381
commit a9a80bbf6d
10 changed files with 1376 additions and 157 deletions
+2 -2
View File
@@ -290,7 +290,7 @@ registerCommand(program, ["search"])
registerCommand(program, ["install"])
.description("Install a skill into <dir>")
.argument("<skill>", "Skill to install, e.g. @openclaw/demo")
.argument("<skill>", "Skill to install, e.g. @openclaw/demo or skills-sh:owner/repo/slug")
.option("--version <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 <version>", "Update to specific version (single slug only)")
.option("--force", "Overwrite when local files do not match any version")
+154 -10
View File
@@ -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 () => {
+67 -18
View File
@@ -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<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function parseSkillRef(raw: string) {
@@ -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,
},
},
+671 -32
View File
@@ -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<ReturnType<typeof stat>>);
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<ReturnType<typeof stat>>);
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<ReturnType<typeof stat>>);
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]) }]);
+345 -95
View File
@@ -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<ApiV1SkillInstallResolveResponse, { ok: true }>;
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<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
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");
@@ -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;
}
+7
View File
@@ -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?",
},
+26
View File
@@ -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,
};
+21
View File
@@ -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<SkillOrigin
slug: parsed.slug,
ownerHandle: typeof parsed.ownerHandle === "string" ? parsed.ownerHandle : undefined,
sourceRef: typeof parsed.sourceRef === "string" ? parsed.sourceRef : undefined,
sourceKind: parsed.sourceKind === "skills-sh" ? "skills-sh" : undefined,
sourceRepository:
typeof parsed.sourceRepository === "string" ? parsed.sourceRepository : undefined,
sourcePath: typeof parsed.sourcePath === "string" ? parsed.sourcePath : undefined,
sourceUrl: typeof parsed.sourceUrl === "string" ? parsed.sourceUrl : undefined,
canonicalRef: typeof parsed.canonicalRef === "string" ? parsed.canonicalRef : undefined,
clawhubScan:
parsed.clawhubScan === "unscanned" || parsed.clawhubScan === "scanned"
? parsed.clawhubScan
: undefined,
trustLabel: typeof parsed.trustLabel === "string" ? parsed.trustLabel : undefined,
artifactIdentity:
typeof parsed.artifactIdentity === "string" ? parsed.artifactIdentity : undefined,
installedVersion: parsed.installedVersion,
installedAt: parsed.installedAt,
fingerprint: typeof parsed.fingerprint === "string" ? parsed.fingerprint : undefined,