refactor(skills): split ClawHub lifecycle (#113789)

* refactor(skills): split ClawHub lifecycle

* refactor(skills): privatize ClawHub store internals
This commit is contained in:
Peter Steinberger
2026-07-25 11:33:47 -07:00
committed by GitHub
parent 2949343039
commit 0a2a629117
5 changed files with 1684 additions and 1779 deletions

View File

@@ -982,7 +982,6 @@ src/sessions/session-state-events.ts
src/shared/json-schema-defaults.ts
src/shared/text/assistant-visible-text.ts
src/skills/lifecycle/clawhub.test.ts
src/skills/lifecycle/clawhub.ts
src/skills/lifecycle/install.ts
src/skills/loading/workspace-load.test.ts
src/skills/loading/workspace.ts

View File

@@ -0,0 +1,678 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
type ClawHubTrustErrorCode,
ensureClawHubPackageTrustAcknowledged,
type ClawHubRiskAcknowledgementRequest,
} from "../../infra/clawhub-install-trust.js";
import {
CLAWHUB_SKILLS_SH_TRUST_LABEL,
CLAWHUB_SKILLS_SH_TRUST_STATE,
downloadClawHubGitHubSkillArchive,
downloadClawHubSkillArchive,
downloadClawHubSkillArchiveUrl,
fetchClawHubSkillDetail,
fetchClawHubSkillInstallResolution,
fetchClawHubSkillVerification,
isDefaultClawHubBaseUrl,
normalizeClawHubSha256Integrity,
reportClawHubSkillInstallTelemetry,
resolveClawHubBaseUrl,
type ClawHubDownloadResult,
type ClawHubSkillDetail,
type ClawHubSkillInstallResolutionResponse,
type ClawHubSkillVerificationResponse,
type ClawHubSkillsShTrustState,
} from "../../infra/clawhub.js";
import { sha256Hex } from "../../infra/crypto-digest.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { pathExists } from "../../infra/fs-safe.js";
import { withExtractedArchiveRoot } from "../../infra/install-flow.js";
import { markClawPackageIndependentlyOwned } from "../../state/claw-package-adoption.js";
import {
CLAWHUB_SKILL_ARCHIVE_ROOT_MARKERS,
installExtractedSkillRoot,
resolveWorkspaceSkillInstallDir,
} from "./archive-install.js";
import {
formatClawHubSkillRef,
normalizeGitHubCommitSegment,
normalizeOptionalStringValue,
readClawHubSkillsLockfile,
writeClawHubSkillOrigin,
writeClawHubSkillsLockfile,
type ClawHubSkillDownloadedArtifactLock,
type ClawHubSkillFileLock,
type ClawHubSkillVerificationLock,
} from "./clawhub-store.js";
import { digestClawHubSkillTree } from "./skill-tree-digest.js";
export type Logger = {
info?: (message: string) => void;
warn?: (message: string) => void;
terminalLinks?: boolean;
};
export type ClawHubInstallParams = {
workspaceDir: string;
slug: string;
ownerHandle?: string;
requestedReference?: string;
trustState?: ClawHubSkillsShTrustState;
version?: string;
expectedIntegrity?: string;
baseUrl?: string;
force?: boolean;
forceInstall?: boolean;
acknowledgeClawHubRisk?: boolean;
onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise<boolean>;
logger?: Logger;
config?: OpenClawConfig;
clawManaged?: boolean;
};
export type InstallClawHubSkillResult =
| {
ok: true;
slug: string;
version: string;
targetDir: string;
detail?: ClawHubSkillDetail;
warning?: string;
}
| { ok: false; error: string; code?: ClawHubTrustErrorCode; version?: string; warning?: string };
export function normalizeExpectedArtifactIntegrity(expectedIntegrity: string): string;
export function normalizeExpectedArtifactIntegrity(expectedIntegrity: undefined): undefined;
export function normalizeExpectedArtifactIntegrity(
expectedIntegrity: string | undefined,
): string | undefined;
export function normalizeExpectedArtifactIntegrity(
expectedIntegrity: string | undefined,
): string | undefined {
if (expectedIntegrity === undefined) {
return undefined;
}
const normalized = normalizeClawHubSha256Integrity(expectedIntegrity);
if (!normalized) {
throw new Error(`Invalid expected ClawHub archive integrity: ${expectedIntegrity}`);
}
return normalized;
}
function assertDownloadedArtifactIntegrity(
archive: ClawHubDownloadResult,
expectedIntegrity: string | undefined,
): void {
const normalizedExpected = normalizeExpectedArtifactIntegrity(expectedIntegrity);
if (normalizedExpected && archive.integrity !== normalizedExpected) {
throw new Error(
`ClawHub archive integrity mismatch: expected ${normalizedExpected}, got ${archive.integrity}.`,
);
}
}
type ClawHubOfficialFlagContainer = {
channel?: unknown;
official?: unknown;
isOfficial?: unknown;
};
function hasOfficialClawHubFlag(value: ClawHubOfficialFlagContainer | null | undefined): boolean {
return value?.channel === "official" || value?.official === true || value?.isOfficial === true;
}
export function isDefaultOfficialClawHubSkillSource(params: {
baseUrl?: string;
detail?: ClawHubSkillDetail;
resolution?: Extract<ClawHubSkillInstallResolutionResponse, { ok: true }>;
}): boolean {
if (!isDefaultClawHubBaseUrl(params.baseUrl)) {
return false;
}
return (
hasOfficialClawHubFlag(params.detail?.skill) ||
hasOfficialClawHubFlag(params.detail?.owner) ||
hasOfficialClawHubFlag(params.resolution) ||
(params.resolution?.installKind === "archive" &&
hasOfficialClawHubFlag(params.resolution.archive))
);
}
async function fetchDefaultClawHubSkillDetailIfOfficial(params: {
baseUrl?: string;
slug: string;
ownerHandle?: string;
}): Promise<ClawHubSkillDetail | undefined> {
if (!isDefaultClawHubBaseUrl(params.baseUrl)) {
return undefined;
}
try {
const detail = await fetchClawHubSkillDetail({
slug: params.slug,
...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}),
baseUrl: params.baseUrl,
});
return isDefaultOfficialClawHubSkillSource({ baseUrl: params.baseUrl, detail })
? detail
: undefined;
} catch {
return undefined;
}
}
export async function resolveInstallVersion(params: {
slug: string;
ownerHandle?: string;
version?: string;
baseUrl?: string;
}): Promise<{ detail: ClawHubSkillDetail; version: string }> {
const detail = await fetchClawHubSkillDetail({
slug: params.slug,
...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}),
baseUrl: params.baseUrl,
});
if (!detail.skill) {
throw new Error(`Skill "${params.slug}" not found on ClawHub.`);
}
const version = params.version ?? detail.latestVersion?.version;
if (!version) {
throw new Error(`Skill "${params.slug}" has no installable version.`);
}
return { detail, version };
}
function normalizeGitHubSourcePath(raw: string): string {
const parts = raw.replaceAll("\\", "/").split("/").filter(Boolean);
if (parts.length === 0 || parts.some((part) => part === "." || part === "..")) {
throw new Error(`Invalid GitHub skill source path: ${raw}`);
}
return parts.join("/");
}
function buildGitHubTreeUrl(params: { repo: string; commit: string; sourcePath?: string }): string {
const [owner, name] = params.repo.split("/") as [string, string];
const segments = [
owner,
name,
"tree",
params.commit,
...(params.sourcePath ? params.sourcePath.split("/") : []),
];
return `https://github.com/${segments.map(encodeURIComponent).join("/")}`;
}
export function readVerifiedClawHubSkillSourceUrl(raw: unknown): string | undefined {
const provenance =
raw && typeof raw === "object" && !Array.isArray(raw)
? (raw as Record<string, unknown>)
: undefined;
// Only this ClawHub variant is server-resolved; other provenance metadata
// must not become a trusted source link.
if (provenance?.source !== "server-resolved-github-import") {
return undefined;
}
const repo = normalizeOptionalStringValue(provenance.repo);
const repoParts = repo?.split("/");
const commit = normalizeGitHubCommitSegment(provenance.commit);
if (
!repo ||
repoParts?.length !== 2 ||
repoParts.some((part) => !/^[A-Za-z0-9._-]+$/.test(part)) ||
!commit
) {
return undefined;
}
const pathValue = normalizeOptionalStringValue(provenance.path);
try {
const sourcePath = pathValue ? normalizeGitHubSourcePath(pathValue) : undefined;
return buildGitHubTreeUrl({ repo, commit, ...(sourcePath ? { sourcePath } : {}) });
} catch {
return undefined;
}
}
function buildDownloadedArtifactLock(
archive: ClawHubDownloadResult,
): ClawHubSkillDownloadedArtifactLock {
return { kind: archive.artifact, sha256: archive.sha256Hex, integrity: archive.integrity };
}
function snapshotClawHubSkillVerification(
verification: ClawHubSkillVerificationResponse,
): ClawHubSkillVerificationLock {
return {
schema: verification.schema,
ok: verification.ok,
decision: verification.decision,
reasons: [...verification.reasons],
...(verification.card !== undefined ? { card: verification.card } : {}),
...(verification.artifact !== undefined ? { artifact: verification.artifact } : {}),
...(verification.provenance !== undefined ? { provenance: verification.provenance } : {}),
...(verification.security !== undefined ? { security: verification.security } : {}),
...(verification.signature !== undefined ? { signature: verification.signature } : {}),
};
}
async function fetchInstallVerificationLock(params: {
slug: string;
ownerHandle?: string;
requestedReference?: string;
version?: string;
baseUrl?: string;
logger?: Logger;
}): Promise<ClawHubSkillVerificationLock | undefined> {
try {
const verification = await fetchClawHubSkillVerification({
slug: params.slug,
...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}),
...(params.requestedReference ? { requestedReference: params.requestedReference } : {}),
version: params.version,
baseUrl: params.baseUrl,
});
return snapshotClawHubSkillVerification(verification);
} catch (err) {
params.logger?.warn?.(
`Skill verification for ${formatClawHubSkillRef(params)} failed: ${formatErrorMessage(err)}`,
);
return undefined;
}
}
async function readInstalledSkillFileLock(
skillDir: string,
): Promise<ClawHubSkillFileLock | undefined> {
for (const marker of CLAWHUB_SKILL_ARCHIVE_ROOT_MARKERS) {
try {
return { path: marker, sha256: sha256Hex(await fs.readFile(path.join(skillDir, marker))) };
} catch {
continue;
}
}
return undefined;
}
function resolveGitHubSkillSourceDir(repoRoot: string, sourcePath: string): string {
return path.join(repoRoot, ...normalizeGitHubSourcePath(sourcePath).split("/"));
}
async function installArchiveResolution(params: {
workspaceDir: string;
slug: string;
ownerHandle?: string;
version: string;
archivePath: string;
registry: string;
authority: "official" | "openclaw" | "third-party";
force?: boolean;
logger?: Logger;
config?: OpenClawConfig;
}) {
return await withExtractedArchiveRoot({
archivePath: params.archivePath,
tempDirPrefix: "openclaw-skill-clawhub-",
timeoutMs: 120_000,
rootMarkers: CLAWHUB_SKILL_ARCHIVE_ROOT_MARKERS,
onExtracted: async (rootDir) =>
await installExtractedSkillRoot({
workspaceDir: params.workspaceDir,
slug: params.slug,
extractedRoot: rootDir,
mode: params.force ? "update" : "install",
logger: params.logger,
policy: {
config: params.config,
installId: "clawhub",
origin: {
type: "clawhub",
registry: params.registry,
slug: params.slug,
...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}),
version: params.version,
},
source: { kind: "clawhub", authority: params.authority, mutable: false, network: true },
requestedSpecifier: `clawhub:${formatClawHubSkillRef(params)}@${params.version}`,
},
rootMarkers: CLAWHUB_SKILL_ARCHIVE_ROOT_MARKERS,
}),
});
}
async function installGitHubResolution(params: {
workspaceDir: string;
slug: string;
ownerHandle?: string;
sourcePath: string;
archivePath: string;
registry: string;
authority: "official" | "third-party";
repo: string;
commit: string;
requestedReference?: string;
trustState?: ClawHubSkillsShTrustState;
force?: boolean;
logger?: Logger;
config?: OpenClawConfig;
}) {
// Preserve the repository root for sourcePath selection. Root markers validate
// the selected skill directory afterward, so nested paths are not applied twice.
return await withExtractedArchiveRoot({
archivePath: params.archivePath,
tempDirPrefix: "openclaw-skill-clawhub-github-",
timeoutMs: 120_000,
onExtracted: async (repoRoot) =>
await installExtractedSkillRoot({
workspaceDir: params.workspaceDir,
slug: params.slug,
extractedRoot: resolveGitHubSkillSourceDir(repoRoot, params.sourcePath),
mode: params.force ? "update" : "install",
logger: params.logger,
policy: {
config: params.config,
installId: "clawhub",
origin: {
type: "clawhub",
registry: params.registry,
slug: params.slug,
...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}),
version: params.commit,
repo: params.repo,
path: params.sourcePath,
commit: params.commit,
...(params.requestedReference ? { reference: params.requestedReference } : {}),
...(params.trustState ? { trustState: params.trustState } : {}),
},
source: { kind: "git", authority: params.authority, mutable: false, network: true },
requestedSpecifier:
params.requestedReference ??
`clawhub:${formatClawHubSkillRef(params)}@${params.commit}`,
},
rootMarkers: CLAWHUB_SKILL_ARCHIVE_ROOT_MARKERS,
}),
});
}
function assertInstallResolutionAllowed(
resolution: ClawHubSkillInstallResolutionResponse,
): Extract<ClawHubSkillInstallResolutionResponse, { ok: true }> {
if (!resolution.ok) {
if (resolution.reason === "ambiguous_slug") {
const message = resolution.message ? ` ${resolution.message}` : "";
throw new Error(
`Skill "${resolution.slug}" is ambiguous on ClawHub. Install an owner-qualified skill, for example: openclaw skills install @owner/${resolution.slug}.${message}`,
);
}
throw new Error(resolution.message || `Skill "${resolution.slug}" is not installable.`);
}
if (resolution.installKind !== "github") {
return resolution;
}
const commit = normalizeGitHubCommitSegment(resolution.github.commit)?.toLowerCase();
if (!commit) {
throw new Error(
`Skill "${resolution.slug}" resolved to a mutable or invalid GitHub source ref; expected a full 40-character commit SHA.`,
);
}
return { ...resolution, github: { ...resolution.github, commit } };
}
export async function ensureClawHubSkillTrustAcknowledged(
params: ClawHubInstallParams & { version: string; skipClawHubTrustCheck?: boolean },
): Promise<
| { ok: true; warning?: string }
| { ok: false; error: string; code?: ClawHubTrustErrorCode; warning?: string }
> {
if (params.skipClawHubTrustCheck) {
return { ok: true };
}
const result = await ensureClawHubPackageTrustAcknowledged({
subject: {
kind: "skill",
packageName: params.slug,
...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}),
},
version: params.version,
baseUrl: params.baseUrl,
acknowledgeClawHubRisk: params.acknowledgeClawHubRisk,
onClawHubRisk: params.onClawHubRisk,
logger: params.logger,
mode: params.force ? "update" : "install",
});
return result.ok
? { ok: true, ...(result.warning ? { warning: result.warning } : {}) }
: {
ok: false,
error: result.error,
...(result.code ? { code: result.code } : {}),
...(result.warning ? { warning: result.warning } : {}),
};
}
export async function performClawHubSkillInstall(
params: ClawHubInstallParams,
): Promise<InstallClawHubSkillResult> {
try {
normalizeExpectedArtifactIntegrity(params.expectedIntegrity);
const targetDir = resolveWorkspaceSkillInstallDir(params.workspaceDir, params.slug);
const registry = resolveClawHubBaseUrl(params.baseUrl);
if (!params.force && (await pathExists(targetDir))) {
return {
ok: false,
error: `Skill already exists at ${targetDir}. Re-run with force/update.`,
};
}
let version: string;
let detail: ClawHubSkillDetail | undefined;
let resolution: Extract<ClawHubSkillInstallResolutionResponse, { ok: true }> | undefined;
let trustWarning: string | undefined;
let official = false;
let archive: ClawHubDownloadResult;
if (params.version) {
const resolved = await resolveInstallVersion(params);
detail = resolved.detail;
version = resolved.version;
official = isDefaultOfficialClawHubSkillSource({ baseUrl: params.baseUrl, detail });
const trust = await ensureClawHubSkillTrustAcknowledged({
...params,
version,
skipClawHubTrustCheck: official,
});
if (!trust.ok) {
return { ...trust, version };
}
trustWarning = trust.warning;
params.logger?.info?.(`Downloading ${params.slug}@${version} from ClawHub…`);
archive = await downloadClawHubSkillArchive({
slug: params.slug,
...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}),
version,
baseUrl: params.baseUrl,
});
} else {
resolution = assertInstallResolutionAllowed(
await fetchClawHubSkillInstallResolution({
slug: params.slug,
...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}),
...(params.requestedReference ? { requestedReference: params.requestedReference } : {}),
baseUrl: params.baseUrl,
...(params.forceInstall ? { forceInstall: true } : {}),
}),
);
if (params.requestedReference) {
if (
resolution.installKind !== "github" ||
resolution.trust?.state !== CLAWHUB_SKILLS_SH_TRUST_STATE
) {
throw new Error(
`Skill "${params.slug}" did not resolve to an unscanned, commit-pinned GitHub source.`,
);
}
trustWarning = CLAWHUB_SKILLS_SH_TRUST_LABEL;
params.logger?.warn?.(CLAWHUB_SKILLS_SH_TRUST_LABEL);
}
const resolutionOfficial = isDefaultOfficialClawHubSkillSource({
baseUrl: params.baseUrl,
resolution,
});
detail = resolutionOfficial
? undefined
: await fetchDefaultClawHubSkillDetailIfOfficial({
baseUrl: params.baseUrl,
slug: params.slug,
...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}),
});
official = isDefaultOfficialClawHubSkillSource({
baseUrl: params.baseUrl,
detail,
resolution,
});
if (resolution.installKind === "github") {
version = resolution.github.commit;
// GitHub-backed ClawHub skills are commit resolutions, not ClawHub skill
// release versions; the install resolver owns their scan/force policy.
params.logger?.info?.(`Downloading ${params.slug}@${version} from GitHub…`);
archive = await downloadClawHubGitHubSkillArchive({
repo: resolution.github.repo,
commit: resolution.github.commit,
});
} else {
version = resolution.archive.version;
const trust = await ensureClawHubSkillTrustAcknowledged({
...params,
version,
skipClawHubTrustCheck: official,
});
if (!trust.ok) {
return { ...trust, version };
}
trustWarning = trust.warning;
params.logger?.info?.(`Downloading ${params.slug}@${version} from ClawHub…`);
archive = await downloadClawHubSkillArchiveUrl({
url: resolution.archive.downloadUrl,
baseUrl: params.baseUrl,
});
}
}
try {
assertDownloadedArtifactIntegrity(archive, params.expectedIntegrity);
if (!params.version && !resolution) {
throw new Error(`Skill "${params.slug}" has no install resolution.`);
}
const install =
resolution?.installKind === "github" && !params.version
? await installGitHubResolution({
workspaceDir: params.workspaceDir,
slug: params.slug,
...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}),
sourcePath: resolution.github.path,
archivePath: archive.archivePath,
registry,
authority: official ? "official" : "third-party",
repo: resolution.github.repo,
commit: resolution.github.commit,
requestedReference: params.requestedReference,
trustState: params.trustState,
force: params.force,
logger: params.logger,
config: params.config,
})
: await installArchiveResolution({
workspaceDir: params.workspaceDir,
slug: params.slug,
...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}),
version,
archivePath: archive.archivePath,
registry,
authority: official
? "official"
: isDefaultClawHubBaseUrl(params.baseUrl)
? "openclaw"
: "third-party",
force: params.force,
logger: params.logger,
config: params.config,
});
if (!install.ok) {
return { ok: false, error: install.error };
}
const installedAt = Date.now();
const artifact = buildDownloadedArtifactLock(archive);
const fileTreeSha256 = await digestClawHubSkillTree(install.targetDir);
const verificationVersion =
resolution?.installKind === "github" && !params.version ? undefined : version;
const [skillFile, verification] = await Promise.all([
readInstalledSkillFileLock(install.targetDir),
fetchInstallVerificationLock({
slug: params.slug,
...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}),
...(params.requestedReference ? { requestedReference: params.requestedReference } : {}),
version: verificationVersion,
baseUrl: params.baseUrl,
logger: params.logger,
}),
]);
const sourceUrl =
(resolution?.installKind === "github"
? normalizeOptionalStringValue(resolution.github.sourceUrl)
: undefined) ?? readVerifiedClawHubSkillSourceUrl(verification?.provenance);
const trackedMetadata = {
...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}),
...(params.requestedReference ? { requestedReference: params.requestedReference } : {}),
...(params.trustState ? { trustState: params.trustState } : {}),
installedAt,
...(sourceUrl ? { sourceUrl } : {}),
artifact,
...(skillFile ? { skillFile } : {}),
fileTreeSha256,
};
await writeClawHubSkillOrigin(install.targetDir, {
version: 1,
registry,
slug: params.slug,
...trackedMetadata,
installedVersion: version,
});
const lock = await readClawHubSkillsLockfile(params.workspaceDir);
lock.skills[params.slug] = {
version,
registry,
...trackedMetadata,
...(verification ? { verification } : {}),
};
await writeClawHubSkillsLockfile(params.workspaceDir, lock);
if (!params.clawManaged) {
markClawPackageIndependentlyOwned({
kind: "skill",
source: "clawhub",
ref: params.slug,
version,
workspace: params.workspaceDir,
});
}
await reportClawHubSkillInstallTelemetry({
baseUrl: params.baseUrl,
slug: params.slug,
...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}),
version,
...(params.requestedReference ? { requestedReference: params.requestedReference } : {}),
...(params.trustState ? { trustState: params.trustState } : {}),
}).catch(() => undefined);
return {
ok: true,
slug: params.slug,
version,
targetDir: install.targetDir,
...(detail ? { detail } : {}),
...(trustWarning ? { warning: trustWarning } : {}),
};
} finally {
await archive.cleanup().catch(() => undefined);
}
} catch (err) {
return { ok: false, error: formatErrorMessage(err) };
}
}

