feat: install GitHub-backed ClawHub skills (#90478)

* feat: install GitHub-backed ClawHub skills

* fix: satisfy ClawHub install type checks

* fix: harden github-backed skill installs

* fix: keep heartbeat template non-actionable

* feat: support forcing pending ClawHub installs
This commit is contained in:
Patrick Erichsen
2026-06-04 19:10:02 -07:00
committed by GitHub
parent 5380d11977
commit 8f85f94946
14 changed files with 1029 additions and 179 deletions

View File

@@ -90,9 +90,9 @@ function readAuthProfileStoreText(agentDir) {
let db;
try {
db = new DatabaseSync(dbPath, { readOnly: true });
const row = db.prepare("SELECT store_json FROM auth_profile_store WHERE store_key = ?").get(
"primary",
);
const row = db
.prepare("SELECT store_json FROM auth_profile_store WHERE store_key = ?")
.get("primary");
return typeof row?.store_json === "string" ? row.store_json : "";
} finally {
db?.close();

View File

@@ -224,11 +224,14 @@ function handleParentSignal(signal) {
terminateChildGroup("SIGKILL");
rethrowParentSignal(signal);
}, timeoutKillGraceMs);
parentSignalPollTimer = setInterval(() => {
if (!childGroupExists()) {
rethrowParentSignal(signal);
}
}, Math.min(50, timeoutKillGraceMs));
parentSignalPollTimer = setInterval(
() => {
if (!childGroupExists()) {
rethrowParentSignal(signal);
}
},
Math.min(50, timeoutKillGraceMs),
);
}
for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) {

View File

@@ -125,9 +125,12 @@ function readBody(req) {
}
function requestBodyTooLargeError() {
return Object.assign(new Error(`ClickClack fixture request body exceeded ${requestMaxBytes} bytes`), {
code: "ETOOBIG",
});
return Object.assign(
new Error(`ClickClack fixture request body exceeded ${requestMaxBytes} bytes`),
{
code: "ETOOBIG",
},
);
}
function isRequestBodyTooLargeError(error) {

View File

@@ -7,14 +7,7 @@ import { chromium } from "playwright";
import { resolvePnpmRunner } from "./pnpm-runner.mjs";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const playwrightInstallArgs = [
"--dir",
"ui",
"exec",
"playwright",
"install",
"chromium",
];
const playwrightInstallArgs = ["--dir", "ui", "exec", "playwright", "install", "chromium"];
const playwrightInstallWithDepsArgs = [
"--dir",
"ui",
@@ -123,10 +116,7 @@ export function ensurePlaywrightChromium(options = {}) {
const systemExecutablePath =
options.systemExecutablePath ?? resolveSystemChromiumExecutablePath(existsSync, spawnSync);
if (
systemExecutablePath &&
canRunChromiumExecutable(systemExecutablePath, spawnSync)
) {
if (systemExecutablePath && canRunChromiumExecutable(systemExecutablePath, spawnSync)) {
log(`[ui-e2e] Using system Chromium at ${systemExecutablePath}.`);
return 0;
}
@@ -157,11 +147,13 @@ export function ensurePlaywrightChromium(options = {}) {
}
if (!existsSync(executablePath) || !canRunChromiumExecutable(executablePath, spawnSync)) {
if (shouldInstallPlaywrightSystemDependencies({
env,
getuid: options.getuid,
platform: options.platform,
})) {
if (
shouldInstallPlaywrightSystemDependencies({
env,
getuid: options.getuid,
platform: options.platform,
})
) {
log(
`[ui-e2e] Chromium is installed but still cannot start; installing Linux system dependencies.`,
);

View File

@@ -67,117 +67,121 @@ describe("gateway-hosted exec approvals", () => {
clearSessionStoreCacheForTest();
});
it("lets OpenClaw-style gateway tool calls request and wait for approval over separate connections", async () => {
const envSnapshot = captureEnv(TEST_ENV_KEYS);
cleanup.push(() => envSnapshot.restore());
it(
"lets OpenClaw-style gateway tool calls request and wait for approval over separate connections",
async () => {
const envSnapshot = captureEnv(TEST_ENV_KEYS);
cleanup.push(() => envSnapshot.restore());
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-exec-approval-e2e-"));
cleanup.push(() => fs.rm(tempHome, { recursive: true, force: true, maxRetries: 5 }));
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-exec-approval-e2e-"));
cleanup.push(() => fs.rm(tempHome, { recursive: true, force: true, maxRetries: 5 }));
const stateDir = path.join(tempHome, ".openclaw");
const workspaceDir = path.join(tempHome, "workspace");
await fs.mkdir(workspaceDir, { recursive: true });
const stateDir = path.join(tempHome, ".openclaw");
const workspaceDir = path.join(tempHome, "workspace");
await fs.mkdir(workspaceDir, { recursive: true });
const port = await getFreeGatewayPort();
const token = "exec-approval-e2e-token";
const configPath = path.join(stateDir, "openclaw.json");
await fs.mkdir(stateDir, { recursive: true });
await fs.writeFile(
configPath,
`${JSON.stringify(
{
gateway: {
port,
auth: { mode: "token", token },
},
tools: {
exec: {
host: "gateway",
security: "allowlist",
ask: "always",
const port = await getFreeGatewayPort();
const token = "exec-approval-e2e-token";
const configPath = path.join(stateDir, "openclaw.json");
await fs.mkdir(stateDir, { recursive: true });
await fs.writeFile(
configPath,
`${JSON.stringify(
{
gateway: {
port,
auth: { mode: "token", token },
},
tools: {
exec: {
host: "gateway",
security: "allowlist",
ask: "always",
},
},
},
null,
2,
)}\n`,
"utf8",
);
process.env.HOME = tempHome;
process.env.OPENCLAW_STATE_DIR = stateDir;
process.env.OPENCLAW_CONFIG_PATH = configPath;
process.env.OPENCLAW_GATEWAY_TOKEN = token;
process.env.OPENCLAW_GATEWAY_PORT = String(port);
process.env.OPENCLAW_SKIP_CHANNELS = "1";
process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1";
process.env.OPENCLAW_SKIP_CRON = "1";
process.env.OPENCLAW_SKIP_CANVAS_HOST = "1";
process.env.OPENCLAW_SKIP_BROWSER_CONTROL_SERVER = "1";
process.env.OPENCLAW_SKIP_PROVIDERS = "1";
process.env.OPENCLAW_TEST_MINIMAL_GATEWAY = "1";
clearRuntimeConfigSnapshot();
clearConfigCache();
clearSessionStoreCacheForTest();
const server = await startGatewayServer(port, {
bind: "loopback",
auth: { mode: "token", token },
controlUiEnabled: false,
deferStartupSidecars: true,
});
cleanup.push(() => server.close());
const operator = await connectGatewayClient({
url: `ws://127.0.0.1:${port}`,
token,
clientName: GATEWAY_CLIENT_NAMES.TEST,
clientDisplayName: "approval operator",
mode: GATEWAY_CLIENT_MODES.TEST,
scopes: [ADMIN_SCOPE],
requestTimeoutMs: GATEWAY_CONNECT_TIMEOUT_MS,
timeoutMs: GATEWAY_CONNECT_TIMEOUT_MS,
});
cleanup.push(() => disconnectGatewayClient(operator));
let resolveOutcome: (outcome: ExecApprovalFollowupOutcome) => void = () => {};
const outcomePromise = new Promise<ExecApprovalFollowupOutcome>((resolve) => {
resolveOutcome = resolve;
});
const tool = createExecTool({
host: "gateway",
security: "allowlist",
ask: "always",
cwd: workspaceDir,
approvalRunningNoticeMs: 0,
approvalFollowupMode: "direct",
approvalFollowup: ({ outcome }) => {
resolveOutcome(outcome);
return undefined;
},
null,
2,
)}\n`,
"utf8",
);
});
process.env.HOME = tempHome;
process.env.OPENCLAW_STATE_DIR = stateDir;
process.env.OPENCLAW_CONFIG_PATH = configPath;
process.env.OPENCLAW_GATEWAY_TOKEN = token;
process.env.OPENCLAW_GATEWAY_PORT = String(port);
process.env.OPENCLAW_SKIP_CHANNELS = "1";
process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1";
process.env.OPENCLAW_SKIP_CRON = "1";
process.env.OPENCLAW_SKIP_CANVAS_HOST = "1";
process.env.OPENCLAW_SKIP_BROWSER_CONTROL_SERVER = "1";
process.env.OPENCLAW_SKIP_PROVIDERS = "1";
process.env.OPENCLAW_TEST_MINIMAL_GATEWAY = "1";
clearRuntimeConfigSnapshot();
clearConfigCache();
clearSessionStoreCacheForTest();
const pending = await tool.execute("exec-approval-e2e", {
command: "printf 'smoke\\n'",
workdir: workspaceDir,
timeout: 5,
});
const server = await startGatewayServer(port, {
bind: "loopback",
auth: { mode: "token", token },
controlUiEnabled: false,
deferStartupSidecars: true,
});
cleanup.push(() => server.close());
expect(pending.details.status).toBe("approval-pending");
if (pending.details.status !== "approval-pending") {
throw new Error("expected approval-pending exec result");
}
const operator = await connectGatewayClient({
url: `ws://127.0.0.1:${port}`,
token,
clientName: GATEWAY_CLIENT_NAMES.TEST,
clientDisplayName: "approval operator",
mode: GATEWAY_CLIENT_MODES.TEST,
scopes: [ADMIN_SCOPE],
requestTimeoutMs: GATEWAY_CONNECT_TIMEOUT_MS,
timeoutMs: GATEWAY_CONNECT_TIMEOUT_MS,
});
cleanup.push(() => disconnectGatewayClient(operator));
await operator.request(
"exec.approval.resolve",
{ id: pending.details.approvalId, decision: "allow-once" },
{ timeoutMs: 10_000 },
);
let resolveOutcome: (outcome: ExecApprovalFollowupOutcome) => void = () => {};
const outcomePromise = new Promise<ExecApprovalFollowupOutcome>((resolve) => {
resolveOutcome = resolve;
});
const tool = createExecTool({
host: "gateway",
security: "allowlist",
ask: "always",
cwd: workspaceDir,
approvalRunningNoticeMs: 0,
approvalFollowupMode: "direct",
approvalFollowup: ({ outcome }) => {
resolveOutcome(outcome);
return undefined;
},
});
const pending = await tool.execute("exec-approval-e2e", {
command: "printf 'smoke\\n'",
workdir: workspaceDir,
timeout: 5,
});
expect(pending.details.status).toBe("approval-pending");
if (pending.details.status !== "approval-pending") {
throw new Error("expected approval-pending exec result");
}
await operator.request(
"exec.approval.resolve",
{ id: pending.details.approvalId, decision: "allow-once" },
{ timeoutMs: 10_000 },
);
const outcome = await withTimeout(outcomePromise, 15_000, "approved exec outcome");
expect(outcome.status).toBe("completed");
expect(outcome.exitCode).toBe(0);
expect(outcome.aggregated).toBe("smoke");
}, EXEC_APPROVAL_E2E_TIMEOUT_MS);
const outcome = await withTimeout(outcomePromise, 15_000, "approved exec outcome");
expect(outcome.status).toBe("completed");
expect(outcome.exitCode).toBe(0);
expect(outcome.aggregated).toBe("smoke");
},
EXEC_APPROVAL_E2E_TIMEOUT_MS,
);
});

View File

@@ -216,6 +216,16 @@ tasks:
expect(isHeartbeatContentEffectivelyEmpty("Reminder <!-- not scaffolding -->")).toBe(false);
});
it("returns true for HTML comments only", () => {
expect(isHeartbeatContentEffectivelyEmpty("<!-- runtime template note -->")).toBe(true);
expect(
isHeartbeatContentEffectivelyEmpty(`<!-- runtime template note -->
# HEARTBEAT.md
`),
).toBe(true);
});
it("returns false when a template includes plain instructional prose", () => {
const defaultTemplate = `# HEARTBEAT.md

View File

@@ -82,6 +82,10 @@ export function isHeartbeatContentEffectivelyEmpty(content: string | undefined |
if (!trimmed) {
continue;
}
// Skip single-line HTML comments used by the bundled runtime template.
if (/^<!--.*-->$/.test(trimmed)) {
continue;
}
// Skip markdown header lines (# followed by space or EOL, ## etc)
// This intentionally does NOT skip lines like "#TODO" or "#hashtag" which might be content
// (Those aren't valid markdown headers - ATX headers require space after #)

View File

@@ -0,0 +1,155 @@
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";
import os from "node:os";
import path from "node:path";
import JSZip from "jszip";
import { describe, expect, it } from "vitest";
async function readRequestBody(req: IncomingMessage): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks).toString("utf8");
}
async function spawnOpenClaw(
args: string[],
options: { cwd: string; env: NodeJS.ProcessEnv },
): Promise<{ status: number | null; stdout: string; stderr: string }> {
return await new Promise((resolve, reject) => {
const child = spawn(process.execPath, ["--import", "tsx", "src/entry.ts", ...args], {
cwd: options.cwd,
env: options.env,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
stdout += chunk;
});
child.stderr.on("data", (chunk: string) => {
stderr += chunk;
});
child.on("error", reject);
child.on("close", (status) => resolve({ status, stdout, stderr }));
});
}
async function buildGitHubSkillZip(): Promise<Buffer> {
const zip = new JSZip();
zip.file("skills-main/skills/aiq-deploy/SKILL.md", "# AIQ Deploy\n");
zip.file("skills-main/skills/aiq-deploy/skill-card.md", "# Card\n");
zip.file("skills-main/skills/other/SKILL.md", "# Other\n");
return await zip.generateAsync({ type: "nodebuffer" });
}
describe("openclaw skills install ClawHub GitHub-backed E2E", () => {
it("installs from the install resolver and reports install telemetry", async () => {
const commit = "c".repeat(40);
const telemetryBodies: unknown[] = [];
const requestLog: string[] = [];
const githubZipBytes = await buildGitHubSkillZip();
async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
const url = new URL(req.url ?? "/", "http://127.0.0.1");
requestLog.push(`${req.method ?? "GET"} ${url.pathname}`);
if (req.method === "GET" && url.pathname === "/api/v1/skills/aiq-deploy/install") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
ok: true,
slug: "aiq-deploy",
installKind: "github",
github: {
repo: "NVIDIA/skills",
path: "skills/aiq-deploy",
commit,
contentHash: "hash-aiq-deploy",
sourceUrl: `https://github.com/NVIDIA/skills/tree/${commit}/skills/aiq-deploy`,
},
}),
);
return;
}
if (req.method === "GET" && url.pathname === `/NVIDIA/skills/zip/${commit}`) {
res.writeHead(200, { "Content-Type": "application/zip" });
res.end(githubZipBytes);
return;
}
if (req.method === "POST" && url.pathname === "/api/cli/telemetry/install") {
telemetryBodies.push(JSON.parse(await readRequestBody(req)) as unknown);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true }));
return;
}
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
res.end("not found");
}
const server = createServer((req, res) => {
void handleRequest(req, res).catch((error: unknown) => {
res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
res.end(error instanceof Error ? error.message : String(error));
});
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", () => {
resolve();
});
});
const registry = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-clawhub-cli-e2e-"));
try {
const result = await spawnOpenClaw(["skills", "install", "aiq-deploy", "--global"], {
cwd: process.cwd(),
env: {
...process.env,
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"),
OPENCLAW_CLAWHUB_URL: registry,
OPENCLAW_CLAWHUB_TOKEN: "test-token",
OPENCLAW_CLAWHUB_GITHUB_CODELOAD_BASE_URL: registry,
CLAWHUB_DISABLE_TELEMETRY: "",
CLAWDHUB_DISABLE_TELEMETRY: "",
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
},
});
expect(result.status, result.stderr || result.stdout).toBe(0);
await expect(
fs.readFile(path.join(stateDir, "skills", "aiq-deploy", "SKILL.md"), "utf8"),
).resolves.toContain("# AIQ Deploy");
await expect(
fs.readFile(path.join(stateDir, "skills", "aiq-deploy", "skill-card.md"), "utf8"),
).resolves.toContain("# Card");
await expect(
fs.readFile(path.join(stateDir, "skills", "aiq-deploy", "other", "SKILL.md")),
).rejects.toThrow();
if (telemetryBodies.length !== 1) {
throw new Error(`Expected one install telemetry request, saw: ${requestLog.join(", ")}`);
}
expect(telemetryBodies[0]).toMatchObject({
roots: [
{
skills: [{ slug: "aiq-deploy", version: commit }],
},
],
});
} finally {
await new Promise<void>((resolve) => {
server.close(() => {
resolve();
});
});
await fs.rm(stateDir, { recursive: true, force: true });
}
}, 30_000);
});

