mirror of https://github.com/openclaw/openclaw.git
387 lines
11 KiB
TypeScript
387 lines
11 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileExists } from "../infra/archive.js";
|
|
import {
|
|
downloadClawHubSkillArchive,
|
|
fetchClawHubSkillDetail,
|
|
resolveClawHubBaseUrl,
|
|
searchClawHubSkills,
|
|
type ClawHubSkillDetail,
|
|
type ClawHubSkillSearchResult,
|
|
} from "../infra/clawhub.js";
|
|
import { withExtractedArchiveRoot } from "../infra/install-flow.js";
|
|
import { installPackageDir } from "../infra/install-package-dir.js";
|
|
import { resolveSafeInstallDir } from "../infra/install-safe-path.js";
|
|
|
|
const DOT_DIR = ".clawhub";
|
|
const LEGACY_DOT_DIR = ".clawdhub";
|
|
const SKILL_ORIGIN_RELATIVE_PATH = path.join(DOT_DIR, "origin.json");
|
|
|
|
export type ClawHubSkillOrigin = {
|
|
version: 1;
|
|
registry: string;
|
|
slug: string;
|
|
installedVersion: string;
|
|
installedAt: number;
|
|
};
|
|
|
|
export type ClawHubSkillsLockfile = {
|
|
version: 1;
|
|
skills: Record<
|
|
string,
|
|
{
|
|
version: string;
|
|
installedAt: number;
|
|
}
|
|
>;
|
|
};
|
|
|
|
export type InstallClawHubSkillResult =
|
|
| {
|
|
ok: true;
|
|
slug: string;
|
|
version: string;
|
|
targetDir: string;
|
|
detail: ClawHubSkillDetail;
|
|
}
|
|
| { ok: false; error: string };
|
|
|
|
export type UpdateClawHubSkillResult =
|
|
| {
|
|
ok: true;
|
|
slug: string;
|
|
previousVersion: string | null;
|
|
version: string;
|
|
changed: boolean;
|
|
targetDir: string;
|
|
}
|
|
| { ok: false; error: string };
|
|
|
|
type Logger = {
|
|
info?: (message: string) => void;
|
|
};
|
|
|
|
const VALID_SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
|
|
function normalizeSlug(raw: string): string {
|
|
const slug = raw.trim().toLowerCase();
|
|
if (!slug || !VALID_SLUG_PATTERN.test(slug)) {
|
|
throw new Error(`Invalid skill slug: ${raw}`);
|
|
}
|
|
return slug;
|
|
}
|
|
|
|
function resolveSkillInstallDir(workspaceDir: string, slug: string): string {
|
|
const skillsDir = path.join(path.resolve(workspaceDir), "skills");
|
|
const target = resolveSafeInstallDir({
|
|
baseDir: skillsDir,
|
|
id: slug,
|
|
invalidNameMessage: "invalid skill target path",
|
|
});
|
|
if (!target.ok) {
|
|
throw new Error(target.error);
|
|
}
|
|
return target.path;
|
|
}
|
|
|
|
async function ensureSkillRoot(rootDir: string): Promise<void> {
|
|
for (const candidate of ["SKILL.md", "skill.md", "skills.md", "SKILL.MD"]) {
|
|
if (await fileExists(path.join(rootDir, candidate))) {
|
|
return;
|
|
}
|
|
}
|
|
throw new Error("downloaded archive is missing SKILL.md");
|
|
}
|
|
|
|
export async function readClawHubSkillsLockfile(
|
|
workspaceDir: string,
|
|
): Promise<ClawHubSkillsLockfile> {
|
|
const candidates = [
|
|
path.join(workspaceDir, DOT_DIR, "lock.json"),
|
|
path.join(workspaceDir, LEGACY_DOT_DIR, "lock.json"),
|
|
];
|
|
for (const candidate of candidates) {
|
|
try {
|
|
const raw = JSON.parse(
|
|
await fs.readFile(candidate, "utf8"),
|
|
) as Partial<ClawHubSkillsLockfile>;
|
|
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> {
|
|
const targetPath = path.join(workspaceDir, DOT_DIR, "lock.json");
|
|
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
await fs.writeFile(targetPath, `${JSON.stringify(lockfile, null, 2)}\n`, "utf8");
|
|
}
|
|
|
|
export async function readClawHubSkillOrigin(skillDir: string): Promise<ClawHubSkillOrigin | null> {
|
|
const candidates = [
|
|
path.join(skillDir, DOT_DIR, "origin.json"),
|
|
path.join(skillDir, LEGACY_DOT_DIR, "origin.json"),
|
|
];
|
|
for (const candidate of candidates) {
|
|
try {
|
|
const raw = JSON.parse(await fs.readFile(candidate, "utf8")) as Partial<ClawHubSkillOrigin>;
|
|
if (
|
|
raw.version === 1 &&
|
|
typeof raw.registry === "string" &&
|
|
typeof raw.slug === "string" &&
|
|
typeof raw.installedVersion === "string" &&
|
|
typeof raw.installedAt === "number"
|
|
) {
|
|
return raw as ClawHubSkillOrigin;
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export async function writeClawHubSkillOrigin(
|
|
skillDir: string,
|
|
origin: ClawHubSkillOrigin,
|
|
): Promise<void> {
|
|
const targetPath = path.join(skillDir, SKILL_ORIGIN_RELATIVE_PATH);
|
|
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
await fs.writeFile(targetPath, `${JSON.stringify(origin, null, 2)}\n`, "utf8");
|
|
}
|
|
|
|
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,
|
|
});
|
|
}
|
|
|
|
async function resolveInstallVersion(params: {
|
|
slug: string;
|
|
version?: string;
|
|
baseUrl?: string;
|
|
}): Promise<{ detail: ClawHubSkillDetail; version: string }> {
|
|
const detail = await fetchClawHubSkillDetail({
|
|
slug: params.slug,
|
|
baseUrl: params.baseUrl,
|
|
});
|
|
if (!detail.skill) {
|
|
throw new Error(`Skill "${params.slug}" not found on ClawHub.`);
|
|
}
|
|
const resolvedVersion = params.version ?? detail.latestVersion?.version;
|
|
if (!resolvedVersion) {
|
|
throw new Error(`Skill "${params.slug}" has no installable version.`);
|
|
}
|
|
return {
|
|
detail,
|
|
version: resolvedVersion,
|
|
};
|
|
}
|
|
|
|
async function installExtractedSkill(params: {
|
|
workspaceDir: string;
|
|
slug: string;
|
|
extractedRoot: string;
|
|
mode: "install" | "update";
|
|
logger?: Logger;
|
|
}): Promise<{ ok: true; targetDir: string } | { ok: false; error: string }> {
|
|
await ensureSkillRoot(params.extractedRoot);
|
|
const targetDir = resolveSkillInstallDir(params.workspaceDir, params.slug);
|
|
const install = await installPackageDir({
|
|
sourceDir: params.extractedRoot,
|
|
targetDir,
|
|
mode: params.mode,
|
|
timeoutMs: 120_000,
|
|
logger: params.logger,
|
|
copyErrorPrefix: "failed to install skill",
|
|
hasDeps: false,
|
|
depsLogMessage: "",
|
|
});
|
|
if (!install.ok) {
|
|
return install;
|
|
}
|
|
return { ok: true, targetDir };
|
|
}
|
|
|
|
export async function installSkillFromClawHub(params: {
|
|
workspaceDir: string;
|
|
slug: string;
|
|
version?: string;
|
|
baseUrl?: string;
|
|
force?: boolean;
|
|
logger?: Logger;
|
|
}): Promise<InstallClawHubSkillResult> {
|
|
try {
|
|
const slug = normalizeSlug(params.slug);
|
|
const { detail, version } = await resolveInstallVersion({
|
|
slug,
|
|
version: params.version,
|
|
baseUrl: params.baseUrl,
|
|
});
|
|
const targetDir = resolveSkillInstallDir(params.workspaceDir, slug);
|
|
if (!params.force && (await fileExists(targetDir))) {
|
|
return {
|
|
ok: false,
|
|
error: `Skill already exists at ${targetDir}. Re-run with force/update.`,
|
|
};
|
|
}
|
|
|
|
params.logger?.info?.(`Downloading ${slug}@${version} from ClawHub…`);
|
|
const archive = await downloadClawHubSkillArchive({
|
|
slug,
|
|
version,
|
|
baseUrl: params.baseUrl,
|
|
});
|
|
try {
|
|
const install = await withExtractedArchiveRoot({
|
|
archivePath: archive.archivePath,
|
|
tempDirPrefix: "openclaw-skill-clawhub-",
|
|
timeoutMs: 120_000,
|
|
rootMarkers: ["SKILL.md"],
|
|
onExtracted: async (rootDir) =>
|
|
await installExtractedSkill({
|
|
workspaceDir: params.workspaceDir,
|
|
slug,
|
|
extractedRoot: rootDir,
|
|
mode: params.force ? "update" : "install",
|
|
logger: params.logger,
|
|
}),
|
|
});
|
|
if (!install.ok) {
|
|
return install;
|
|
}
|
|
|
|
const installedAt = Date.now();
|
|
await writeClawHubSkillOrigin(install.targetDir, {
|
|
version: 1,
|
|
registry: resolveClawHubBaseUrl(params.baseUrl),
|
|
slug,
|
|
installedVersion: version,
|
|
installedAt,
|
|
});
|
|
const lock = await readClawHubSkillsLockfile(params.workspaceDir);
|
|
lock.skills[slug] = {
|
|
version,
|
|
installedAt,
|
|
};
|
|
await writeClawHubSkillsLockfile(params.workspaceDir, lock);
|
|
|
|
return {
|
|
ok: true,
|
|
slug,
|
|
version,
|
|
targetDir: install.targetDir,
|
|
detail,
|
|
};
|
|
} finally {
|
|
await fs.rm(archive.archivePath, { force: true }).catch(() => undefined);
|
|
await fs
|
|
.rm(path.dirname(archive.archivePath), { recursive: true, force: true })
|
|
.catch(() => undefined);
|
|
}
|
|
} catch (err) {
|
|
return {
|
|
ok: false,
|
|
error: err instanceof Error ? err.message : String(err),
|
|
};
|
|
}
|
|
}
|
|
|
|
export async function updateSkillsFromClawHub(params: {
|
|
workspaceDir: string;
|
|
slug?: string;
|
|
baseUrl?: string;
|
|
logger?: Logger;
|
|
}): Promise<UpdateClawHubSkillResult[]> {
|
|
const lock = await readClawHubSkillsLockfile(params.workspaceDir);
|
|
const slugs = params.slug ? [normalizeSlug(params.slug)] : Object.keys(lock.skills);
|
|
const results: UpdateClawHubSkillResult[] = [];
|
|
for (const slug of slugs) {
|
|
const targetDir = resolveSkillInstallDir(params.workspaceDir, slug);
|
|
const origin = (await readClawHubSkillOrigin(targetDir)) ?? null;
|
|
const baseUrl = origin?.registry ?? params.baseUrl;
|
|
if (!origin && !lock.skills[slug]) {
|
|
results.push({
|
|
ok: false,
|
|
error: `Skill "${slug}" is not tracked as a ClawHub install.`,
|
|
});
|
|
continue;
|
|
}
|
|
const previousVersion = origin?.installedVersion ?? lock.skills[slug]?.version ?? null;
|
|
const install = await installSkillFromClawHub({
|
|
workspaceDir: params.workspaceDir,
|
|
slug,
|
|
baseUrl,
|
|
force: true,
|
|
logger: params.logger,
|
|
});
|
|
if (!install.ok) {
|
|
results.push(install);
|
|
continue;
|
|
}
|
|
results.push({
|
|
ok: true,
|
|
slug,
|
|
previousVersion,
|
|
version: install.version,
|
|
changed: previousVersion !== install.version,
|
|
targetDir: install.targetDir,
|
|
});
|
|
}
|
|
return results;
|
|
}
|
|
|
|
export async function readTrackedClawHubSkillSlugs(workspaceDir: string): Promise<string[]> {
|
|
const lock = await readClawHubSkillsLockfile(workspaceDir);
|
|
return Object.keys(lock.skills).toSorted();
|
|
}
|
|
|
|
export async function computeSkillFingerprint(skillDir: string): Promise<string> {
|
|
const digest = createHash("sha256");
|
|
const queue = [skillDir];
|
|
while (queue.length > 0) {
|
|
const current = queue.shift();
|
|
if (!current) {
|
|
continue;
|
|
}
|
|
const entries = await fs.readdir(current, { withFileTypes: true });
|
|
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
for (const entry of entries) {
|
|
if (entry.name.startsWith(".")) {
|
|
continue;
|
|
}
|
|
const fullPath = path.join(current, entry.name);
|
|
if (entry.isDirectory()) {
|
|
queue.push(fullPath);
|
|
continue;
|
|
}
|
|
if (!entry.isFile()) {
|
|
continue;
|
|
}
|
|
const relPath = path.relative(skillDir, fullPath).split(path.sep).join("/");
|
|
digest.update(relPath);
|
|
digest.update("\n");
|
|
digest.update(await fs.readFile(fullPath));
|
|
digest.update("\n");
|
|
}
|
|
}
|
|
return digest.digest("hex");
|
|
}
|