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