View File

@@ -0,0 +1,491 @@
import fsSync from "node:fs";
import path from "node:path";
import {
CLAWHUB_SKILLS_SH_TRUST_STATE,
resolveClawHubBaseUrl,
searchClawHubSkills,
type ClawHubSkillSearchResult,
type ClawHubSkillsShTrustState,
} from "../../infra/clawhub.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { normalizeTrackedSkillSlug, resolveWorkspaceSkillInstallDir } from "./archive-install.js";
import {
normalizeDownloadedArtifactLock,
normalizeOptionalStringValue,
normalizeSkillFileLock,
normalizeStoredRegistry,
parseRequestedClawHubSkillRef,
readClawHubSkillOriginStatusSync,
readClawHubSkillOriginStrict,
readClawHubSkillsLockfile,
readClawHubSkillsLockfileStatusSync,
type ClawHubSkillDownloadedArtifactLock,
type ClawHubSkillFileLock,
type ClawHubSkillsLockfileStatusRead,
} from "./clawhub-store.js";
const LOCAL_SKILL_CARD_FILENAME = "skill-card.md";
const LOCAL_SKILL_CARD_MAX_BYTES = 256 * 1024;
export type ClawHubSkillStatusLink =
| {
status: "linked";
valid: true;
registry: string;
slug: string;
ownerHandle?: string;
requestedReference?: string;
trustState?: ClawHubSkillsShTrustState;
installedVersion: string;
installedAt: number;
originPath: string;
lockPath: string;
sourceUrl?: string;
artifact?: ClawHubSkillDownloadedArtifactLock;
skillFile?: ClawHubSkillFileLock;
fileTreeSha256?: string;
}
| {
status: "invalid";
valid: false;
reason: string;
registry?: string;
slug?: string;
installedVersion?: string;
installedAt?: number;
originPath?: string;
lockPath?: string;
};
export type LocalSkillCardStatus = {
present: true;
path: string;
sizeBytes: number;
};
type LocalSkillCardRead = LocalSkillCardStatus & { content?: string };
type ClawHubSkillVerificationSelector = "installed-version" | "version" | "tag" | "latest";
type ClawHubSkillVerificationTargetResult =
| {
ok: true;
slug: string;
ownerHandle?: string;
requestedReference?: string;
trustState?: ClawHubSkillsShTrustState;
baseUrl: string;
version: string | undefined;
tag: string | undefined;
resolution: {
source: "installed" | "registry";
selector: ClawHubSkillVerificationSelector;
registry: string;
skillDir: string | undefined;
installedVersion: string | undefined;
};
}
| { ok: false; error: string };
function readRealPathSync(candidate: string): string | undefined {
try {
return fsSync.realpathSync.native(candidate);
} catch {
return undefined;
}
}
function invalidLink(
reason: string,
details: Omit<Extract<ClawHubSkillStatusLink, { valid: false }>, "status" | "valid" | "reason">,
): ClawHubSkillStatusLink {
return { status: "invalid", valid: false, reason, ...details };
}
export function resolveClawHubSkillStatusLinkSync(params: {
workspaceDir: string;
skillDir: string;
skillKey: string;
lockRead?: ClawHubSkillsLockfileStatusRead;
lockfileScope?: "workspace" | "managed";
}): ClawHubSkillStatusLink | undefined {
const originRead = readClawHubSkillOriginStatusSync(params.skillDir);
const lockRead = params.lockRead ?? readClawHubSkillsLockfileStatusSync(params.workspaceDir);
const lockfileLabel = `${params.lockfileScope ?? "workspace"} ClawHub lockfile`;
if (originRead.kind === "missing") {
let trackedSlug: string;
try {
trackedSlug = normalizeTrackedSkillSlug(params.skillKey);
} catch {
return undefined;
}
const locked = lockRead.kind === "found" ? lockRead.lock.skills[trackedSlug] : undefined;
if (!locked) {
return undefined;
}
return invalidLink(
`Skill "${trackedSlug}" is tracked by the ${lockfileLabel} but is missing local ClawHub origin metadata.`,
{
slug: trackedSlug,
installedVersion: locked.version,
installedAt: locked.installedAt,
registry: normalizeStoredRegistry(locked.registry ?? resolveClawHubBaseUrl()),
lockPath: lockRead.kind === "found" ? lockRead.path : undefined,
},
);
}
if (originRead.kind === "malformed") {
return invalidLink(
`Malformed ClawHub origin metadata at ${originRead.path}: ${originRead.error}`,
{
originPath: originRead.path,
lockPath: lockRead.kind === "found" ? lockRead.path : undefined,
},
);
}
const originDetails = {
registry: originRead.origin.registry,
installedVersion: originRead.origin.installedVersion,
installedAt: originRead.origin.installedAt,
originPath: originRead.path,
};
let trackedSlug: string;
try {
trackedSlug = normalizeTrackedSkillSlug(originRead.origin.slug);
} catch (err) {
return invalidLink(
`Invalid ClawHub origin slug "${originRead.origin.slug}": ${formatErrorMessage(err)}`,
{
...originDetails,
slug: originRead.origin.slug,
lockPath: lockRead.kind === "found" ? lockRead.path : undefined,
},
);
}
if (lockRead.kind === "missing") {
return invalidLink(
`Skill "${trackedSlug}" has ClawHub origin metadata but is not tracked by the ${lockfileLabel}.`,
{ ...originDetails, slug: trackedSlug },
);
}
if (lockRead.kind === "malformed") {
return invalidLink(`Malformed ${lockfileLabel} at ${lockRead.path}: ${lockRead.error}`, {
...originDetails,
slug: trackedSlug,
lockPath: lockRead.path,
});
}
const locked = lockRead.lock.skills[trackedSlug];
if (!locked) {
return invalidLink(
`Skill "${trackedSlug}" has ClawHub origin metadata but is not tracked by the ${lockfileLabel}.`,
{ ...originDetails, slug: trackedSlug, lockPath: lockRead.path },
);
}
const expectedSkillDir = readRealPathSync(
resolveWorkspaceSkillInstallDir(params.workspaceDir, trackedSlug),
);
if (!expectedSkillDir || readRealPathSync(params.skillDir) !== expectedSkillDir) {
return invalidLink(
`Skill "${trackedSlug}" ClawHub origin metadata is not in the expected ClawHub install directory.`,
{ ...originDetails, slug: trackedSlug, lockPath: lockRead.path },
);
}
const originRegistry = normalizeStoredRegistry(originRead.origin.registry);
const lockedRegistry =
locked.registry === undefined ? originRegistry : normalizeStoredRegistry(locked.registry);
const sourceUrl = normalizeOptionalStringValue(locked.sourceUrl);
const ownerHandle = normalizeOptionalStringValue(locked.ownerHandle);
const requestedReference = normalizeOptionalStringValue(locked.requestedReference);
const trustState =
locked.trustState === CLAWHUB_SKILLS_SH_TRUST_STATE ? CLAWHUB_SKILLS_SH_TRUST_STATE : undefined;
const artifact = normalizeDownloadedArtifactLock(locked.artifact);
const skillFile = normalizeSkillFileLock(locked.skillFile);
const fileTreeSha256 = normalizeOptionalStringValue(locked.fileTreeSha256);
// A linked status is a trust signal. Only expose provenance when both
// install records agree, so a one-sided origin edit cannot become trusted.
const provenanceMatches =
originRead.origin.ownerHandle === ownerHandle &&
originRead.origin.requestedReference === requestedReference &&
originRead.origin.trustState === trustState &&
originRead.origin.sourceUrl === sourceUrl &&
originRead.origin.artifact?.kind === artifact?.kind &&
originRead.origin.artifact?.sha256 === artifact?.sha256 &&
originRead.origin.artifact?.integrity === artifact?.integrity &&
originRead.origin.skillFile?.path === skillFile?.path &&
originRead.origin.skillFile?.sha256 === skillFile?.sha256 &&
originRead.origin.fileTreeSha256 === fileTreeSha256;
if (
locked.version !== originRead.origin.installedVersion ||
locked.installedAt !== originRead.origin.installedAt ||
lockedRegistry !== originRegistry ||
!provenanceMatches
) {
return invalidLink(
`Skill "${trackedSlug}" ClawHub origin metadata does not match the ${lockfileLabel}.`,
{
...originDetails,
registry: lockedRegistry,
slug: trackedSlug,
lockPath: lockRead.path,
},
);
}
return {
status: "linked",
valid: true,
registry: lockedRegistry,
slug: trackedSlug,
...(ownerHandle ? { ownerHandle } : {}),
...(requestedReference ? { requestedReference } : {}),
...(trustState ? { trustState } : {}),
installedVersion: locked.version,
installedAt: locked.installedAt,
originPath: originRead.path,
lockPath: lockRead.path,
...(sourceUrl ? { sourceUrl } : {}),
...(artifact ? { artifact } : {}),
...(skillFile ? { skillFile } : {}),
...(fileTreeSha256 ? { fileTreeSha256 } : {}),
};
}
function isPathInsideDir(child: string, parent: string): boolean {
const relative = path.relative(parent, child);
return (
relative === "" ||
(relative.length > 0 && !relative.startsWith("..") && !path.isAbsolute(relative))
);
}
function readLocalSkillCardSync(
skillDir: string,
includeContent = false,
): LocalSkillCardRead | undefined {
const cardPath = path.join(skillDir, LOCAL_SKILL_CARD_FILENAME);
let lstat: fsSync.Stats;
try {
lstat = fsSync.lstatSync(cardPath);
} catch {
return undefined;
}
if (!lstat.isFile() || lstat.size > LOCAL_SKILL_CARD_MAX_BYTES) {
return undefined;
}
let fd: number | undefined;
try {
const rootRealPath = fsSync.realpathSync.native(skillDir);
const cardRealPath = fsSync.realpathSync.native(cardPath);
if (!isPathInsideDir(cardRealPath, rootRealPath)) {
return undefined;
}
fd = fsSync.openSync(cardPath, fsSync.constants.O_RDONLY | (fsSync.constants.O_NOFOLLOW ?? 0));
const fdStat = fsSync.fstatSync(fd);
if (!fdStat.isFile() || fdStat.size > LOCAL_SKILL_CARD_MAX_BYTES) {
return undefined;
}
const result: LocalSkillCardRead = {
present: true,
path: cardPath,
sizeBytes: fdStat.size,
};
if (includeContent) {
result.content = fsSync.readFileSync(fd, "utf8");
}
return result;
} catch {
return undefined;
} finally {
if (fd !== undefined) {
try {
fsSync.closeSync(fd);
} catch {
// ignore close errors while reporting the card as unavailable
}
}
}
}
export function resolveLocalSkillCardStatusSync(
skillDir: string,
): LocalSkillCardStatus | undefined {
return readLocalSkillCardSync(skillDir);
}
export function readLocalSkillCardContentSync(skillDir: string): string | undefined {
return readLocalSkillCardSync(skillDir, true)?.content;
}
function normalizeOptionalSelector(value: string | undefined): string | undefined {
return value?.trim() || undefined;
}
export async function searchSkillsFromClawHub(params: {
query?: string;
limit?: number;
baseUrl?: string;
}): Promise<ClawHubSkillSearchResult[]> {
return await searchClawHubSkills({
query: params.query?.trim() || "*",
limit: params.limit,
baseUrl: params.baseUrl,
});
}
export async function resolveClawHubSkillVerificationTarget(params: {
workspaceDir: string;
slug: string;
version?: string;
tag?: string;
baseUrl?: string;
}): Promise<ClawHubSkillVerificationTargetResult> {
try {
const version = normalizeOptionalSelector(params.version);
const tag = normalizeOptionalSelector(params.tag);
if (version && tag) {
return { ok: false, error: "Use either --version or --tag." };
}
const requestedRef = parseRequestedClawHubSkillRef(params.slug);
if (requestedRef.requestedReference && (version || tag)) {
return {
ok: false,
error: "--version and --tag are not supported for skills-sh references.",
};
}
const trackedSlug = requestedRef.slug;
const skillDir = resolveWorkspaceSkillInstallDir(params.workspaceDir, trackedSlug);
const originRead = await readClawHubSkillOriginStrict(skillDir);
if (originRead.kind === "malformed") {
return {
ok: false,
error: `Malformed ClawHub origin metadata at ${originRead.path}: ${originRead.error}`,
};
}
if (originRead.kind === "found") {
const locked = (await readClawHubSkillsLockfile(params.workspaceDir)).skills[trackedSlug];
if (!locked) {
return {
ok: false,
error: `Skill "${trackedSlug}" has ClawHub origin metadata but is not tracked by the workspace ClawHub lockfile. Reinstall it from ClawHub before verifying it as an installed ClawHub skill.`,
};
}
const originSlug = normalizeTrackedSkillSlug(originRead.origin.slug);
if (originSlug !== trackedSlug) {
return {
ok: false,
error: `Skill "${trackedSlug}" has ClawHub origin metadata for "${originRead.origin.slug}". Reinstall it from ClawHub before verifying it as an installed ClawHub skill.`,
};
}
const originRegistry = normalizeStoredRegistry(originRead.origin.registry);
const lockedRegistry =
locked.registry === undefined ? originRegistry : normalizeStoredRegistry(locked.registry);
const ownerHandle = normalizeOptionalStringValue(locked.ownerHandle);
const requestedReference = normalizeOptionalStringValue(locked.requestedReference);
const trustState =
locked.trustState === CLAWHUB_SKILLS_SH_TRUST_STATE
? CLAWHUB_SKILLS_SH_TRUST_STATE
: undefined;
if (
locked.version !== originRead.origin.installedVersion ||
locked.installedAt !== originRead.origin.installedAt ||
lockedRegistry !== originRegistry ||
originRead.origin.ownerHandle !== ownerHandle ||
originRead.origin.requestedReference !== requestedReference ||
originRead.origin.trustState !== trustState
) {
return {
ok: false,
error: `Skill "${trackedSlug}" ClawHub origin metadata does not match the workspace ClawHub lockfile. Reinstall it from ClawHub before verifying it as an installed ClawHub skill.`,
};
}
if (requestedReference && (version || tag)) {
return {
ok: false,
error: "--version and --tag are not supported for skills-sh references.",
};
}
if (requestedRef.ownerHandle && ownerHandle !== requestedRef.ownerHandle) {
const trackedRef = ownerHandle ? `@${ownerHandle}/${trackedSlug}` : trackedSlug;
return {
ok: false,
error: `Skill "${trackedSlug}" is tracked as ${trackedRef}, not @${requestedRef.ownerHandle}/${trackedSlug}.`,
};
}
if (
requestedRef.requestedReference &&
requestedReference !== requestedRef.requestedReference
) {
return {
ok: false,
error: `Skill "${trackedSlug}" is not tracked from ${requestedRef.requestedReference}.`,
};
}
const selector: ClawHubSkillVerificationSelector = version
? "version"
: tag
? "tag"
: "installed-version";
// ClawHub's skills.sh verify route accepts the catalog reference as its sole selector.
// It rejects version/tag; the stored commit remains local installed provenance.
const verificationVersion = requestedReference
? undefined
: (version ?? (tag ? undefined : locked.version));
return {
ok: true,
slug: trackedSlug,
...(ownerHandle ? { ownerHandle } : {}),
...(requestedReference ? { requestedReference } : {}),
...(trustState ? { trustState } : {}),
baseUrl: lockedRegistry,
version: verificationVersion,
tag: requestedReference ? undefined : tag,
resolution: {
source: "installed",
selector,
registry: lockedRegistry,
skillDir,
installedVersion: locked.version,
},
};
}
const lockRead = readClawHubSkillsLockfileStatusSync(params.workspaceDir);
if (lockRead.kind === "malformed") {
return {
ok: false,
error: `Malformed workspace ClawHub lockfile at ${lockRead.path}: ${lockRead.error}`,
};
}
if (lockRead.kind === "found" && lockRead.lock.skills[trackedSlug]) {
return {
ok: false,
error: `Skill "${trackedSlug}" is tracked by the workspace ClawHub lockfile but is missing ClawHub origin metadata. Reinstall it from ClawHub before verifying it as an installed ClawHub skill.`,
};
}
const registry = resolveClawHubBaseUrl(params.baseUrl);
const selector: ClawHubSkillVerificationSelector = version ? "version" : tag ? "tag" : "latest";
return {
ok: true,
slug: requestedRef.slug,
...(requestedRef.ownerHandle ? { ownerHandle: requestedRef.ownerHandle } : {}),
...(requestedRef.requestedReference
? { requestedReference: requestedRef.requestedReference }
: {}),
...(requestedRef.trustState ? { trustState: requestedRef.trustState } : {}),
baseUrl: registry,
version,
tag,
resolution: {
source: "registry",
selector,
registry,
skillDir: undefined,
installedVersion: undefined,
},
};
} catch (err) {
return { ok: false, error: formatErrorMessage(err) };
}
}