View File

@@ -566,6 +566,25 @@ describe("skills cli commands", () => {
);
});
it("passes --force-install through for ClawHub skill installs", async () => {
installSkillFromClawHubMock.mockResolvedValue({
ok: true,
slug: "calendar",
version: "1.2.3",
targetDir: "/tmp/workspace/skills/calendar",
});
await runCommand(["skills", "install", "calendar", "--force-install"]);
expect(installSkillFromClawHubMock).toHaveBeenCalledWith(
expect.objectContaining({
workspaceDir: "/tmp/workspace",
slug: "calendar",
forceInstall: true,
}),
);
});
it("rejects using --global and --agent together for installs", async () => {
await expect(
runCommand(["skills", "install", "calendar", "--global", "--agent", "main"]),
@@ -613,6 +632,30 @@ describe("skills cli commands", () => {
expect(runtimeErrors).toStrictEqual([]);
});
it("passes --force-install through for ClawHub skill updates", async () => {
readTrackedClawHubSkillSlugsMock.mockResolvedValue(["calendar"]);
updateSkillsFromClawHubMock.mockResolvedValue([
{
ok: true,
slug: "calendar",
previousVersion: "1.2.2",
version: "1.2.3",
changed: true,
targetDir: "/tmp/workspace/skills/calendar",
},
]);
await runCommand(["skills", "update", "--all", "--force-install"]);
expect(updateSkillsFromClawHubMock).toHaveBeenCalledWith(
expect.objectContaining({
workspaceDir: "/tmp/workspace",
slug: undefined,
forceInstall: true,
}),
);
});
it("updates tracked ClawHub skills in the cwd-inferred agent workspace", async () => {
routeWorkspaceByAgent();
resolveAgentIdByWorkspacePathMock.mockReturnValue("writer");

View File

@@ -287,6 +287,11 @@ export function registerSkillsCli(program: Command) {
.argument("<slug>", "ClawHub skill slug, git:<repo>, or local skill directory")
.option("--version <version>", "Install a specific version")
.option("--force", "Overwrite an existing workspace skill", false)
.option(
"--force-install",
"Install a pending GitHub-backed skill before ClawHub scan completes",
false,
)
.option("--global", "Install into the shared managed skills directory", false)
.option("--agent <id>", "Target agent workspace (defaults to cwd-inferred, then default agent)")
.option("--as <slug>", "Install a git/local skill under this slug")
@@ -296,6 +301,7 @@ export function registerSkillsCli(program: Command) {
opts: {
version?: string;
force?: boolean;
forceInstall?: boolean;
global?: boolean;
agent?: string;
as?: string;
@@ -345,6 +351,7 @@ export function registerSkillsCli(program: Command) {
slug,
version: opts.version,
force: Boolean(opts.force),
...(opts.forceInstall ? { forceInstall: true } : {}),
logger: {
info: (message) => defaultRuntime.log(message),
},
@@ -367,12 +374,17 @@ export function registerSkillsCli(program: Command) {
.description("Update ClawHub-installed skills in the active or shared managed directory")
.argument("[slug]", "Single skill slug")
.option("--all", "Update all tracked ClawHub skills", false)
.option(
"--force-install",
"Install a pending GitHub-backed skill before ClawHub scan completes",
false,
)
.option("--global", "Update skills in the shared managed skills directory", false)
.option("--agent <id>", "Target agent workspace (defaults to cwd-inferred, then default agent)")
.action(
async (
slug: string | undefined,
opts: { all?: boolean; global?: boolean; agent?: string },
opts: { all?: boolean; forceInstall?: boolean; global?: boolean; agent?: string },
command: Command,
) => {
try {
@@ -398,6 +410,7 @@ export function registerSkillsCli(program: Command) {
const results = await updateSkillsFromClawHub({
workspaceDir,
slug,
...(opts.forceInstall ? { forceInstall: true } : {}),
logger: {
info: (message) => defaultRuntime.log(message),
},

View File

@@ -8,6 +8,7 @@ import { withTempDir } from "../test-helpers/temp-dir.js";
import {
downloadClawHubPackageArchive,
downloadClawHubSkillArchive,
downloadClawHubSkillArchiveUrl,
fetchClawHubSkillCard,
fetchClawHubSkillSecurityVerdicts,
fetchClawHubPackageArtifact,
@@ -855,4 +856,33 @@ describe("clawhub helpers", () => {
await expectPathMissing(archiveDir);
}
});
it("does not send ambient ClawHub auth tokens to off-registry resolver archive URLs", async () => {
process.env.OPENCLAW_CLAWHUB_TOKEN = "env-token-123";
let requestedUrl = "";
let requestedInit: RequestInit | undefined;
const archive = await downloadClawHubSkillArchiveUrl({
baseUrl: "https://clawhub.ai",
url: "https://codeload.github.com/NVIDIA/skills/zip/abcdef",
fetchImpl: async (input, init) => {
requestedUrl = input instanceof Request ? input.url : String(input);
requestedInit = init;
return new Response(new Uint8Array([7, 8, 9]), {
status: 200,
headers: { "content-type": "application/zip" },
});
},
});
try {
expect(requestedUrl).toBe("https://codeload.github.com/NVIDIA/skills/zip/abcdef");
expect(new Headers(requestedInit?.headers).get("Authorization")).toBeNull();
await expect(fs.readFile(archive.archivePath)).resolves.toEqual(Buffer.from([7, 8, 9]));
} finally {
const archiveDir = path.dirname(archive.archivePath);
await archive.cleanup();
await expectPathMissing(archiveDir);
}
});
});

View File

@@ -16,6 +16,7 @@ import { createTempDownloadTarget } from "./temp-download.js";
export { parseClawHubPluginSpec } from "./clawhub-spec.js";
const DEFAULT_CLAWHUB_URL = "https://clawhub.ai";
const DEFAULT_GITHUB_CODELOAD_URL = "https://codeload.github.com";
const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
const SKILL_CARD_MAX_BYTES = 256 * 1024;
@@ -306,6 +307,36 @@ export type ClawHubSkillDetail = {
} | null;
};
export type ClawHubSkillInstallResolutionResponse =
| {
ok: true;
slug: string;
installKind: "archive";
archive: {
version: string;
downloadUrl: string;
};
}
| {
ok: true;
slug: string;
installKind: "github";
github: {
repo: string;
path: string;
commit: string;
contentHash: string;
sourceUrl: string;
};
}
| {
ok: false;
slug: string;
reason: string;
message: string;
status: number;
};
export type ClawHubSkillVerificationDecision = "pass" | "fail" | (string & {});
export type ClawHubSkillVerificationResponse = {
@@ -389,6 +420,10 @@ export type ClawHubDownloadResult = {
cleanup: () => Promise<void>;
};
export type ClawHubInstallTelemetrySkill = {
version?: string | null;
};
type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
type ClawHubRequestParams = {
@@ -438,6 +473,14 @@ function normalizeBaseUrl(baseUrl?: string): string {
return value || DEFAULT_CLAWHUB_URL;
}
function normalizeGitHubCodeloadBaseUrl(): string {
const value =
normalizeOptionalString(process.env.OPENCLAW_CLAWHUB_GITHUB_CODELOAD_BASE_URL) ||
normalizeOptionalString(process.env.CLAWHUB_GITHUB_CODELOAD_BASE_URL) ||
DEFAULT_GITHUB_CODELOAD_URL;
return value.replace(/\/+$/, "") || DEFAULT_GITHUB_CODELOAD_URL;
}
function extractTokenFromClawHubConfig(value: unknown): string | undefined {
if (!value || typeof value !== "object") {
return undefined;
@@ -787,6 +830,17 @@ function buildVersionOrTagSearch(params: {
return tag ? { tag } : undefined;
}
function buildGitHubZipUrl(repo: string, commit: string): string {
const url = new URL(`${normalizeGitHubCodeloadBaseUrl()}/`);
const basePath = url.pathname.replace(/\/+$/, "");
const repoPath = repo
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/");
url.pathname = `${basePath}/${repoPath}/zip/${encodeURIComponent(commit)}`;
return url.toString();
}
function formatSha256Integrity(bytes: Uint8Array): string {
const digest = createHash("sha256").update(bytes).digest("base64");
return `sha256-${digest}`;
@@ -1006,6 +1060,35 @@ export async function fetchClawHubSkillDetail(params: {
});
}
export async function fetchClawHubSkillInstallResolution(params: {
slug: string;
baseUrl?: string;
token?: string;
timeoutMs?: number;
fetchImpl?: FetchLike;
forceInstall?: boolean;
}): Promise<ClawHubSkillInstallResolutionResponse> {
const { response, url, hasToken } = await clawhubRequest({
baseUrl: params.baseUrl,
path: `/api/v1/skills/${encodeURIComponent(params.slug)}/install`,
token: params.token,
timeoutMs: params.timeoutMs,
fetchImpl: params.fetchImpl,
search: {
forceInstall: params.forceInstall ? "1" : undefined,
},
});
const isStructuredBlock = [403, 409, 410, 423].includes(response.status);
if (!response.ok && !isStructuredBlock) {
throw await buildClawHubError(response, url, hasToken);
}
try {
return (await response.json()) as ClawHubSkillInstallResolutionResponse;
} catch (cause) {
throw new Error(`ClawHub ${url.pathname} returned malformed JSON`, { cause });
}
}
export async function fetchClawHubSkillVerification(params: {
slug: string;
version?: string;
@@ -1278,6 +1361,149 @@ export async function downloadClawHubSkillArchive(params: {
};
}
export async function downloadClawHubSkillArchiveUrl(params: {
url: string;
baseUrl?: string;
token?: string;
timeoutMs?: number;
fetchImpl?: FetchLike;
}): Promise<ClawHubDownloadResult> {
const explicitToken = normalizeOptionalString(params.token);
const requestUrl = new URL(params.url, `${normalizeBaseUrl(params.baseUrl)}/`);
const registryOrigin = new URL(`${normalizeBaseUrl(params.baseUrl)}/`).origin;
const skipAuth = explicitToken == null && requestUrl.origin !== registryOrigin;
const { response, url, hasToken } = await clawhubRequest({
baseUrl: params.baseUrl,
url: params.url,
token: explicitToken,
timeoutMs: params.timeoutMs,
fetchImpl: params.fetchImpl,
skipAuth,
});
if (!response.ok) {
throw await buildClawHubError(response, url, hasToken);
}
const bytes = await readClawHubResponseBytes({
response,
timeoutMs: params.timeoutMs,
resourceLabel: `skill archive download at ${url.pathname}`,
});
const sha256Hex = formatSha256Hex(bytes);
const target = await createTempDownloadTarget({
prefix: "openclaw-clawhub-skill",
fileName: "skill.zip",
tmpDir: os.tmpdir(),
});
await fs.writeFile(target.path, bytes);
return {
archivePath: target.path,
integrity: formatSha256Integrity(bytes),
sha256Hex,
artifact: "archive",
cleanup: target.cleanup,
};
}
export async function downloadClawHubGitHubSkillArchive(params: {
repo: string;
commit: string;
timeoutMs?: number;
fetchImpl?: FetchLike;
}): Promise<ClawHubDownloadResult> {
const downloadUrl = buildGitHubZipUrl(params.repo, params.commit);
const { response, url, hasToken } = await clawhubRequest({
url: downloadUrl,
skipAuth: true,
timeoutMs: params.timeoutMs,
fetchImpl: params.fetchImpl,
});
if (!response.ok) {
throw await buildClawHubError(response, url, hasToken);
}
const bytes = await readClawHubResponseBytes({
response,
timeoutMs: params.timeoutMs,
resourceLabel: `GitHub source archive for ${params.repo}@${params.commit}`,
});
const sha256Hex = formatSha256Hex(bytes);
const target = await createTempDownloadTarget({
prefix: "openclaw-clawhub-github-skill",
fileName: `${params.commit}.zip`,
tmpDir: os.tmpdir(),
});
await fs.writeFile(target.path, bytes);
return {
archivePath: target.path,
integrity: formatSha256Integrity(bytes),
sha256Hex,
artifact: "archive",
cleanup: target.cleanup,
};
}
export async function reportClawHubSkillInstallTelemetry(params: {
baseUrl?: string;
token?: string;
root: string;
skills: Record<string, ClawHubInstallTelemetrySkill>;
timeoutMs?: number;
fetchImpl?: FetchLike;
}): Promise<void> {
const token = normalizeOptionalString(params.token) ?? (await resolveClawHubAuthToken());
if (!token || isClawHubTelemetryDisabled()) {
return;
}
const skills = Object.entries(params.skills)
.map(([slug, entry]) => ({
slug,
version: entry.version ?? null,
}))
.filter((entry) => entry.slug.length > 0);
const { response, url, hasToken } = await clawhubRequest({
baseUrl: params.baseUrl,
path: "/api/cli/telemetry/install",
method: "POST",
token,
timeoutMs: params.timeoutMs,
fetchImpl: params.fetchImpl,
json: {
roots: [
{
rootId: createHash("sha256").update(path.resolve(params.root)).digest("hex"),
label: formatTelemetryRootLabel(params.root),
skills,
},
],
},
});
if (!response.ok) {
throw await buildClawHubError(response, url, hasToken);
}
}
function isClawHubTelemetryDisabled(): boolean {
const raw = process.env.CLAWHUB_DISABLE_TELEMETRY ?? process.env.CLAWDHUB_DISABLE_TELEMETRY;
if (!raw) {
return false;
}
return ["1", "true", "yes", "on"].includes(raw.trim().toLowerCase());
}
function formatTelemetryRootLabel(root: string): string {
const home = os.homedir();
const absolute = path.resolve(root);
if (absolute === home) {
return "~";
}
const normalized = absolute.replaceAll("\\", "/");
const normalizedHome = home.replaceAll("\\", "/");
const withinHome = normalized.startsWith(`${normalizedHome}/`);
const stripped = withinHome ? normalized.slice(normalizedHome.length + 1) : normalized;
const tail = stripped.split("/").filter(Boolean).slice(-2).join("/");
return withinHome ? `~/${tail}` : tail || absolute;
}
/** Resolves the preferred latest package version from detail metadata. */
export function resolveLatestVersionFromPackage(detail: ClawHubPackageDetail): string | null {
return detail.package?.latestVersion ?? detail.package?.tags?.latest ?? null;

View File

@@ -5,8 +5,12 @@ import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
const fetchClawHubSkillDetailMock = vi.fn();
const fetchClawHubSkillInstallResolutionMock = vi.fn();
const downloadClawHubSkillArchiveMock = vi.fn();
const downloadClawHubSkillArchiveUrlMock = vi.fn();
const downloadClawHubGitHubSkillArchiveMock = vi.fn();
const listClawHubSkillsMock = vi.fn();
const reportClawHubSkillInstallTelemetryMock = vi.fn();
const resolveClawHubBaseUrlMock = vi.fn(() => "https://clawhub.ai");
const isDefaultClawHubBaseUrlMock = vi.fn((baseUrl?: string) => !baseUrl);
const searchClawHubSkillsMock = vi.fn();
@@ -18,8 +22,12 @@ const pathExistsMock = vi.fn();
vi.mock("../../infra/clawhub.js", () => ({
fetchClawHubSkillDetail: fetchClawHubSkillDetailMock,
fetchClawHubSkillInstallResolution: fetchClawHubSkillInstallResolutionMock,
downloadClawHubSkillArchive: downloadClawHubSkillArchiveMock,
downloadClawHubSkillArchiveUrl: downloadClawHubSkillArchiveUrlMock,
downloadClawHubGitHubSkillArchive: downloadClawHubGitHubSkillArchiveMock,
listClawHubSkills: listClawHubSkillsMock,
reportClawHubSkillInstallTelemetry: reportClawHubSkillInstallTelemetryMock,
isDefaultClawHubBaseUrl: isDefaultClawHubBaseUrlMock,
resolveClawHubBaseUrl: resolveClawHubBaseUrlMock,
searchClawHubSkills: searchClawHubSkillsMock,
@@ -156,8 +164,12 @@ async function writeClawHubOriginFixture(params: {
describe("skills-clawhub", () => {
beforeEach(() => {
fetchClawHubSkillDetailMock.mockReset();
fetchClawHubSkillInstallResolutionMock.mockReset();
downloadClawHubSkillArchiveMock.mockReset();
downloadClawHubSkillArchiveUrlMock.mockReset();
downloadClawHubGitHubSkillArchiveMock.mockReset();
listClawHubSkillsMock.mockReset();
reportClawHubSkillInstallTelemetryMock.mockReset();
resolveClawHubBaseUrlMock.mockReset();
isDefaultClawHubBaseUrlMock.mockReset();
searchClawHubSkillsMock.mockReset();
@@ -184,11 +196,31 @@ describe("skills-clawhub", () => {
createdAt: 3,
},
});
fetchClawHubSkillInstallResolutionMock.mockResolvedValue({
ok: true,
slug: "agentreceipt",
installKind: "archive",
archive: {
version: "1.0.0",
downloadUrl: "https://clawhub.ai/api/v1/download?slug=agentreceipt&version=1.0.0",
},
});
downloadClawHubSkillArchiveMock.mockResolvedValue({
archivePath: "/tmp/agentreceipt.zip",
integrity: "sha256-test",
cleanup: archiveCleanupMock,
});
downloadClawHubSkillArchiveUrlMock.mockResolvedValue({
archivePath: "/tmp/agentreceipt.zip",
integrity: "sha256-test",
cleanup: archiveCleanupMock,
});
downloadClawHubGitHubSkillArchiveMock.mockResolvedValue({
archivePath: "/tmp/github-agentreceipt.zip",
integrity: "sha256-github-test",
cleanup: archiveCleanupMock,
});
reportClawHubSkillInstallTelemetryMock.mockResolvedValue(undefined);
archiveCleanupMock.mockResolvedValue(undefined);
searchClawHubSkillsMock.mockResolvedValue([]);
withExtractedArchiveRootMock.mockImplementation(async (params) => {
@@ -208,9 +240,12 @@ describe("skills-clawhub", () => {
slug: "agentreceipt",
});
expect(downloadClawHubSkillArchiveMock).toHaveBeenCalledWith({
expect(fetchClawHubSkillInstallResolutionMock).toHaveBeenCalledWith({
slug: "agentreceipt",
version: "1.0.0",
baseUrl: undefined,
});
expect(downloadClawHubSkillArchiveUrlMock).toHaveBeenCalledWith({
url: "https://clawhub.ai/api/v1/download?slug=agentreceipt&version=1.0.0",
baseUrl: undefined,
});
expectInstallPackageSourceDir("/tmp/extracted-skill");
@@ -224,6 +259,124 @@ describe("skills-clawhub", () => {
targetDir: "/tmp/workspace/skills/agentreceipt",
});
expect(archiveCleanupMock).toHaveBeenCalledTimes(1);
expect(reportClawHubSkillInstallTelemetryMock).toHaveBeenCalledWith({
baseUrl: undefined,
root: "/tmp/workspace",
skills: expect.objectContaining({
agentreceipt: {
version: "1.0.0",
installedAt: expect.any(Number),
registry: "https://clawhub.ai",
},
}),
});
});
it("installs GitHub-backed ClawHub skills from the pinned resolver source path", async () => {
const commit = "b".repeat(40);
fetchClawHubSkillInstallResolutionMock.mockResolvedValueOnce({
ok: true,
slug: "aiq-deploy",
installKind: "github",
github: {
repo: "NVIDIA/skills",
path: "skills/aiq-deploy",
commit,
contentHash: "hash-aiq-deploy",
sourceUrl: `https://github.com/NVIDIA/skills/tree/${commit}/skills/aiq-deploy`,
},
});
withExtractedArchiveRootMock.mockImplementationOnce(async (params) => {
expect(params.rootMarkers).toBeUndefined();
return await params.onExtracted("/tmp/extracted-github-repo");
});
installPackageDirMock.mockResolvedValueOnce({
ok: true,
targetDir: "/tmp/workspace/skills/aiq-deploy",
});
const result = await installSkillFromClawHub({
workspaceDir: "/tmp/workspace",
slug: "aiq-deploy",
});
expect(fetchClawHubSkillInstallResolutionMock).toHaveBeenCalledWith({
slug: "aiq-deploy",
baseUrl: undefined,
});
expect(downloadClawHubGitHubSkillArchiveMock).toHaveBeenCalledWith({
repo: "NVIDIA/skills",
commit,
});
expectInstallPackageSourceDir("/tmp/extracted-github-repo/skills/aiq-deploy");
expect(installPolicyInput()).toMatchObject({
origin: {
registry: "https://clawhub.ai",
repo: "NVIDIA/skills",
path: "skills/aiq-deploy",
commit,
},
source: { kind: "git", authority: "third-party", mutable: false, network: true },
});
expectInstalledSkill(result, {
slug: "aiq-deploy",
version: commit,
targetDir: "/tmp/workspace/skills/aiq-deploy",
});
});
it("passes forceInstall to the ClawHub install resolver", async () => {
const commit = "b".repeat(40);
fetchClawHubSkillInstallResolutionMock.mockResolvedValueOnce({
ok: true,
slug: "aiq-deploy",
installKind: "github",
github: {
repo: "NVIDIA/skills",
path: "skills/aiq-deploy",
commit,
contentHash: "hash-aiq-deploy",
sourceUrl: `https://github.com/NVIDIA/skills/tree/${commit}/skills/aiq-deploy`,
},
});
withExtractedArchiveRootMock.mockImplementationOnce(async (params) => {
return await params.onExtracted("/tmp/extracted-github-repo");
});
installPackageDirMock.mockResolvedValueOnce({
ok: true,
targetDir: "/tmp/workspace/skills/aiq-deploy",
});
const result = await installSkillFromClawHub({
workspaceDir: "/tmp/workspace",
slug: "aiq-deploy",
forceInstall: true,
});
expect(fetchClawHubSkillInstallResolutionMock).toHaveBeenCalledWith({
slug: "aiq-deploy",
baseUrl: undefined,
forceInstall: true,
});
expectInstalledSkill(result, {
slug: "aiq-deploy",
version: commit,
targetDir: "/tmp/workspace/skills/aiq-deploy",
});
});
it("keeps ClawHub install telemetry best-effort", async () => {
reportClawHubSkillInstallTelemetryMock.mockRejectedValueOnce(new Error("telemetry down"));
const result = await installSkillFromClawHub({
workspaceDir: "/tmp/workspace",
slug: "agentreceipt",
});
expectInstalledSkill(result, {
slug: "agentreceipt",
version: "1.0.0",
});
});
it("marks custom ClawHub skill registries as third-party install policy authority", async () => {
@@ -312,6 +465,15 @@ describe("skills-clawhub", () => {
it("updates all tracked legacy Unicode slugs in place", async () => {
const slug = "re\u0430ct";
const { workspaceDir } = await createLegacyTrackedSkillFixture(slug);
fetchClawHubSkillInstallResolutionMock.mockResolvedValueOnce({
ok: true,
slug,
installKind: "archive",
archive: {
version: "1.0.0",
downloadUrl: `https://legacy.clawhub.ai/api/v1/download?slug=${encodeURIComponent(slug)}&version=1.0.0`,
},
});
installPackageDirMock.mockResolvedValueOnce({
ok: true,
targetDir: path.join(workspaceDir, "skills", slug),
@@ -322,13 +484,12 @@ describe("skills-clawhub", () => {
workspaceDir,
});
expect(fetchClawHubSkillDetailMock).toHaveBeenCalledWith({
expect(fetchClawHubSkillInstallResolutionMock).toHaveBeenCalledWith({
slug,
baseUrl: "https://legacy.clawhub.ai",
});
expect(downloadClawHubSkillArchiveMock).toHaveBeenCalledWith({
slug,
version: "1.0.0",
expect(downloadClawHubSkillArchiveUrlMock).toHaveBeenCalledWith({
url: `https://legacy.clawhub.ai/api/v1/download?slug=${encodeURIComponent(slug)}&version=1.0.0`,
baseUrl: "https://legacy.clawhub.ai",
});
expectLegacyUpdateSuccess(results, workspaceDir, slug);
@@ -337,6 +498,40 @@ describe("skills-clawhub", () => {
}
});
it("passes forceInstall to resolver for tracked updates", async () => {
const slug = "agentreceipt";
const { workspaceDir } = await createLegacyTrackedSkillFixture(slug);
fetchClawHubSkillInstallResolutionMock.mockResolvedValueOnce({
ok: true,
slug,
installKind: "archive",
archive: {
version: "1.0.0",
downloadUrl: `https://legacy.clawhub.ai/api/v1/download?slug=${encodeURIComponent(slug)}&version=1.0.0`,
},
});
installPackageDirMock.mockResolvedValueOnce({
ok: true,
targetDir: path.join(workspaceDir, "skills", slug),
});
try {
const results = await updateSkillsFromClawHub({
workspaceDir,
forceInstall: true,
});
expect(fetchClawHubSkillInstallResolutionMock).toHaveBeenCalledWith({
slug,
baseUrl: "https://legacy.clawhub.ai",
forceInstall: true,
});
expectLegacyUpdateSuccess(results, workspaceDir, slug);
} finally {
await fs.rm(workspaceDir, { recursive: true, force: true });
}
});
it("updates a legacy Unicode slug when requested explicitly", async () => {
const slug = "re\u0430ct";
const { workspaceDir } = await createLegacyTrackedSkillFixture(slug);

View File

@@ -3,12 +3,17 @@ import fsSync from "node:fs";
import path from "node:path";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
downloadClawHubGitHubSkillArchive,
downloadClawHubSkillArchive,
downloadClawHubSkillArchiveUrl,
fetchClawHubSkillDetail,
fetchClawHubSkillInstallResolution,
isDefaultClawHubBaseUrl,
reportClawHubSkillInstallTelemetry,
resolveClawHubBaseUrl,
searchClawHubSkills,
type ClawHubSkillDetail,
type ClawHubSkillInstallResolutionResponse,
type ClawHubSkillSearchResult,
} from "../../infra/clawhub.js";
import { formatErrorMessage } from "../../infra/errors.js";
@@ -93,7 +98,7 @@ export type InstallClawHubSkillResult =
slug: string;
version: string;
targetDir: string;
detail: ClawHubSkillDetail;
detail?: ClawHubSkillDetail;
}
| { ok: false; error: string };
@@ -132,6 +137,7 @@ type ClawHubInstallParams = {
version?: string;
baseUrl?: string;
force?: boolean;
forceInstall?: boolean;
logger?: Logger;
config?: OpenClawConfig;
};
@@ -756,15 +762,125 @@ async function resolveInstallVersion(params: {
};
}
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 resolveGitHubSkillSourceDir(repoRoot: string, sourcePath: string): string {
const normalized = normalizeGitHubSourcePath(sourcePath);
return path.join(repoRoot, ...normalized.split("/"));
}
async function installArchiveResolution(params: {
workspaceDir: string;
slug: string;
version: string;
archivePath: string;
registry: string;
authority: "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,
version: params.version,
},
source: {
kind: "clawhub",
authority: params.authority,
mutable: false,
network: true,
},
requestedSpecifier: `clawhub:${params.slug}@${params.version}`,
},
rootMarkers: CLAWHUB_SKILL_ARCHIVE_ROOT_MARKERS,
}),
});
}
async function installGitHubResolution(params: {
workspaceDir: string;
slug: string;
sourcePath: string;
archivePath: string;
registry: string;
repo: string;
commit: string;
force?: boolean;
logger?: Logger;
config?: OpenClawConfig;
}) {
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,
version: params.commit,
repo: params.repo,
path: params.sourcePath,
commit: params.commit,
},
source: {
kind: "git",
authority: "third-party",
mutable: false,
network: true,
},
requestedSpecifier: `clawhub:${params.slug}@${params.commit}`,
},
rootMarkers: CLAWHUB_SKILL_ARCHIVE_ROOT_MARKERS,
}),
});
}
function assertInstallResolutionAllowed(
resolution: ClawHubSkillInstallResolutionResponse,
): Extract<ClawHubSkillInstallResolutionResponse, { ok: true }> {
if (resolution.ok) {
return resolution;
}
throw new Error(resolution.message || `Skill "${resolution.slug}" is not installable.`);
}
async function performClawHubSkillInstall(
params: ClawHubInstallParams,
): Promise<InstallClawHubSkillResult> {
try {
const { detail, version } = await resolveInstallVersion({
slug: params.slug,
version: params.version,
baseUrl: params.baseUrl,
});
const targetDir = resolveWorkspaceSkillInstallDir(params.workspaceDir, params.slug);
const registry = resolveClawHubBaseUrl(params.baseUrl);
const clawhubAuthority = isDefaultClawHubBaseUrl(params.baseUrl) ? "openclaw" : "third-party";
@@ -775,45 +891,93 @@ async function performClawHubSkillInstall(
};
}
params.logger?.info?.(`Downloading ${params.slug}@${version} from ClawHub…`);
const archive = await downloadClawHubSkillArchive({
slug: params.slug,
version,
baseUrl: params.baseUrl,
});
try {
const install = await withExtractedArchiveRoot({
archivePath: archive.archivePath,
tempDirPrefix: "openclaw-skill-clawhub-",
timeoutMs: 120_000,
rootMarkers: CLAWHUB_SKILL_ARCHIVE_ROOT_MARKERS,
onExtracted: async (rootDir) =>
await installExtractedSkillRoot({
workspaceDir: params.workspaceDir,
let version!: string;
let detail: ClawHubSkillDetail | undefined;
let latestResolution: Extract<ClawHubSkillInstallResolutionResponse, { ok: true }> | undefined;
let install: Awaited<ReturnType<typeof installArchiveResolution>>;
const archive = params.version
? await (async () => {
const resolved = await resolveInstallVersion({
slug: params.slug,
extractedRoot: rootDir,
mode: params.force ? "update" : "install",
logger: params.logger,
policy: {
config: params.config,
installId: "clawhub",
origin: {
type: "clawhub",
version: params.version,
baseUrl: params.baseUrl,
});
detail = resolved.detail;
version = resolved.version;
params.logger?.info?.(`Downloading ${params.slug}@${version} from ClawHub`);
return await downloadClawHubSkillArchive({
slug: params.slug,
version,
baseUrl: params.baseUrl,
});
})()
: await (async () => {
latestResolution = assertInstallResolutionAllowed(
await fetchClawHubSkillInstallResolution({
slug: params.slug,
baseUrl: params.baseUrl,
forceInstall: params.forceInstall,
}),
);
if (latestResolution.installKind === "github") {
version = latestResolution.github.commit;
params.logger?.info?.(`Downloading ${params.slug}@${version} from GitHub…`);
return await downloadClawHubGitHubSkillArchive({
repo: latestResolution.github.repo,
commit: latestResolution.github.commit,
});
}
version = latestResolution.archive.version;
params.logger?.info?.(`Downloading ${params.slug}@${version} from ClawHub…`);
return await downloadClawHubSkillArchiveUrl({
url: latestResolution.archive.downloadUrl,
baseUrl: params.baseUrl,
});
})();
try {
if (!params.version) {
if (!latestResolution) {
throw new Error(`Skill "${params.slug}" has no install resolution.`);
}
install =
latestResolution.installKind === "github"
? await installGitHubResolution({
workspaceDir: params.workspaceDir,
slug: params.slug,
sourcePath: latestResolution.github.path,
archivePath: archive.archivePath,
registry,
repo: latestResolution.github.repo,
commit: latestResolution.github.commit,
force: params.force,
logger: params.logger,
config: params.config,
})
: await installArchiveResolution({
workspaceDir: params.workspaceDir,
slug: params.slug,
version,
},
source: {
kind: "clawhub",
archivePath: archive.archivePath,
registry,
authority: clawhubAuthority,
mutable: false,
network: true,
},
requestedSpecifier: `clawhub:${params.slug}@${version}`,
},
rootMarkers: CLAWHUB_SKILL_ARCHIVE_ROOT_MARKERS,
}),
});
force: params.force,
logger: params.logger,
config: params.config,
});
} else {
install = await installArchiveResolution({
workspaceDir: params.workspaceDir,
slug: params.slug,
version,
archivePath: archive.archivePath,
registry,
authority: clawhubAuthority,
force: params.force,
logger: params.logger,
config: params.config,
});
}
if (!install.ok) {
return { ok: false, error: install.error };
}
@@ -833,13 +997,18 @@ async function performClawHubSkillInstall(
registry: resolveClawHubBaseUrl(params.baseUrl),
};
await writeClawHubSkillsLockfile(params.workspaceDir, lock);
await reportClawHubSkillInstallTelemetry({
baseUrl: params.baseUrl,
root: params.workspaceDir,
skills: lock.skills,
}).catch(() => undefined);
return {
ok: true,
slug: params.slug,
version,
targetDir: install.targetDir,
detail,
...(detail ? { detail } : {}),
};
} finally {
await archive.cleanup().catch(() => undefined);
@@ -913,6 +1082,7 @@ export async function installSkillFromClawHub(params: {
version?: string;
baseUrl?: string;
force?: boolean;
forceInstall?: boolean;
logger?: Logger;
config?: OpenClawConfig;
}): Promise<InstallClawHubSkillResult> {
@@ -923,6 +1093,7 @@ export async function updateSkillsFromClawHub(params: {
workspaceDir: string;
slug?: string;
baseUrl?: string;
forceInstall?: boolean;
logger?: Logger;
config?: OpenClawConfig;
}): Promise<UpdateClawHubSkillResult[]> {
@@ -956,6 +1127,7 @@ export async function updateSkillsFromClawHub(params: {
slug: tracked.slug,
baseUrl: tracked.baseUrl,
force: true,
forceInstall: params.forceInstall,
logger: params.logger,
config: params.config,
});