View File

@@ -0,0 +1,422 @@
import fsSync from "node:fs";
import path from "node:path";
import {
CLAWHUB_SKILLS_SH_TRUST_STATE,
type ClawHubDownloadResult,
type ClawHubSkillVerificationResponse,
type ClawHubSkillsShTrustState,
} from "../../infra/clawhub.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { readJsonIfExists, tryReadJson, writeJson } from "../../infra/json-files.js";
import { normalizeTrackedSkillSlug, validateRequestedSkillSlug } from "./archive-install.js";
const DOT_DIR = ".clawhub";
const LEGACY_DOT_DIR = ".clawdhub";
const CLAWHUB_OWNER_HANDLE_PATTERN = /^[a-z0-9](?:[a-z0-9._-]{0,38}[a-z0-9])?$/;
const GITHUB_OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/;
const GITHUB_REPO_PATTERN = /^[A-Za-z0-9._-]{1,100}$/;
export type ClawHubSkillDownloadedArtifactLock = {
kind: ClawHubDownloadResult["artifact"];
sha256: string;
integrity: string;
};
export type ClawHubSkillFileLock = {
path: string;
sha256: string;
};
export type ClawHubSkillVerificationLock = {
schema: ClawHubSkillVerificationResponse["schema"];
ok: boolean;
decision: ClawHubSkillVerificationResponse["decision"];
reasons: string[];
card?: unknown;
artifact?: unknown;
provenance?: unknown;
security?: unknown;
signature?: unknown;
};
type ClawHubSkillLockEntry = {
version: string;
installedAt: number;
registry?: string;
ownerHandle?: string;
requestedReference?: string;
trustState?: ClawHubSkillsShTrustState;
sourceUrl?: string;
artifact?: ClawHubSkillDownloadedArtifactLock;
skillFile?: ClawHubSkillFileLock;
fileTreeSha256?: string;
verification?: ClawHubSkillVerificationLock;
};
type ClawHubSkillOrigin = {
version: 1;
registry: string;
slug: string;
ownerHandle?: string;
requestedReference?: string;
trustState?: ClawHubSkillsShTrustState;
installedVersion: string;
installedAt: number;
sourceUrl?: string;
artifact?: ClawHubSkillDownloadedArtifactLock;
skillFile?: ClawHubSkillFileLock;
fileTreeSha256?: string;
};
export type ClawHubSkillsLockfile = {
version: 1;
skills: Record<string, ClawHubSkillLockEntry>;
};
export type ClawHubSkillsLockfileStatusRead =
| { kind: "found"; lock: ClawHubSkillsLockfile; path: string }
| { kind: "missing" }
| { kind: "malformed"; path: string; error: string };
export type ClawHubSkillRef = {
slug: string;
ownerHandle?: string;
requestedReference?: string;
trustState?: ClawHubSkillsShTrustState;
};
type StrictOriginReadResult =
| { kind: "found"; origin: ClawHubSkillOrigin; path: string }
| { kind: "missing" }
| { kind: "malformed"; path: string; error: string };
function metadataPaths(rootDir: string, filename: string): string[] {
return [path.join(rootDir, DOT_DIR, filename), path.join(rootDir, LEGACY_DOT_DIR, filename)];
}
function normalizeClawHubOwnerHandle(raw: string): string {
const ownerHandle = raw.trim().toLowerCase();
if (!CLAWHUB_OWNER_HANDLE_PATTERN.test(ownerHandle)) {
throw new Error(`Invalid ClawHub owner handle: ${raw}`);
}
return ownerHandle;
}
export function parseRequestedClawHubSkillRef(raw: string): ClawHubSkillRef {
const value = raw.trim();
if (value.startsWith("skills-sh/")) {
throw new Error(`Invalid skills.sh skill reference: ${raw}`);
}
if (value.startsWith("skills-sh:")) {
const parts = value.slice("skills-sh:".length).split("/");
if (parts.length !== 3) {
throw new Error(`Invalid skills.sh skill reference: ${raw}`);
}
const [owner, repo, slug] = parts;
if (
!owner ||
!repo ||
!slug ||
!GITHUB_OWNER_PATTERN.test(owner) ||
!GITHUB_REPO_PATTERN.test(repo) ||
repo === "." ||
repo === ".."
) {
throw new Error(`Invalid skills.sh skill reference: ${raw}`);
}
return {
slug: validateRequestedSkillSlug(slug),
requestedReference: value,
trustState: CLAWHUB_SKILLS_SH_TRUST_STATE,
};
}
if (!value.startsWith("@")) {
return { slug: validateRequestedSkillSlug(value) };
}
const parts = value.slice(1).split("/");
if (parts.length !== 2) {
throw new Error(`Invalid ClawHub skill reference: ${raw}`);
}
const [owner, slug] = parts;
if (!owner || !slug) {
throw new Error(`Invalid ClawHub skill reference: ${raw}`);
}
return {
ownerHandle: normalizeClawHubOwnerHandle(owner),
slug: validateRequestedSkillSlug(slug),
};
}
export function formatClawHubSkillRef(ref: ClawHubSkillRef): string {
return ref.ownerHandle ? `@${ref.ownerHandle}/${ref.slug}` : ref.slug;
}
export function normalizeStoredRegistry(registry: string): string {
const trimmed = registry.trim();
return trimmed.replace(/\/+$/, "") || trimmed;
}
export function normalizeOptionalStringValue(raw: unknown): string | undefined {
return typeof raw === "string" && raw.trim() ? raw.trim() : undefined;
}
export function normalizeGitHubCommitSegment(raw: unknown): string | undefined {
const commit = normalizeOptionalStringValue(raw);
return commit && /^[0-9a-f]{40}$/i.test(commit) ? commit : undefined;
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
export function normalizeDownloadedArtifactLock(
raw: unknown,
): ClawHubSkillDownloadedArtifactLock | undefined {
if (!raw || typeof raw !== "object") {
return undefined;
}
const candidate = raw as Partial<ClawHubSkillDownloadedArtifactLock>;
if (
(candidate.kind === "archive" || candidate.kind === "clawpack") &&
isNonEmptyString(candidate.sha256) &&
isNonEmptyString(candidate.integrity)
) {
return { kind: candidate.kind, sha256: candidate.sha256, integrity: candidate.integrity };
}
return undefined;
}
export function normalizeSkillFileLock(raw: unknown): ClawHubSkillFileLock | undefined {
if (!raw || typeof raw !== "object") {
return undefined;
}
const candidate = raw as Partial<ClawHubSkillFileLock>;
return isNonEmptyString(candidate.path) && isNonEmptyString(candidate.sha256)
? { path: candidate.path, sha256: candidate.sha256 }
: undefined;
}
function normalizeClawHubSkillOrigin(
raw: Partial<ClawHubSkillOrigin> | null,
): ClawHubSkillOrigin | null {
if (
raw?.version !== 1 ||
!isNonEmptyString(raw.registry) ||
!isNonEmptyString(raw.slug) ||
!isNonEmptyString(raw.installedVersion) ||
typeof raw.installedAt !== "number"
) {
return null;
}
const sourceUrl = normalizeOptionalStringValue((raw as { sourceUrl?: unknown }).sourceUrl);
const ownerHandleRaw = normalizeOptionalStringValue(
(raw as { ownerHandle?: unknown }).ownerHandle,
);
let ownerHandle: string | undefined;
if (ownerHandleRaw) {
try {
ownerHandle = normalizeClawHubOwnerHandle(ownerHandleRaw);
} catch {
return null;
}
}
const requestedReferenceRaw = normalizeOptionalStringValue(
(raw as { requestedReference?: unknown }).requestedReference,
);
let requestedReference: string | undefined;
let trustState: ClawHubSkillsShTrustState | undefined;
if (requestedReferenceRaw) {
try {
const parsed = parseRequestedClawHubSkillRef(requestedReferenceRaw);
if (!parsed.requestedReference || parsed.slug !== raw.slug) {
return null;
}
requestedReference = parsed.requestedReference;
const rawTrustState = normalizeOptionalStringValue(
(raw as { trustState?: unknown }).trustState,
);
if (rawTrustState !== CLAWHUB_SKILLS_SH_TRUST_STATE) {
return null;
}
trustState = CLAWHUB_SKILLS_SH_TRUST_STATE;
} catch {
return null;
}
} else if ((raw as { trustState?: unknown }).trustState !== undefined) {
return null;
}
const artifact = normalizeDownloadedArtifactLock((raw as { artifact?: unknown }).artifact);
const skillFile = normalizeSkillFileLock((raw as { skillFile?: unknown }).skillFile);
const fileTreeSha256 = normalizeOptionalStringValue(
(raw as { fileTreeSha256?: unknown }).fileTreeSha256,
);
return {
version: 1,
registry: normalizeStoredRegistry(raw.registry),
slug: raw.slug,
...(ownerHandle ? { ownerHandle } : {}),
...(requestedReference ? { requestedReference } : {}),
...(trustState ? { trustState } : {}),
installedVersion: raw.installedVersion,
installedAt: raw.installedAt,
...(sourceUrl ? { sourceUrl } : {}),
...(artifact ? { artifact } : {}),
...(skillFile ? { skillFile } : {}),
...(fileTreeSha256 ? { fileTreeSha256 } : {}),
};
}
export async function readClawHubSkillsLockfile(
workspaceDir: string,
): Promise<ClawHubSkillsLockfile> {
for (const candidate of metadataPaths(workspaceDir, "lock.json")) {
try {
const raw = await tryReadJson<Partial<ClawHubSkillsLockfile>>(candidate);
if (raw?.version === 1 && raw.skills && typeof raw.skills === "object") {
return { version: 1, skills: raw.skills };
}
} catch {
// ignore
}
}
return { version: 1, skills: {} };
}
export async function writeClawHubSkillsLockfile(
workspaceDir: string,
lockfile: ClawHubSkillsLockfile,
): Promise<void> {
await writeJson(path.join(workspaceDir, DOT_DIR, "lock.json"), lockfile, {
trailingNewline: true,
});
}
function readJsonIfExistsSync(
candidate: string,
): { exists: false } | { exists: true; value: unknown } {
try {
return { exists: true, value: JSON.parse(fsSync.readFileSync(candidate, "utf8")) };
} catch (err) {
if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") {
return { exists: false };
}
throw err;
}
}
export function readClawHubSkillsLockfileStatusSync(
workspaceDir: string,
): ClawHubSkillsLockfileStatusRead {
for (const candidate of metadataPaths(workspaceDir, "lock.json")) {
try {
const read = readJsonIfExistsSync(candidate);
if (!read.exists) {
continue;
}
const raw = read.value as Partial<ClawHubSkillsLockfile>;
return raw?.version === 1 && raw.skills && typeof raw.skills === "object"
? { kind: "found", path: candidate, lock: { version: 1, skills: raw.skills } }
: {
kind: "malformed",
path: candidate,
error: "expected version 1 lockfile with skills",
};
} catch (err) {
return { kind: "malformed", path: candidate, error: formatErrorMessage(err) };
}
}
return { kind: "missing" };
}
function originResult(
raw: Partial<ClawHubSkillOrigin> | null,
candidate: string,
): StrictOriginReadResult {
const origin = normalizeClawHubSkillOrigin(raw);
return origin
? { kind: "found", origin, path: candidate }
: {
kind: "malformed",
path: candidate,
error: "expected version 1 origin with registry, slug, installedVersion, and installedAt",
};
}
export async function readClawHubSkillOrigin(skillDir: string): Promise<ClawHubSkillOrigin | null> {
for (const candidate of metadataPaths(skillDir, "origin.json")) {
try {
const origin = normalizeClawHubSkillOrigin(
await tryReadJson<Partial<ClawHubSkillOrigin>>(candidate),
);
if (origin) {
return origin;
}
} catch {
// ignore
}
}
return null;
}
export function readClawHubSkillOriginStatusSync(skillDir: string): StrictOriginReadResult {
for (const candidate of metadataPaths(skillDir, "origin.json")) {
try {
const read = readJsonIfExistsSync(candidate);
if (read.exists) {
return originResult(read.value as Partial<ClawHubSkillOrigin>, candidate);
}
} catch (err) {
return { kind: "malformed", path: candidate, error: formatErrorMessage(err) };
}
}
return { kind: "missing" };
}
export async function readClawHubSkillOriginStrict(
skillDir: string,
): Promise<StrictOriginReadResult> {
for (const candidate of metadataPaths(skillDir, "origin.json")) {
try {
const raw = await readJsonIfExists<Partial<ClawHubSkillOrigin>>(candidate);
if (raw) {
return originResult(raw, candidate);
}
} catch (err) {
return { kind: "malformed", path: candidate, error: formatErrorMessage(err) };
}
}
return { kind: "missing" };
}
export async function writeClawHubSkillOrigin(
skillDir: string,
origin: ClawHubSkillOrigin,
): Promise<void> {
await writeJson(path.join(skillDir, DOT_DIR, "origin.json"), origin, { trailingNewline: true });
}
export async function readTrackedClawHubSkillSlugs(workspaceDir: string): Promise<string[]> {
return Object.keys((await readClawHubSkillsLockfile(workspaceDir)).skills).toSorted();
}
export async function untrackClawHubSkill(
workspaceDir: string,
slug: string,
): Promise<() => Promise<void>> {
const trackedSlug = normalizeTrackedSkillSlug(slug);
const lock = await readClawHubSkillsLockfile(workspaceDir);
const previous = lock.skills[trackedSlug];
if (!previous) {
return async () => undefined;
}
delete lock.skills[trackedSlug];
await writeClawHubSkillsLockfile(workspaceDir, lock);
return async () => {
const current = await readClawHubSkillsLockfile(workspaceDir);
if (current.skills[trackedSlug]) {
throw new Error(`Skill ${JSON.stringify(trackedSlug)} was retracked during rollback.`);
}
current.skills[trackedSlug] = previous;
await writeClawHubSkillsLockfile(workspaceDir, current);
};
}

File diff suppressed because it is too large Load Diff