feat: add native clawhub install flows

This commit is contained in:
Peter Steinberger 2026-03-22 17:03:32 +00:00
parent c7788773bf
commit 91b2800241
25 changed files with 2471 additions and 208 deletions

View File

@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
### Changes
- ClawHub/install: add native `openclaw skills search|install|update` flows plus `openclaw plugins install clawhub:<package>` with tracked update metadata, gateway skill-install/update support for ClawHub-backed requests, and regression coverage/docs for the new source path.
- Models/Anthropic Vertex: add core `anthropic-vertex` provider support for Claude via Google Vertex AI, including GCP auth/discovery and main run-path routing. (#43356) Thanks @sallyom and @yossiovadia.
- Commands/btw: add `/btw` side questions for quick tool-less answers about the current session without changing future session context, with dismissible in-session TUI answers and explicit BTW replies on external channels. (#45444) Thanks @ngutman.
- Gateway/docs: clarify that empty URL input allowlists are treated as unset, document `allowUrl: false` as the deny-all switch, and add regression coverage for the normalization path.

View File

@ -485,6 +485,9 @@ List and inspect available skills plus readiness info.
Subcommands:
- `skills search [query...]`: search ClawHub skills.
- `skills install <slug>`: install a skill from ClawHub into the active workspace.
- `skills update <slug|--all>`: update tracked ClawHub skills.
- `skills list`: list skills (default when no subcommand).
- `skills info <name>`: show details for one skill.
- `skills check`: summary of ready vs missing requirements.
@ -495,7 +498,7 @@ Options:
- `--json`: output JSON (no styling).
- `-v`, `--verbose`: include missing requirements detail.
Tip: use `npx clawhub` to search, install, and sync skills.
Tip: use `openclaw skills search`, `openclaw skills install`, and `openclaw skills update` for ClawHub-backed skills.
### `pairing`

View File

@ -48,6 +48,7 @@ capabilities.
```bash
openclaw plugins install <path-or-spec>
openclaw plugins install <npm-spec> --pin
openclaw plugins install clawhub:<package>
openclaw plugins install <plugin>@<marketplace>
openclaw plugins install <plugin> --marketplace <marketplace>
```
@ -71,6 +72,18 @@ Supported archives: `.zip`, `.tgz`, `.tar.gz`, `.tar`.
Claude marketplace installs are also supported.
ClawHub installs use an explicit `clawhub:<package>` locator:
```bash
openclaw plugins install clawhub:openclaw-codex-app-server
openclaw plugins install clawhub:openclaw-codex-app-server@1.2.3
```
OpenClaw downloads the package archive from ClawHub, checks the advertised
plugin API / minimum gateway compatibility, then installs it through the normal
archive path. Recorded installs keep their ClawHub source metadata for later
updates.
Use `plugin@marketplace` shorthand when the marketplace name exists in Claude's
local registry cache at `~/.claude/plugins/known_marketplaces.json`:
@ -144,8 +157,8 @@ openclaw plugins update <id-or-npm-spec> --dry-run
openclaw plugins update @openclaw/voice-call@beta
```
Updates apply to tracked installs in `plugins.installs`, currently npm and
marketplace installs.
Updates apply to tracked installs in `plugins.installs`, including npm,
ClawHub, and marketplace installs.
When you pass a plugin id, OpenClaw reuses the recorded install spec for that
plugin. That means previously stored dist-tags such as `@beta` and exact pinned

View File

@ -1,14 +1,15 @@
---
summary: "CLI reference for `openclaw skills` (list/info/check) and skill eligibility"
summary: "CLI reference for `openclaw skills` (search/install/update/list/info/check)"
read_when:
- You want to see which skills are available and ready to run
- You want to search, install, or update skills from ClawHub
- You want to debug missing binaries/env/config for skills
title: "skills"
---
# `openclaw skills`
Inspect skills (bundled + workspace + managed overrides) and see whats eligible vs missing requirements.
Inspect local skills and install/update skills from ClawHub.
Related:
@ -19,8 +20,17 @@ Related:
## Commands
```bash
openclaw skills search "calendar"
openclaw skills install <slug>
openclaw skills install <slug> --version <version>
openclaw skills update <slug>
openclaw skills update --all
openclaw skills list
openclaw skills list --eligible
openclaw skills info <name>
openclaw skills check
```
`search`/`install`/`update` use ClawHub directly and install into the active
workspace `skills/` directory. `list`/`info`/`check` still inspect the local
skills visible to the current workspace and config.

View File

@ -992,18 +992,16 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
</Accordion>
<Accordion title="How do I install skills on Linux?">
Use **ClawHub** (CLI) or drop skills into your workspace. The macOS Skills UI isn't available on Linux.
Use native `openclaw skills` commands or drop skills into your workspace. The macOS Skills UI isn't available on Linux.
Browse skills at [https://clawhub.com](https://clawhub.com).
Install the ClawHub CLI (pick one package manager):
```bash
npm i -g clawhub
openclaw skills search "calendar"
openclaw skills install <skill-slug>
openclaw skills update --all
```
```bash
pnpm add -g clawhub
```
Install the separate `clawhub` CLI only if you want to publish or sync your own skills.
</Accordion>
@ -1075,11 +1073,11 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
Install skills:
```bash
clawhub install <skill-slug>
clawhub update --all
openclaw skills install <skill-slug>
openclaw skills update --all
```
ClawHub installs into `./skills` under your current directory (or falls back to your configured OpenClaw workspace); OpenClaw treats that as `<workspace>/skills` on the next session. For shared skills across agents, place them in `~/.openclaw/skills/<name>/SKILL.md`. Some skills expect binaries installed via Homebrew; on Linux that means Linuxbrew (see the Homebrew Linux FAQ entry above). See [Skills](/tools/skills) and [ClawHub](/tools/clawhub).
Native installs land in the active workspace `skills/` directory. For shared skills across agents, place them in `~/.openclaw/skills/<name>/SKILL.md`. Some skills expect binaries installed via Homebrew; on Linux that means Linuxbrew (see the Homebrew Linux FAQ entry above). See [Skills](/tools/skills) and [ClawHub](/tools/clawhub).
</Accordion>

View File

@ -1,5 +1,5 @@
---
summary: "ClawHub guide: public skills registry + CLI workflows"
summary: "ClawHub guide: public registry, native OpenClaw install flows, and ClawHub CLI workflows"
read_when:
- Introducing ClawHub to new users
- Installing, searching, or publishing skills
@ -9,10 +9,35 @@ title: "ClawHub"
# ClawHub
ClawHub is the **public skill registry for OpenClaw**. It is a free service: all skills are public, open, and visible to everyone for sharing and reuse. A skill is just a folder with a `SKILL.md` file (plus supporting text files). You can browse skills in the web app or use the CLI to search, install, update, and publish skills.
ClawHub is the public registry for **OpenClaw skills and plugins**.
- Use native `openclaw` commands to search/install/update skills and install
plugins from ClawHub.
- Use the separate `clawhub` CLI when you need registry auth, publish, delete,
undelete, or sync workflows.
Site: [clawhub.ai](https://clawhub.ai)
## Native OpenClaw flows
Skills:
```bash
openclaw skills search "calendar"
openclaw skills install <skill-slug>
openclaw skills update --all
```
Plugins:
```bash
openclaw plugins install clawhub:<package>
openclaw plugins update --all
```
Native `openclaw` commands install into your active workspace and persist source
metadata so later `update` calls can stay on ClawHub.
## What ClawHub is
- A public registry for OpenClaw skills.
@ -45,16 +70,17 @@ If you want to add new capabilities to your OpenClaw agent, ClawHub is the easie
## Quick start (non-technical)
1. Install the CLI (see next section).
2. Search for something you need:
- `clawhub search "calendar"`
3. Install a skill:
- `clawhub install <skill-slug>`
4. Start a new OpenClaw session so it picks up the new skill.
1. Search for something you need:
- `openclaw skills search "calendar"`
2. Install a skill:
- `openclaw skills install <skill-slug>`
3. Start a new OpenClaw session so it picks up the new skill.
4. If you want to publish or manage registry auth, install the separate
`clawhub` CLI too.
## Install the CLI
## Install the ClawHub CLI
Pick one:
You only need this for registry-authenticated workflows such as publish/sync:
```bash
npm i -g clawhub
@ -66,7 +92,16 @@ pnpm add -g clawhub
## How it fits into OpenClaw
By default, the CLI installs skills into `./skills` under your current working directory. If an OpenClaw workspace is configured, `clawhub` falls back to that workspace unless you override `--workdir` (or `CLAWHUB_WORKDIR`). OpenClaw loads workspace skills from `<workspace>/skills` and will pick them up in the **next** session. If you already use `~/.openclaw/skills` or bundled skills, workspace skills take precedence.
Native `openclaw skills install` installs into the active workspace `skills/`
directory. `openclaw plugins install clawhub:...` records a normal managed
plugin install plus ClawHub source metadata for updates.
The separate `clawhub` CLI also installs skills into `./skills` under your
current working directory. If an OpenClaw workspace is configured, `clawhub`
falls back to that workspace unless you override `--workdir` (or
`CLAWHUB_WORKDIR`). OpenClaw loads workspace skills from `<workspace>/skills`
and will pick them up in the **next** session. If you already use
`~/.openclaw/skills` or bundled skills, workspace skills take precedence.
For more detail on how skills are loaded, shared, and gated, see
[Skills](/tools/skills).

View File

@ -50,21 +50,24 @@ tool surface those skills teach.
## ClawHub (install + sync)
ClawHub is the public skills registry for OpenClaw. Browse at
[https://clawhub.com](https://clawhub.com). Use it to discover, install, update, and back up skills.
[https://clawhub.com](https://clawhub.com). Use native `openclaw skills`
commands to discover/install/update skills, or the separate `clawhub` CLI when
you need publish/sync workflows.
Full guide: [ClawHub](/tools/clawhub).
Common flows:
- Install a skill into your workspace:
- `clawhub install <skill-slug>`
- `openclaw skills install <skill-slug>`
- Update all installed skills:
- `clawhub update --all`
- `openclaw skills update --all`
- Sync (scan + publish updates):
- `clawhub sync --all`
By default, `clawhub` installs into `./skills` under your current working
directory (or falls back to the configured OpenClaw workspace). OpenClaw picks
that up as `<workspace>/skills` on the next session.
Native `openclaw skills install` installs into the active workspace `skills/`
directory. The separate `clawhub` CLI also installs into `./skills` under your
current working directory (or falls back to the configured OpenClaw workspace).
OpenClaw picks that up as `<workspace>/skills` on the next session.
## Security notes

View File

@ -0,0 +1,398 @@
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,
listClawHubSkills,
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;
};
function normalizeSlug(raw: string): string {
const trimmed = raw.trim();
if (!trimmed || trimmed.includes("/") || trimmed.includes("\\") || trimmed.includes("..")) {
throw new Error(`Invalid skill slug: ${raw}`);
}
return trimmed;
}
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[]> {
if (params.query?.trim()) {
return await searchClawHubSkills({
query: params.query,
limit: params.limit,
baseUrl: params.baseUrl,
});
}
const list = await listClawHubSkills({
limit: params.limit,
baseUrl: params.baseUrl,
});
return list.items.map((item) => ({
score: 0,
slug: item.slug,
displayName: item.displayName,
summary: item.summary,
version: item.latestVersion?.version,
updatedAt: item.updatedAt,
}));
}
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,
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");
}

View File

@ -19,6 +19,8 @@ const updateNpmInstalledPlugins = vi.fn();
const promptYesNo = vi.fn();
const installPluginFromNpmSpec = vi.fn();
const installPluginFromPath = vi.fn();
const installPluginFromClawHub = vi.fn();
const parseClawHubPluginSpec = vi.fn();
const { defaultRuntime, runtimeLogs, runtimeErrors, resetRuntimeCapture } =
createCliRuntimeCapture();
@ -27,22 +29,14 @@ vi.mock("../runtime.js", () => ({
defaultRuntime,
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => loadConfig(),
writeConfigFile: (config: OpenClawConfig) => writeConfigFile(config),
};
});
vi.mock("../config/config.js", () => ({
loadConfig: () => loadConfig(),
writeConfigFile: (config: OpenClawConfig) => writeConfigFile(config),
}));
vi.mock("../config/paths.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/paths.js")>();
return {
...actual,
resolveStateDir: () => resolveStateDir(),
};
});
vi.mock("../config/paths.js", () => ({
resolveStateDir: () => resolveStateDir(),
}));
vi.mock("../plugins/marketplace.js", () => ({
installPluginFromMarketplace: (...args: unknown[]) => installPluginFromMarketplace(...args),
@ -63,25 +57,22 @@ vi.mock("../plugins/manifest-registry.js", () => ({
clearPluginManifestRegistryCache: () => clearPluginManifestRegistryCache(),
}));
vi.mock("../plugins/status.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../plugins/status.js")>();
return {
...actual,
buildPluginStatusReport: (...args: unknown[]) => buildPluginStatusReport(...args),
};
});
vi.mock("../plugins/status.js", () => ({
buildPluginStatusReport: (...args: unknown[]) => buildPluginStatusReport(...args),
}));
vi.mock("../plugins/slots.js", () => ({
applyExclusiveSlotSelection: (...args: unknown[]) => applyExclusiveSlotSelection(...args),
}));
vi.mock("../plugins/uninstall.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../plugins/uninstall.js")>();
return {
...actual,
uninstallPlugin: (...args: unknown[]) => uninstallPlugin(...args),
};
});
vi.mock("../plugins/uninstall.js", () => ({
uninstallPlugin: (...args: unknown[]) => uninstallPlugin(...args),
resolveUninstallDirectoryTarget: ({
installRecord,
}: {
installRecord?: { installPath?: string; sourcePath?: string };
}) => installRecord?.installPath ?? installRecord?.sourcePath ?? null,
}));
vi.mock("../plugins/update.js", () => ({
updateNpmInstalledPlugins: (...args: unknown[]) => updateNpmInstalledPlugins(...args),
@ -96,6 +87,16 @@ vi.mock("../plugins/install.js", () => ({
installPluginFromPath: (...args: unknown[]) => installPluginFromPath(...args),
}));
vi.mock("../plugins/clawhub.js", () => ({
installPluginFromClawHub: (...args: unknown[]) => installPluginFromClawHub(...args),
formatClawHubSpecifier: ({ name, version }: { name: string; version?: string }) =>
`clawhub:${name}${version ? `@${version}` : ""}`,
}));
vi.mock("../infra/clawhub.js", () => ({
parseClawHubPluginSpec: (...args: unknown[]) => parseClawHubPluginSpec(...args),
}));
const { registerPluginsCli } = await import("./plugins-cli.js");
describe("plugins cli", () => {
@ -126,6 +127,8 @@ describe("plugins cli", () => {
promptYesNo.mockReset();
installPluginFromNpmSpec.mockReset();
installPluginFromPath.mockReset();
installPluginFromClawHub.mockReset();
parseClawHubPluginSpec.mockReset();
loadConfig.mockReturnValue({} as OpenClawConfig);
writeConfigFile.mockResolvedValue(undefined);
@ -169,6 +172,11 @@ describe("plugins cli", () => {
ok: false,
error: "npm install disabled in test",
});
installPluginFromClawHub.mockResolvedValue({
ok: false,
error: "clawhub install disabled in test",
});
parseClawHubPluginSpec.mockReturnValue(null);
});
it("exits when --marketplace is combined with --link", async () => {
@ -251,6 +259,87 @@ describe("plugins cli", () => {
expect(runtimeLogs.some((line) => line.includes("Installed plugin: alpha"))).toBe(true);
});
it("installs ClawHub plugins and persists source metadata", async () => {
const cfg = {
plugins: {
entries: {},
},
} as OpenClawConfig;
const enabledCfg = {
plugins: {
entries: {
demo: {
enabled: true,
},
},
},
} as OpenClawConfig;
const installedCfg = {
...enabledCfg,
plugins: {
...enabledCfg.plugins,
installs: {
demo: {
source: "clawhub",
spec: "clawhub:demo@1.2.3",
installPath: "/tmp/openclaw-state/extensions/demo",
clawhubPackage: "demo",
clawhubFamily: "code-plugin",
clawhubChannel: "official",
},
},
},
} as OpenClawConfig;
loadConfig.mockReturnValue(cfg);
parseClawHubPluginSpec.mockReturnValue({ name: "demo" });
installPluginFromClawHub.mockResolvedValue({
ok: true,
pluginId: "demo",
targetDir: "/tmp/openclaw-state/extensions/demo",
version: "1.2.3",
packageName: "demo",
clawhub: {
source: "clawhub",
clawhubUrl: "https://clawhub.ai",
clawhubPackage: "demo",
clawhubFamily: "code-plugin",
clawhubChannel: "official",
version: "1.2.3",
integrity: "sha256-abc",
resolvedAt: "2026-03-22T00:00:00.000Z",
},
});
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
recordPluginInstall.mockReturnValue(installedCfg);
applyExclusiveSlotSelection.mockReturnValue({
config: installedCfg,
warnings: [],
});
await runCommand(["plugins", "install", "clawhub:demo"]);
expect(installPluginFromClawHub).toHaveBeenCalledWith(
expect.objectContaining({
spec: "clawhub:demo",
}),
);
expect(recordPluginInstall).toHaveBeenCalledWith(
enabledCfg,
expect.objectContaining({
pluginId: "demo",
source: "clawhub",
spec: "clawhub:demo@1.2.3",
clawhubPackage: "demo",
clawhubFamily: "code-plugin",
clawhubChannel: "official",
}),
);
expect(writeConfigFile).toHaveBeenCalledWith(installedCfg);
expect(runtimeLogs.some((line) => line.includes("Installed plugin: demo"))).toBe(true);
expect(installPluginFromNpmSpec).not.toHaveBeenCalled();
});
it("shows uninstall dry-run preview without mutating config", async () => {
loadConfig.mockReturnValue({
plugins: {

View File

@ -7,8 +7,10 @@ import { loadConfig, writeConfigFile } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import type { PluginInstallRecord } from "../config/types.plugins.js";
import { resolveArchiveKind } from "../infra/archive.js";
import { parseClawHubPluginSpec } from "../infra/clawhub.js";
import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js";
import { type BundledPluginSource, findBundledPluginSource } from "../plugins/bundled-sources.js";
import { formatClawHubSpecifier, installPluginFromClawHub } from "../plugins/clawhub.js";
import { enablePluginInConfig } from "../plugins/enable.js";
import { installPluginFromNpmSpec, installPluginFromPath } from "../plugins/install.js";
import { recordPluginInstall } from "../plugins/installs.js";
@ -504,6 +506,45 @@ async function runPluginInstallCommand(params: {
return;
}
const clawhubSpec = parseClawHubPluginSpec(raw);
if (clawhubSpec) {
const result = await installPluginFromClawHub({
spec: raw,
logger: createPluginInstallLogger(),
});
if (!result.ok) {
defaultRuntime.error(result.error);
return defaultRuntime.exit(1);
}
clearPluginManifestRegistryCache();
let next = enablePluginInConfig(cfg, result.pluginId).config;
next = recordPluginInstall(next, {
pluginId: result.pluginId,
source: "clawhub",
spec: formatClawHubSpecifier({
name: result.clawhub.clawhubPackage,
version: result.clawhub.version,
}),
installPath: result.targetDir,
version: result.version,
integrity: result.clawhub.integrity,
resolvedAt: result.clawhub.resolvedAt,
clawhubUrl: result.clawhub.clawhubUrl,
clawhubPackage: result.clawhub.clawhubPackage,
clawhubFamily: result.clawhub.clawhubFamily,
clawhubChannel: result.clawhub.clawhubChannel,
});
const slotResult = applySlotSelectionForPlugin(next, result.pluginId);
next = slotResult.config;
await writeConfigFile(next);
logSlotWarnings(slotResult.warnings);
defaultRuntime.log(`Installed plugin: ${result.pluginId}`);
defaultRuntime.log(`Restart the gateway to load plugins.`);
return;
}
const result = await installPluginFromNpmSpec({
spec: raw,
logger: createPluginInstallLogger(),
@ -1059,7 +1100,9 @@ export function registerPluginsCli(program: Command) {
plugins
.command("install")
.description("Install a plugin (path, archive, npm spec, or marketplace entry)")
.description(
"Install a plugin (path, archive, npm spec, clawhub:package, or marketplace entry)",
)
.argument(
"<path-or-spec-or-plugin>",
"Path (.ts/.js/.zip/.tgz/.tar.gz), npm package spec, or marketplace plugin name",
@ -1076,7 +1119,7 @@ export function registerPluginsCli(program: Command) {
plugins
.command("update")
.description("Update installed plugins (npm and marketplace installs)")
.description("Update installed plugins (npm, clawhub, and marketplace installs)")
.argument("[id]", "Plugin id (omit with --all)")
.option("--all", "Update all tracked plugins", false)
.option("--dry-run", "Show what would change without writing", false)

View File

@ -1,124 +1,139 @@
import { Command } from "commander";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createCliRuntimeCapture } from "./test-runtime-capture.js";
const loadConfigMock = vi.fn();
const resolveAgentWorkspaceDirMock = vi.fn();
const resolveDefaultAgentIdMock = vi.fn();
const buildWorkspaceSkillStatusMock = vi.fn();
const formatSkillsListMock = vi.fn();
const formatSkillInfoMock = vi.fn();
const formatSkillsCheckMock = vi.fn();
const loadConfigMock = vi.fn(() => ({}));
const resolveDefaultAgentIdMock = vi.fn(() => "main");
const resolveAgentWorkspaceDirMock = vi.fn(() => "/tmp/workspace");
const searchSkillsFromClawHubMock = vi.fn();
const installSkillFromClawHubMock = vi.fn();
const updateSkillsFromClawHubMock = vi.fn();
const readTrackedClawHubSkillSlugsMock = vi.fn();
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
const { defaultRuntime, runtimeLogs, runtimeErrors, resetRuntimeCapture } =
createCliRuntimeCapture();
vi.mock("../runtime.js", () => ({
defaultRuntime,
}));
vi.mock("../config/config.js", () => ({
loadConfig: loadConfigMock,
loadConfig: () => loadConfigMock(),
}));
vi.mock("../agents/agent-scope.js", () => ({
resolveAgentWorkspaceDir: resolveAgentWorkspaceDirMock,
resolveDefaultAgentId: resolveDefaultAgentIdMock,
resolveDefaultAgentId: () => resolveDefaultAgentIdMock(),
resolveAgentWorkspaceDir: () => resolveAgentWorkspaceDirMock(),
}));
vi.mock("../agents/skills-status.js", () => ({
buildWorkspaceSkillStatus: buildWorkspaceSkillStatusMock,
vi.mock("../agents/skills-clawhub.js", () => ({
searchSkillsFromClawHub: (...args: unknown[]) => searchSkillsFromClawHubMock(...args),
installSkillFromClawHub: (...args: unknown[]) => installSkillFromClawHubMock(...args),
updateSkillsFromClawHub: (...args: unknown[]) => updateSkillsFromClawHubMock(...args),
readTrackedClawHubSkillSlugs: (...args: unknown[]) => readTrackedClawHubSkillSlugsMock(...args),
}));
vi.mock("./skills-cli.format.js", () => ({
formatSkillsList: formatSkillsListMock,
formatSkillInfo: formatSkillInfoMock,
formatSkillsCheck: formatSkillsCheckMock,
}));
const { registerSkillsCli } = await import("./skills-cli.js");
vi.mock("../runtime.js", () => ({
defaultRuntime: runtime,
}));
let registerSkillsCli: typeof import("./skills-cli.js").registerSkillsCli;
beforeAll(async () => {
({ registerSkillsCli } = await import("./skills-cli.js"));
});
describe("registerSkillsCli", () => {
const report = {
workspaceDir: "/tmp/workspace",
managedSkillsDir: "/tmp/workspace/.skills",
skills: [],
describe("skills cli commands", () => {
const createProgram = () => {
const program = new Command();
program.exitOverride();
registerSkillsCli(program);
return program;
};
async function runCli(args: string[]) {
const program = new Command();
registerSkillsCli(program);
await program.parseAsync(args, { from: "user" });
}
const runCommand = (argv: string[]) => createProgram().parseAsync(argv, { from: "user" });
beforeEach(() => {
vi.clearAllMocks();
loadConfigMock.mockReturnValue({ gateway: {} });
resetRuntimeCapture();
loadConfigMock.mockReset();
resolveDefaultAgentIdMock.mockReset();
resolveAgentWorkspaceDirMock.mockReset();
searchSkillsFromClawHubMock.mockReset();
installSkillFromClawHubMock.mockReset();
updateSkillsFromClawHubMock.mockReset();
readTrackedClawHubSkillSlugsMock.mockReset();
loadConfigMock.mockReturnValue({});
resolveDefaultAgentIdMock.mockReturnValue("main");
resolveAgentWorkspaceDirMock.mockReturnValue("/tmp/workspace");
buildWorkspaceSkillStatusMock.mockReturnValue(report);
formatSkillsListMock.mockReturnValue("skills-list-output");
formatSkillInfoMock.mockReturnValue("skills-info-output");
formatSkillsCheckMock.mockReturnValue("skills-check-output");
});
it("runs list command with resolved report and formatter options", async () => {
await runCli(["skills", "list", "--eligible", "--verbose", "--json"]);
expect(buildWorkspaceSkillStatusMock).toHaveBeenCalledWith("/tmp/workspace", {
config: { gateway: {} },
searchSkillsFromClawHubMock.mockResolvedValue([]);
installSkillFromClawHubMock.mockResolvedValue({
ok: false,
error: "install disabled in test",
});
expect(formatSkillsListMock).toHaveBeenCalledWith(
report,
expect.objectContaining({
eligible: true,
verbose: true,
json: true,
}),
);
expect(runtime.log).toHaveBeenCalledWith("skills-list-output");
updateSkillsFromClawHubMock.mockResolvedValue([]);
readTrackedClawHubSkillSlugsMock.mockResolvedValue([]);
});
it("runs info command and forwards skill name", async () => {
await runCli(["skills", "info", "peekaboo", "--json"]);
it("searches ClawHub skills from the native CLI", async () => {
searchSkillsFromClawHubMock.mockResolvedValue([
{
slug: "calendar",
displayName: "Calendar",
summary: "CalDAV helpers",
version: "1.2.3",
},
]);
expect(formatSkillInfoMock).toHaveBeenCalledWith(
report,
"peekaboo",
expect.objectContaining({ json: true }),
);
expect(runtime.log).toHaveBeenCalledWith("skills-info-output");
await runCommand(["skills", "search", "calendar"]);
expect(searchSkillsFromClawHubMock).toHaveBeenCalledWith({
query: "calendar",
limit: undefined,
});
expect(runtimeLogs.some((line) => line.includes("calendar v1.2.3 Calendar"))).toBe(true);
});
it("runs check command and writes formatter output", async () => {
await runCli(["skills", "check"]);
expect(formatSkillsCheckMock).toHaveBeenCalledWith(report, expect.any(Object));
expect(runtime.log).toHaveBeenCalledWith("skills-check-output");
});
it("uses list formatter for default skills action", async () => {
await runCli(["skills"]);
expect(formatSkillsListMock).toHaveBeenCalledWith(report, {});
expect(runtime.log).toHaveBeenCalledWith("skills-list-output");
});
it("reports runtime errors when report loading fails", async () => {
loadConfigMock.mockImplementationOnce(() => {
throw new Error("config exploded");
it("installs a skill from ClawHub into the active workspace", async () => {
installSkillFromClawHubMock.mockResolvedValue({
ok: true,
slug: "calendar",
version: "1.2.3",
targetDir: "/tmp/workspace/skills/calendar",
});
await runCli(["skills", "list"]);
await runCommand(["skills", "install", "calendar", "--version", "1.2.3"]);
expect(runtime.error).toHaveBeenCalledWith("Error: config exploded");
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(buildWorkspaceSkillStatusMock).not.toHaveBeenCalled();
expect(installSkillFromClawHubMock).toHaveBeenCalledWith({
workspaceDir: "/tmp/workspace",
slug: "calendar",
version: "1.2.3",
force: false,
logger: expect.any(Object),
});
expect(
runtimeLogs.some((line) =>
line.includes("Installed calendar@1.2.3 -> /tmp/workspace/skills/calendar"),
),
).toBe(true);
});
it("updates all tracked ClawHub skills", 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"]);
expect(readTrackedClawHubSkillSlugsMock).toHaveBeenCalledWith("/tmp/workspace");
expect(updateSkillsFromClawHubMock).toHaveBeenCalledWith({
workspaceDir: "/tmp/workspace",
slug: undefined,
logger: expect.any(Object),
});
expect(runtimeLogs.some((line) => line.includes("Updated calendar: 1.2.2 -> 1.2.3"))).toBe(
true,
);
expect(runtimeErrors).toEqual([]);
});
});

View File

@ -23,7 +23,7 @@ function appendClawHubHint(output: string, json?: boolean): string {
if (json) {
return output;
}
return `${output}\n\nTip: use \`npx clawhub\` to search, install, and sync skills.`;
return `${output}\n\nTip: use \`openclaw skills search\`, \`openclaw skills install\`, and \`openclaw skills update\` for ClawHub-backed skills.`;
}
function formatSkillStatus(skill: SkillStatusEntry): string {

View File

@ -43,7 +43,7 @@ describe("skills-cli", () => {
const report = createMockReport([]);
const output = formatSkillsList(report, {});
expect(output).toContain("No skills found");
expect(output).toContain("npx clawhub");
expect(output).toContain("openclaw skills search");
});
it("formats skills list with eligible skill", () => {
@ -115,7 +115,7 @@ describe("skills-cli", () => {
const report = createMockReport([]);
const output = formatSkillInfo(report, "unknown-skill", {});
expect(output).toContain("not found");
expect(output).toContain("npx clawhub");
expect(output).toContain("openclaw skills install");
});
it("shows detailed info for a skill", () => {
@ -180,7 +180,7 @@ describe("skills-cli", () => {
expect(output).toContain("ready-2");
expect(output).toContain("not-ready");
expect(output).toContain("go"); // missing binary
expect(output).toContain("npx clawhub");
expect(output).toContain("openclaw skills update");
});
it("normalizes text-presentation emoji selectors in check output", () => {

View File

@ -1,5 +1,11 @@
import type { Command } from "commander";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import {
installSkillFromClawHub,
readTrackedClawHubSkillSlugs,
searchSkillsFromClawHub,
updateSkillsFromClawHub,
} from "../agents/skills-clawhub.js";
import { loadConfig } from "../config/config.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
@ -34,6 +40,11 @@ async function runSkillsAction(render: (report: SkillStatusReport) => string): P
}
}
function resolveActiveWorkspaceDir(): string {
const config = loadConfig();
return resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
}
/**
* Register the skills CLI commands
*/
@ -47,6 +58,116 @@ export function registerSkillsCli(program: Command) {
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/skills", "docs.openclaw.ai/cli/skills")}\n`,
);
skills
.command("search")
.description("Search ClawHub skills")
.argument("[query...]", "Optional search query")
.option("--limit <n>", "Max results", (value) => Number.parseInt(value, 10))
.option("--json", "Output as JSON", false)
.action(async (queryParts: string[], opts: { limit?: number; json?: boolean }) => {
try {
const results = await searchSkillsFromClawHub({
query: queryParts.join(" ").trim() || undefined,
limit: opts.limit,
});
if (opts.json) {
defaultRuntime.log(JSON.stringify({ results }, null, 2));
return;
}
if (results.length === 0) {
defaultRuntime.log("No ClawHub skills found.");
return;
}
for (const entry of results) {
const version = entry.version ? ` v${entry.version}` : "";
const summary = entry.summary ? ` ${entry.summary}` : "";
defaultRuntime.log(`${entry.slug}${version} ${entry.displayName}${summary}`);
}
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
skills
.command("install")
.description("Install a skill from ClawHub into the active workspace")
.argument("<slug>", "ClawHub skill slug")
.option("--version <version>", "Install a specific version")
.option("--force", "Overwrite an existing workspace skill", false)
.action(async (slug: string, opts: { version?: string; force?: boolean }) => {
try {
const workspaceDir = resolveActiveWorkspaceDir();
const result = await installSkillFromClawHub({
workspaceDir,
slug,
version: opts.version,
force: Boolean(opts.force),
logger: {
info: (message) => defaultRuntime.log(message),
},
});
if (!result.ok) {
defaultRuntime.error(result.error);
defaultRuntime.exit(1);
return;
}
defaultRuntime.log(`Installed ${result.slug}@${result.version} -> ${result.targetDir}`);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
skills
.command("update")
.description("Update ClawHub-installed skills in the active workspace")
.argument("[slug]", "Single skill slug")
.option("--all", "Update all tracked ClawHub skills", false)
.action(async (slug: string | undefined, opts: { all?: boolean }) => {
try {
if (!slug && !opts.all) {
defaultRuntime.error("Provide a skill slug or use --all.");
defaultRuntime.exit(1);
return;
}
if (slug && opts.all) {
defaultRuntime.error("Use either a skill slug or --all.");
defaultRuntime.exit(1);
return;
}
const workspaceDir = resolveActiveWorkspaceDir();
const tracked = await readTrackedClawHubSkillSlugs(workspaceDir);
if (opts.all && tracked.length === 0) {
defaultRuntime.log("No tracked ClawHub skills to update.");
return;
}
const results = await updateSkillsFromClawHub({
workspaceDir,
slug,
logger: {
info: (message) => defaultRuntime.log(message),
},
});
for (const result of results) {
if (!result.ok) {
defaultRuntime.error(result.error);
continue;
}
if (result.changed) {
defaultRuntime.log(
`Updated ${result.slug}: ${result.previousVersion ?? "unknown"} -> ${result.version}`,
);
continue;
}
defaultRuntime.log(`${result.slug} already at ${result.version}`);
}
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
skills
.command("list")
.description("List all available skills")

View File

@ -1,5 +1,5 @@
export type InstallRecordBase = {
source: "npm" | "archive" | "path";
source: "npm" | "archive" | "path" | "clawhub";
spec?: string;
sourcePath?: string;
installPath?: string;
@ -11,4 +11,8 @@ export type InstallRecordBase = {
shasum?: string;
resolvedAt?: string;
installedAt?: string;
clawhubUrl?: string;
clawhubPackage?: string;
clawhubFamily?: "code-plugin" | "bundle-plugin";
clawhubChannel?: "official" | "community" | "private";
};

View File

@ -4,6 +4,7 @@ export const InstallSourceSchema = z.union([
z.literal("npm"),
z.literal("archive"),
z.literal("path"),
z.literal("clawhub"),
]);
export const PluginInstallSourceSchema = z.union([InstallSourceSchema, z.literal("marketplace")]);
@ -21,6 +22,12 @@ export const InstallRecordShape = {
shasum: z.string().optional(),
resolvedAt: z.string().optional(),
installedAt: z.string().optional(),
clawhubUrl: z.string().optional(),
clawhubPackage: z.string().optional(),
clawhubFamily: z.union([z.literal("code-plugin"), z.literal("bundle-plugin")]).optional(),
clawhubChannel: z
.union([z.literal("official"), z.literal("community"), z.literal("private")])
.optional(),
} as const;
export const PluginInstallRecordShape = {

View File

@ -189,22 +189,50 @@ export const SkillsBinsResultSchema = Type.Object(
{ additionalProperties: false },
);
export const SkillsInstallParamsSchema = Type.Object(
{
name: NonEmptyString,
installId: NonEmptyString,
timeoutMs: Type.Optional(Type.Integer({ minimum: 1000 })),
},
export const SkillsInstallParamsSchema = Type.Union(
[
Type.Object(
{
name: NonEmptyString,
installId: NonEmptyString,
timeoutMs: Type.Optional(Type.Integer({ minimum: 1000 })),
},
{ additionalProperties: false },
),
Type.Object(
{
source: Type.Literal("clawhub"),
slug: NonEmptyString,
version: Type.Optional(NonEmptyString),
force: Type.Optional(Type.Boolean()),
timeoutMs: Type.Optional(Type.Integer({ minimum: 1000 })),
},
{ additionalProperties: false },
),
],
{ additionalProperties: false },
);
export const SkillsUpdateParamsSchema = Type.Object(
{
skillKey: NonEmptyString,
enabled: Type.Optional(Type.Boolean()),
apiKey: Type.Optional(Type.String()),
env: Type.Optional(Type.Record(NonEmptyString, Type.String())),
},
export const SkillsUpdateParamsSchema = Type.Union(
[
Type.Object(
{
skillKey: NonEmptyString,
enabled: Type.Optional(Type.Boolean()),
apiKey: Type.Optional(Type.String()),
env: Type.Optional(Type.Record(NonEmptyString, Type.String())),
},
{ additionalProperties: false },
),
Type.Object(
{
source: Type.Literal("clawhub"),
slug: Type.Optional(NonEmptyString),
all: Type.Optional(Type.Boolean()),
},
{ additionalProperties: false },
),
],
{ additionalProperties: false },
);

View File

@ -0,0 +1,158 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const loadConfigMock = vi.fn(() => ({}));
const resolveDefaultAgentIdMock = vi.fn(() => "main");
const resolveAgentWorkspaceDirMock = vi.fn(() => "/tmp/workspace");
const installSkillFromClawHubMock = vi.fn();
const updateSkillsFromClawHubMock = vi.fn();
vi.mock("../../config/config.js", () => ({
loadConfig: () => loadConfigMock(),
writeConfigFile: vi.fn(),
}));
vi.mock("../../agents/agent-scope.js", () => ({
listAgentIds: vi.fn(() => ["main"]),
resolveDefaultAgentId: () => resolveDefaultAgentIdMock(),
resolveAgentWorkspaceDir: () => resolveAgentWorkspaceDirMock(),
}));
vi.mock("../../agents/skills-clawhub.js", () => ({
installSkillFromClawHub: (...args: unknown[]) => installSkillFromClawHubMock(...args),
updateSkillsFromClawHub: (...args: unknown[]) => updateSkillsFromClawHubMock(...args),
}));
const { skillsHandlers } = await import("./skills.js");
describe("skills gateway handlers (clawhub)", () => {
beforeEach(() => {
loadConfigMock.mockReset();
resolveDefaultAgentIdMock.mockReset();
resolveAgentWorkspaceDirMock.mockReset();
installSkillFromClawHubMock.mockReset();
updateSkillsFromClawHubMock.mockReset();
loadConfigMock.mockReturnValue({});
resolveDefaultAgentIdMock.mockReturnValue("main");
resolveAgentWorkspaceDirMock.mockReturnValue("/tmp/workspace");
});
it("installs a ClawHub skill through skills.install", async () => {
installSkillFromClawHubMock.mockResolvedValue({
ok: true,
slug: "calendar",
version: "1.2.3",
targetDir: "/tmp/workspace/skills/calendar",
});
let ok: boolean | null = null;
let response: unknown;
let error: unknown;
await skillsHandlers["skills.install"]({
params: {
source: "clawhub",
slug: "calendar",
version: "1.2.3",
},
req: {} as never,
client: null as never,
isWebchatConnect: () => false,
context: {} as never,
respond: (success, result, err) => {
ok = success;
response = result;
error = err;
},
});
expect(installSkillFromClawHubMock).toHaveBeenCalledWith({
workspaceDir: "/tmp/workspace",
slug: "calendar",
version: "1.2.3",
force: false,
});
expect(ok).toBe(true);
expect(error).toBeUndefined();
expect(response).toMatchObject({
ok: true,
message: "Installed calendar@1.2.3",
slug: "calendar",
version: "1.2.3",
});
});
it("updates ClawHub skills through skills.update", async () => {
updateSkillsFromClawHubMock.mockResolvedValue([
{
ok: true,
slug: "calendar",
previousVersion: "1.2.2",
version: "1.2.3",
changed: true,
targetDir: "/tmp/workspace/skills/calendar",
},
]);
let ok: boolean | null = null;
let response: unknown;
let error: unknown;
await skillsHandlers["skills.update"]({
params: {
source: "clawhub",
slug: "calendar",
},
req: {} as never,
client: null as never,
isWebchatConnect: () => false,
context: {} as never,
respond: (success, result, err) => {
ok = success;
response = result;
error = err;
},
});
expect(updateSkillsFromClawHubMock).toHaveBeenCalledWith({
workspaceDir: "/tmp/workspace",
slug: "calendar",
});
expect(ok).toBe(true);
expect(error).toBeUndefined();
expect(response).toMatchObject({
ok: true,
skillKey: "calendar",
config: {
source: "clawhub",
results: [
{
ok: true,
slug: "calendar",
version: "1.2.3",
},
],
},
});
});
it("rejects ClawHub skills.update requests without slug or all", async () => {
let ok: boolean | null = null;
let error: { code?: string; message?: string } | undefined;
await skillsHandlers["skills.update"]({
params: {
source: "clawhub",
},
req: {} as never,
client: null as never,
isWebchatConnect: () => false,
context: {} as never,
respond: (success, _result, err) => {
ok = success;
error = err as { code?: string; message?: string } | undefined;
},
});
expect(ok).toBe(false);
expect(error?.message).toContain('requires "slug" or "all"');
expect(updateSkillsFromClawHubMock).not.toHaveBeenCalled();
});
});

View File

@ -3,6 +3,7 @@ import {
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "../../agents/agent-scope.js";
import { installSkillFromClawHub, updateSkillsFromClawHub } from "../../agents/skills-clawhub.js";
import { installSkill } from "../../agents/skills-install.js";
import { buildWorkspaceSkillStatus } from "../../agents/skills-status.js";
import { loadWorkspaceSkillEntries, type SkillEntry } from "../../agents/skills.js";
@ -123,13 +124,44 @@ export const skillsHandlers: GatewayRequestHandlers = {
);
return;
}
const cfg = loadConfig();
const workspaceDirRaw = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
if (params && typeof params === "object" && "source" in params && params.source === "clawhub") {
const p = params as {
source: "clawhub";
slug: string;
version?: string;
force?: boolean;
};
const result = await installSkillFromClawHub({
workspaceDir: workspaceDirRaw,
slug: p.slug,
version: p.version,
force: Boolean(p.force),
});
respond(
result.ok,
result.ok
? {
ok: true,
message: `Installed ${result.slug}@${result.version}`,
stdout: "",
stderr: "",
code: 0,
slug: result.slug,
version: result.version,
targetDir: result.targetDir,
}
: result,
result.ok ? undefined : errorShape(ErrorCodes.UNAVAILABLE, result.error),
);
return;
}
const p = params as {
name: string;
installId: string;
timeoutMs?: number;
};
const cfg = loadConfig();
const workspaceDirRaw = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
const result = await installSkill({
workspaceDir: workspaceDirRaw,
skillName: p.name,
@ -155,6 +187,54 @@ export const skillsHandlers: GatewayRequestHandlers = {
);
return;
}
if (params && typeof params === "object" && "source" in params && params.source === "clawhub") {
const p = params as {
source: "clawhub";
slug?: string;
all?: boolean;
};
if (!p.slug && !p.all) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, 'clawhub skills.update requires "slug" or "all"'),
);
return;
}
if (p.slug && p.all) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
'clawhub skills.update accepts either "slug" or "all", not both',
),
);
return;
}
const cfg = loadConfig();
const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
const results = await updateSkillsFromClawHub({
workspaceDir,
slug: p.slug,
});
const errors = results.filter((result) => !result.ok);
respond(
errors.length === 0,
{
ok: errors.length === 0,
skillKey: p.slug ?? "*",
config: {
source: "clawhub",
results,
},
},
errors.length === 0
? undefined
: errorShape(ErrorCodes.UNAVAILABLE, errors.map((result) => result.error).join("; ")),
);
return;
}
const p = params as {
skillKey: string;
enabled?: boolean;

67
src/infra/clawhub.test.ts Normal file
View File

@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import {
parseClawHubPluginSpec,
resolveLatestVersionFromPackage,
satisfiesGatewayMinimum,
satisfiesPluginApiRange,
} from "./clawhub.js";
describe("clawhub helpers", () => {
it("parses explicit ClawHub package specs", () => {
expect(parseClawHubPluginSpec("clawhub:demo")).toEqual({
name: "demo",
});
expect(parseClawHubPluginSpec("clawhub:demo@1.2.3")).toEqual({
name: "demo",
version: "1.2.3",
});
expect(parseClawHubPluginSpec("@scope/pkg")).toBeNull();
});
it("resolves latest versions from latestVersion before tags", () => {
expect(
resolveLatestVersionFromPackage({
package: {
name: "demo",
displayName: "Demo",
family: "code-plugin",
channel: "official",
isOfficial: true,
createdAt: 0,
updatedAt: 0,
latestVersion: "1.2.3",
tags: { latest: "1.2.2" },
},
}),
).toBe("1.2.3");
expect(
resolveLatestVersionFromPackage({
package: {
name: "demo",
displayName: "Demo",
family: "code-plugin",
channel: "official",
isOfficial: true,
createdAt: 0,
updatedAt: 0,
tags: { latest: "1.2.2" },
},
}),
).toBe("1.2.2");
});
it("checks plugin api ranges without semver dependency", () => {
expect(satisfiesPluginApiRange("1.2.3", "^1.2.0")).toBe(true);
expect(satisfiesPluginApiRange("1.9.0", ">=1.2.0 <2.0.0")).toBe(true);
expect(satisfiesPluginApiRange("2.0.0", "^1.2.0")).toBe(false);
expect(satisfiesPluginApiRange("1.1.9", ">=1.2.0")).toBe(false);
expect(satisfiesPluginApiRange("invalid", "^1.2.0")).toBe(false);
});
it("checks min gateway versions with loose host labels", () => {
expect(satisfiesGatewayMinimum("2026.3.14", "2026.3.0")).toBe(true);
expect(satisfiesGatewayMinimum("OpenClaw 2026.3.14", "2026.3.0")).toBe(true);
expect(satisfiesGatewayMinimum("2026.2.9", "2026.3.0")).toBe(false);
expect(satisfiesGatewayMinimum("unknown", "2026.3.0")).toBe(false);
});
});

654
src/infra/clawhub.ts Normal file
View File

@ -0,0 +1,654 @@
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { isAtLeast, parseSemver } from "./runtime-guard.js";
const DEFAULT_CLAWHUB_URL = "https://clawhub.ai";
const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
export type ClawHubPackageFamily = "skill" | "code-plugin" | "bundle-plugin";
export type ClawHubPackageChannel = "official" | "community" | "private";
export type ClawHubPackageListItem = {
name: string;
displayName: string;
family: ClawHubPackageFamily;
runtimeId?: string | null;
channel: ClawHubPackageChannel;
isOfficial: boolean;
summary?: string | null;
ownerHandle?: string | null;
createdAt: number;
updatedAt: number;
latestVersion?: string | null;
capabilityTags?: string[];
executesCode?: boolean;
verificationTier?: string | null;
};
export type ClawHubPackageDetail = {
package:
| (ClawHubPackageListItem & {
tags?: Record<string, string>;
compatibility?: {
pluginApiRange?: string;
builtWithOpenClawVersion?: string;
minGatewayVersion?: string;
} | null;
capabilities?: {
executesCode?: boolean;
runtimeId?: string;
capabilityTags?: string[];
bundleFormat?: string;
hostTargets?: string[];
pluginKind?: string;
channels?: string[];
providers?: string[];
hooks?: string[];
bundledSkills?: string[];
} | null;
verification?: {
tier?: string;
scope?: string;
summary?: string;
sourceRepo?: string;
sourceCommit?: string;
hasProvenance?: boolean;
scanStatus?: string;
} | null;
})
| null;
owner?: {
handle?: string | null;
displayName?: string | null;
image?: string | null;
} | null;
};
export type ClawHubPackageVersion = {
package: {
name: string;
displayName: string;
family: ClawHubPackageFamily;
} | null;
version: {
version: string;
createdAt: number;
changelog: string;
distTags?: string[];
files?: unknown;
compatibility?: ClawHubPackageDetail["package"] extends infer T
? T extends { compatibility?: infer C }
? C
: never
: never;
capabilities?: ClawHubPackageDetail["package"] extends infer T
? T extends { capabilities?: infer C }
? C
: never
: never;
verification?: ClawHubPackageDetail["package"] extends infer T
? T extends { verification?: infer C }
? C
: never
: never;
} | null;
};
export type ClawHubPackageSearchResult = {
score: number;
package: ClawHubPackageListItem;
};
export type ClawHubSkillSearchResult = {
score: number;
slug: string;
displayName: string;
summary?: string;
version?: string;
updatedAt?: number;
};
export type ClawHubSkillDetail = {
skill: {
slug: string;
displayName: string;
summary?: string;
tags?: Record<string, string>;
createdAt: number;
updatedAt: number;
} | null;
latestVersion?: {
version: string;
createdAt: number;
changelog?: string;
} | null;
metadata?: {
os?: string[] | null;
systems?: string[] | null;
} | null;
owner?: {
handle?: string | null;
displayName?: string | null;
image?: string | null;
} | null;
};
export type ClawHubSkillListResponse = {
items: Array<{
slug: string;
displayName: string;
summary?: string;
tags?: Record<string, string>;
latestVersion?: {
version: string;
createdAt: number;
changelog?: string;
} | null;
metadata?: {
os?: string[] | null;
systems?: string[] | null;
} | null;
createdAt: number;
updatedAt: number;
}>;
nextCursor?: string | null;
};
export type ClawHubDownloadResult = {
archivePath: string;
integrity: string;
};
type FetchLike = typeof fetch;
type ComparableSemver = {
major: number;
minor: number;
patch: number;
prerelease: string[] | null;
};
type ClawHubRequestParams = {
baseUrl?: string;
path: string;
token?: string;
timeoutMs?: number;
search?: Record<string, string | undefined>;
fetchImpl?: FetchLike;
};
function normalizeBaseUrl(baseUrl?: string): string {
const envValue =
process.env.OPENCLAW_CLAWHUB_URL?.trim() ||
process.env.CLAWHUB_URL?.trim() ||
DEFAULT_CLAWHUB_URL;
const value = (baseUrl?.trim() || envValue).replace(/\/+$/, "");
return value || DEFAULT_CLAWHUB_URL;
}
function parseComparableSemver(version: string | null | undefined): ComparableSemver | null {
if (!version) {
return null;
}
const normalized = version.trim();
const match = /^v?([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(
normalized,
);
if (!match) {
return null;
}
const [, major, minor, patch, prereleaseRaw] = match;
if (!major || !minor || !patch) {
return null;
}
return {
major: Number.parseInt(major, 10),
minor: Number.parseInt(minor, 10),
patch: Number.parseInt(patch, 10),
prerelease: prereleaseRaw ? prereleaseRaw.split(".").filter(Boolean) : null,
};
}
function comparePrerelease(a: string[] | null, b: string[] | null): number {
if (!a?.length && !b?.length) {
return 0;
}
if (!a?.length) {
return 1;
}
if (!b?.length) {
return -1;
}
const max = Math.max(a.length, b.length);
for (let i = 0; i < max; i += 1) {
const ai = a[i];
const bi = b[i];
if (ai == null && bi == null) {
return 0;
}
if (ai == null) {
return -1;
}
if (bi == null) {
return 1;
}
const aNum = /^[0-9]+$/.test(ai) ? Number.parseInt(ai, 10) : null;
const bNum = /^[0-9]+$/.test(bi) ? Number.parseInt(bi, 10) : null;
if (aNum != null && bNum != null) {
if (aNum !== bNum) {
return aNum < bNum ? -1 : 1;
}
continue;
}
if (aNum != null) {
return -1;
}
if (bNum != null) {
return 1;
}
if (ai !== bi) {
return ai < bi ? -1 : 1;
}
}
return 0;
}
function compareSemver(left: string, right: string): number | null {
const a = parseComparableSemver(left);
const b = parseComparableSemver(right);
if (!a || !b) {
return null;
}
if (a.major !== b.major) {
return a.major < b.major ? -1 : 1;
}
if (a.minor !== b.minor) {
return a.minor < b.minor ? -1 : 1;
}
if (a.patch !== b.patch) {
return a.patch < b.patch ? -1 : 1;
}
return comparePrerelease(a.prerelease, b.prerelease);
}
function upperBoundForCaret(version: string): string | null {
const parsed = parseComparableSemver(version);
if (!parsed) {
return null;
}
if (parsed.major > 0) {
return `${parsed.major + 1}.0.0`;
}
if (parsed.minor > 0) {
return `0.${parsed.minor + 1}.0`;
}
return `0.0.${parsed.patch + 1}`;
}
function satisfiesComparator(version: string, token: string): boolean {
const trimmed = token.trim();
if (!trimmed) {
return true;
}
if (trimmed.startsWith("^")) {
const base = trimmed.slice(1).trim();
const upperBound = upperBoundForCaret(base);
const lowerCmp = compareSemver(version, base);
const upperCmp = upperBound ? compareSemver(version, upperBound) : null;
return lowerCmp != null && upperCmp != null && lowerCmp >= 0 && upperCmp < 0;
}
const match = /^(>=|<=|>|<|=)?\s*(.+)$/.exec(trimmed);
if (!match) {
return false;
}
const operator = match[1] ?? "=";
const target = match[2]?.trim();
if (!target) {
return false;
}
const cmp = compareSemver(version, target);
if (cmp == null) {
return false;
}
switch (operator) {
case ">=":
return cmp >= 0;
case "<=":
return cmp <= 0;
case ">":
return cmp > 0;
case "<":
return cmp < 0;
case "=":
default:
return cmp === 0;
}
}
function satisfiesSemverRange(version: string, range: string): boolean {
const tokens = range
.trim()
.split(/\s+/)
.map((token) => token.trim())
.filter(Boolean);
if (tokens.length === 0) {
return false;
}
return tokens.every((token) => satisfiesComparator(version, token));
}
function buildUrl(params: Pick<ClawHubRequestParams, "baseUrl" | "path" | "search">): URL {
const url = new URL(params.path, `${normalizeBaseUrl(params.baseUrl)}/`);
for (const [key, value] of Object.entries(params.search ?? {})) {
if (!value) {
continue;
}
url.searchParams.set(key, value);
}
return url;
}
async function clawhubRequest(
params: ClawHubRequestParams,
): Promise<{ response: Response; url: URL }> {
const url = buildUrl(params);
const controller = new AbortController();
const timeout = setTimeout(
() =>
controller.abort(
new Error(
`ClawHub request timed out after ${params.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS}ms`,
),
),
params.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS,
);
try {
const response = await (params.fetchImpl ?? fetch)(url, {
headers: params.token ? { Authorization: `Bearer ${params.token}` } : undefined,
signal: controller.signal,
});
return { response, url };
} finally {
clearTimeout(timeout);
}
}
async function readErrorBody(response: Response): Promise<string> {
try {
const text = (await response.text()).trim();
return text || response.statusText || `HTTP ${response.status}`;
} catch {
return response.statusText || `HTTP ${response.status}`;
}
}
async function fetchJson<T>(params: ClawHubRequestParams): Promise<T> {
const { response, url } = await clawhubRequest(params);
if (!response.ok) {
throw new Error(
`ClawHub ${url.pathname} failed (${response.status}): ${await readErrorBody(response)}`,
);
}
return (await response.json()) as T;
}
export function resolveClawHubBaseUrl(baseUrl?: string): string {
return normalizeBaseUrl(baseUrl);
}
export function formatSha256Integrity(bytes: Uint8Array): string {
const digest = createHash("sha256").update(bytes).digest("base64");
return `sha256-${digest}`;
}
export function parseClawHubPluginSpec(raw: string): {
name: string;
version?: string;
baseUrl?: string;
} | null {
const trimmed = raw.trim();
if (!trimmed.toLowerCase().startsWith("clawhub:")) {
return null;
}
const spec = trimmed.slice("clawhub:".length).trim();
if (!spec) {
return null;
}
const atIndex = spec.lastIndexOf("@");
if (atIndex <= 0 || atIndex >= spec.length - 1) {
return { name: spec };
}
return {
name: spec.slice(0, atIndex).trim(),
version: spec.slice(atIndex + 1).trim() || undefined,
};
}
export async function fetchClawHubPackageDetail(params: {
name: string;
baseUrl?: string;
token?: string;
timeoutMs?: number;
fetchImpl?: FetchLike;
}): Promise<ClawHubPackageDetail> {
return await fetchJson<ClawHubPackageDetail>({
baseUrl: params.baseUrl,
path: `/api/v1/packages/${encodeURIComponent(params.name)}`,
token: params.token,
timeoutMs: params.timeoutMs,
fetchImpl: params.fetchImpl,
});
}
export async function fetchClawHubPackageVersion(params: {
name: string;
version: string;
baseUrl?: string;
token?: string;
timeoutMs?: number;
fetchImpl?: FetchLike;
}): Promise<ClawHubPackageVersion> {
return await fetchJson<ClawHubPackageVersion>({
baseUrl: params.baseUrl,
path: `/api/v1/packages/${encodeURIComponent(params.name)}/versions/${encodeURIComponent(
params.version,
)}`,
token: params.token,
timeoutMs: params.timeoutMs,
fetchImpl: params.fetchImpl,
});
}
export async function searchClawHubPackages(params: {
query: string;
family?: ClawHubPackageFamily;
baseUrl?: string;
token?: string;
timeoutMs?: number;
fetchImpl?: FetchLike;
limit?: number;
}): Promise<ClawHubPackageSearchResult[]> {
const result = await fetchJson<{ results: ClawHubPackageSearchResult[] }>({
baseUrl: params.baseUrl,
path: "/api/v1/packages/search",
token: params.token,
timeoutMs: params.timeoutMs,
fetchImpl: params.fetchImpl,
search: {
q: params.query.trim(),
family: params.family,
limit: params.limit ? String(params.limit) : undefined,
},
});
return result.results ?? [];
}
export async function searchClawHubSkills(params: {
query: string;
baseUrl?: string;
token?: string;
timeoutMs?: number;
fetchImpl?: FetchLike;
limit?: number;
}): Promise<ClawHubSkillSearchResult[]> {
const result = await fetchJson<{ results: ClawHubSkillSearchResult[] }>({
baseUrl: params.baseUrl,
path: "/api/v1/search",
token: params.token,
timeoutMs: params.timeoutMs,
fetchImpl: params.fetchImpl,
search: {
q: params.query.trim(),
limit: params.limit ? String(params.limit) : undefined,
},
});
return result.results ?? [];
}
export async function fetchClawHubSkillDetail(params: {
slug: string;
baseUrl?: string;
token?: string;
timeoutMs?: number;
fetchImpl?: FetchLike;
}): Promise<ClawHubSkillDetail> {
return await fetchJson<ClawHubSkillDetail>({
baseUrl: params.baseUrl,
path: `/api/v1/skills/${encodeURIComponent(params.slug)}`,
token: params.token,
timeoutMs: params.timeoutMs,
fetchImpl: params.fetchImpl,
});
}
export async function listClawHubSkills(params: {
baseUrl?: string;
token?: string;
timeoutMs?: number;
fetchImpl?: FetchLike;
limit?: number;
}): Promise<ClawHubSkillListResponse> {
return await fetchJson<ClawHubSkillListResponse>({
baseUrl: params.baseUrl,
path: "/api/v1/skills",
token: params.token,
timeoutMs: params.timeoutMs,
fetchImpl: params.fetchImpl,
search: {
limit: params.limit ? String(params.limit) : undefined,
},
});
}
export async function downloadClawHubPackageArchive(params: {
name: string;
version?: string;
tag?: string;
baseUrl?: string;
token?: string;
timeoutMs?: number;
fetchImpl?: FetchLike;
}): Promise<ClawHubDownloadResult> {
const search = params.version
? { version: params.version }
: params.tag
? { tag: params.tag }
: undefined;
const { response, url } = await clawhubRequest({
baseUrl: params.baseUrl,
path: `/api/v1/packages/${encodeURIComponent(params.name)}/download`,
search,
token: params.token,
timeoutMs: params.timeoutMs,
fetchImpl: params.fetchImpl,
});
if (!response.ok) {
throw new Error(
`ClawHub ${url.pathname} failed (${response.status}): ${await readErrorBody(response)}`,
);
}
const bytes = new Uint8Array(await response.arrayBuffer());
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-clawhub-package-"));
const archivePath = path.join(tmpDir, `${params.name}.zip`);
await fs.writeFile(archivePath, bytes);
return {
archivePath,
integrity: formatSha256Integrity(bytes),
};
}
export async function downloadClawHubSkillArchive(params: {
slug: string;
version?: string;
tag?: string;
baseUrl?: string;
token?: string;
timeoutMs?: number;
fetchImpl?: FetchLike;
}): Promise<ClawHubDownloadResult> {
const { response, url } = await clawhubRequest({
baseUrl: params.baseUrl,
path: "/api/v1/download",
token: params.token,
timeoutMs: params.timeoutMs,
fetchImpl: params.fetchImpl,
search: {
slug: params.slug,
version: params.version,
tag: params.version ? undefined : params.tag,
},
});
if (!response.ok) {
throw new Error(
`ClawHub ${url.pathname} failed (${response.status}): ${await readErrorBody(response)}`,
);
}
const bytes = new Uint8Array(await response.arrayBuffer());
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-clawhub-skill-"));
const archivePath = path.join(tmpDir, `${params.slug}.zip`);
await fs.writeFile(archivePath, bytes);
return {
archivePath,
integrity: formatSha256Integrity(bytes),
};
}
export function resolveLatestVersionFromPackage(detail: ClawHubPackageDetail): string | null {
return detail.package?.latestVersion ?? detail.package?.tags?.latest ?? null;
}
export function isClawHubFamilySkill(detail: ClawHubPackageDetail | ClawHubSkillDetail): boolean {
if ("package" in detail) {
return detail.package?.family === "skill";
}
return Boolean(detail.skill);
}
export function satisfiesPluginApiRange(
pluginApiVersion: string,
pluginApiRange?: string | null,
): boolean {
if (!pluginApiRange) {
return true;
}
return satisfiesSemverRange(pluginApiVersion, pluginApiRange);
}
export function satisfiesGatewayMinimum(
currentVersion: string,
minGatewayVersion?: string | null,
): boolean {
if (!minGatewayVersion) {
return true;
}
const current = parseSemver(currentVersion);
const minimum = parseSemver(minGatewayVersion);
if (!current || !minimum) {
return false;
}
return isAtLeast(current, minimum);
}

154
src/plugins/clawhub.test.ts Normal file
View File

@ -0,0 +1,154 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const parseClawHubPluginSpecMock = vi.fn();
const fetchClawHubPackageDetailMock = vi.fn();
const fetchClawHubPackageVersionMock = vi.fn();
const downloadClawHubPackageArchiveMock = vi.fn();
const resolveLatestVersionFromPackageMock = vi.fn();
const satisfiesPluginApiRangeMock = vi.fn();
const satisfiesGatewayMinimumMock = vi.fn();
const resolveRuntimeServiceVersionMock = vi.fn();
const installPluginFromArchiveMock = vi.fn();
vi.mock("../infra/clawhub.js", () => ({
parseClawHubPluginSpec: (...args: unknown[]) => parseClawHubPluginSpecMock(...args),
fetchClawHubPackageDetail: (...args: unknown[]) => fetchClawHubPackageDetailMock(...args),
fetchClawHubPackageVersion: (...args: unknown[]) => fetchClawHubPackageVersionMock(...args),
downloadClawHubPackageArchive: (...args: unknown[]) => downloadClawHubPackageArchiveMock(...args),
resolveLatestVersionFromPackage: (...args: unknown[]) =>
resolveLatestVersionFromPackageMock(...args),
satisfiesPluginApiRange: (...args: unknown[]) => satisfiesPluginApiRangeMock(...args),
satisfiesGatewayMinimum: (...args: unknown[]) => satisfiesGatewayMinimumMock(...args),
}));
vi.mock("../version.js", () => ({
resolveRuntimeServiceVersion: (...args: unknown[]) => resolveRuntimeServiceVersionMock(...args),
}));
vi.mock("./install.js", () => ({
installPluginFromArchive: (...args: unknown[]) => installPluginFromArchiveMock(...args),
}));
const { formatClawHubSpecifier, installPluginFromClawHub } = await import("./clawhub.js");
describe("installPluginFromClawHub", () => {
beforeEach(() => {
parseClawHubPluginSpecMock.mockReset();
fetchClawHubPackageDetailMock.mockReset();
fetchClawHubPackageVersionMock.mockReset();
downloadClawHubPackageArchiveMock.mockReset();
resolveLatestVersionFromPackageMock.mockReset();
satisfiesPluginApiRangeMock.mockReset();
satisfiesGatewayMinimumMock.mockReset();
resolveRuntimeServiceVersionMock.mockReset();
installPluginFromArchiveMock.mockReset();
parseClawHubPluginSpecMock.mockReturnValue({ name: "demo" });
fetchClawHubPackageDetailMock.mockResolvedValue({
package: {
name: "demo",
displayName: "Demo",
family: "code-plugin",
channel: "official",
isOfficial: true,
createdAt: 0,
updatedAt: 0,
compatibility: {
pluginApiRange: "^1.2.0",
minGatewayVersion: "2026.3.0",
},
},
});
resolveLatestVersionFromPackageMock.mockReturnValue("1.2.3");
fetchClawHubPackageVersionMock.mockResolvedValue({
version: {
version: "1.2.3",
createdAt: 0,
changelog: "",
compatibility: {
pluginApiRange: "^1.2.0",
minGatewayVersion: "2026.3.0",
},
},
});
downloadClawHubPackageArchiveMock.mockResolvedValue({
archivePath: "/tmp/clawhub-demo/archive.zip",
integrity: "sha256-demo",
});
satisfiesPluginApiRangeMock.mockReturnValue(true);
resolveRuntimeServiceVersionMock.mockReturnValue("2026.3.14");
satisfiesGatewayMinimumMock.mockReturnValue(true);
installPluginFromArchiveMock.mockResolvedValue({
ok: true,
pluginId: "demo",
targetDir: "/tmp/openclaw/plugins/demo",
version: "1.2.3",
});
});
it("formats clawhub specifiers", () => {
expect(formatClawHubSpecifier({ name: "demo" })).toBe("clawhub:demo");
expect(formatClawHubSpecifier({ name: "demo", version: "1.2.3" })).toBe("clawhub:demo@1.2.3");
});
it("installs a ClawHub code plugin through the archive installer", async () => {
const info = vi.fn();
const warn = vi.fn();
const result = await installPluginFromClawHub({
spec: "clawhub:demo",
baseUrl: "https://clawhub.ai",
logger: { info, warn },
});
expect(fetchClawHubPackageDetailMock).toHaveBeenCalledWith(
expect.objectContaining({
name: "demo",
baseUrl: "https://clawhub.ai",
}),
);
expect(fetchClawHubPackageVersionMock).toHaveBeenCalledWith(
expect.objectContaining({
name: "demo",
version: "1.2.3",
}),
);
expect(installPluginFromArchiveMock).toHaveBeenCalledWith(
expect.objectContaining({
archivePath: "/tmp/clawhub-demo/archive.zip",
}),
);
expect(result).toMatchObject({
ok: true,
pluginId: "demo",
version: "1.2.3",
clawhub: {
source: "clawhub",
clawhubPackage: "demo",
clawhubFamily: "code-plugin",
clawhubChannel: "official",
integrity: "sha256-demo",
},
});
expect(info).toHaveBeenCalledWith("ClawHub code-plugin demo@1.2.3 channel=official");
expect(info).toHaveBeenCalledWith("Compatibility: pluginApi=^1.2.0 minGateway=2026.3.0");
expect(warn).not.toHaveBeenCalled();
});
it("rejects skill families and redirects to skills install", async () => {
fetchClawHubPackageDetailMock.mockResolvedValueOnce({
package: {
name: "calendar",
displayName: "Calendar",
family: "skill",
channel: "official",
isOfficial: true,
createdAt: 0,
updatedAt: 0,
},
});
await expect(installPluginFromClawHub({ spec: "clawhub:calendar" })).rejects.toThrow(
'Use "openclaw skills install calendar" instead.',
);
});
});

250
src/plugins/clawhub.ts Normal file
View File

@ -0,0 +1,250 @@
import fs from "node:fs/promises";
import path from "node:path";
import {
downloadClawHubPackageArchive,
fetchClawHubPackageDetail,
fetchClawHubPackageVersion,
parseClawHubPluginSpec,
resolveLatestVersionFromPackage,
satisfiesGatewayMinimum,
satisfiesPluginApiRange,
type ClawHubPackageChannel,
type ClawHubPackageDetail,
type ClawHubPackageFamily,
} from "../infra/clawhub.js";
import { resolveRuntimeServiceVersion } from "../version.js";
import { installPluginFromArchive, type InstallPluginResult } from "./install.js";
export const OPENCLAW_PLUGIN_API_VERSION = "1.2.0";
type PluginInstallLogger = {
info?: (message: string) => void;
warn?: (message: string) => void;
};
export type ClawHubPluginInstallRecordFields = {
source: "clawhub";
clawhubUrl: string;
clawhubPackage: string;
clawhubFamily: Exclude<ClawHubPackageFamily, "skill">;
clawhubChannel?: ClawHubPackageChannel;
version?: string;
integrity?: string;
resolvedAt?: string;
installedAt?: string;
};
export function formatClawHubSpecifier(params: { name: string; version?: string }): string {
return `clawhub:${params.name}${params.version ? `@${params.version}` : ""}`;
}
function resolveRequestedVersion(params: {
detail: ClawHubPackageDetail;
requestedVersion?: string;
}): string | null {
if (params.requestedVersion) {
return params.requestedVersion;
}
return resolveLatestVersionFromPackage(params.detail);
}
async function resolveCompatiblePackageVersion(params: {
detail: ClawHubPackageDetail;
requestedVersion?: string;
baseUrl?: string;
token?: string;
}): Promise<{
version: string;
compatibility?: {
pluginApiRange?: string;
minGatewayVersion?: string;
} | null;
}> {
const version = resolveRequestedVersion(params);
if (!version) {
throw new Error(
`ClawHub package "${params.detail.package?.name ?? "unknown"}" has no installable version.`,
);
}
const versionDetail = await fetchClawHubPackageVersion({
name: params.detail.package?.name ?? "",
version,
baseUrl: params.baseUrl,
token: params.token,
});
return {
version,
compatibility:
versionDetail.version?.compatibility ?? params.detail.package?.compatibility ?? null,
};
}
function validateClawHubPluginPackage(params: {
detail: ClawHubPackageDetail;
compatibility?: {
pluginApiRange?: string;
minGatewayVersion?: string;
} | null;
}) {
const pkg = params.detail.package;
if (!pkg) {
throw new Error("Package not found on ClawHub.");
}
if (pkg.family === "skill") {
throw new Error(`"${pkg.name}" is a skill. Use "openclaw skills install ${pkg.name}" instead.`);
}
if (pkg.family !== "code-plugin" && pkg.family !== "bundle-plugin") {
throw new Error(`Unsupported ClawHub package family: ${String(pkg.family)}`);
}
if (pkg.channel === "private") {
throw new Error(`"${pkg.name}" is private on ClawHub and cannot be installed anonymously.`);
}
const compatibility = params.compatibility;
if (
compatibility?.pluginApiRange &&
!satisfiesPluginApiRange(OPENCLAW_PLUGIN_API_VERSION, compatibility.pluginApiRange)
) {
throw new Error(
`Plugin "${pkg.name}" requires plugin API ${compatibility.pluginApiRange}, but this OpenClaw runtime exposes ${OPENCLAW_PLUGIN_API_VERSION}.`,
);
}
const runtimeVersion = resolveRuntimeServiceVersion();
if (
compatibility?.minGatewayVersion &&
!satisfiesGatewayMinimum(runtimeVersion, compatibility.minGatewayVersion)
) {
throw new Error(
`Plugin "${pkg.name}" requires OpenClaw >=${compatibility.minGatewayVersion}, but this host is ${runtimeVersion}.`,
);
}
}
function logClawHubPackageSummary(params: {
detail: ClawHubPackageDetail;
version: string;
logger?: PluginInstallLogger;
}) {
const pkg = params.detail.package;
if (!pkg) {
return;
}
const verification = pkg.verification?.tier ? ` verification=${pkg.verification.tier}` : "";
params.logger?.info?.(
`ClawHub ${pkg.family} ${pkg.name}@${params.version} channel=${pkg.channel}${verification}`,
);
const compatibilityParts = [
pkg.compatibility?.pluginApiRange ? `pluginApi=${pkg.compatibility.pluginApiRange}` : null,
pkg.compatibility?.minGatewayVersion
? `minGateway=${pkg.compatibility.minGatewayVersion}`
: null,
].filter(Boolean);
if (compatibilityParts.length > 0) {
params.logger?.info?.(`Compatibility: ${compatibilityParts.join(" ")}`);
}
if (pkg.channel !== "official") {
params.logger?.warn?.(
`ClawHub package "${pkg.name}" is ${pkg.channel}; review source and verification before enabling.`,
);
}
}
export async function installPluginFromClawHub(params: {
spec: string;
baseUrl?: string;
token?: string;
logger?: PluginInstallLogger;
mode?: "install" | "update";
dryRun?: boolean;
expectedPluginId?: string;
}): Promise<
| ({
ok: true;
} & Extract<InstallPluginResult, { ok: true }> & {
clawhub: ClawHubPluginInstallRecordFields;
packageName: string;
})
| Extract<InstallPluginResult, { ok: false }>
> {
const parsed = parseClawHubPluginSpec(params.spec);
if (!parsed?.name) {
return {
ok: false,
error: `invalid ClawHub plugin spec: ${params.spec}`,
};
}
params.logger?.info?.(`Resolving ${formatClawHubSpecifier(parsed)}`);
const detail = await fetchClawHubPackageDetail({
name: parsed.name,
baseUrl: params.baseUrl,
token: params.token,
});
const versionState = await resolveCompatiblePackageVersion({
detail,
requestedVersion: parsed.version,
baseUrl: params.baseUrl,
token: params.token,
});
validateClawHubPluginPackage({
detail,
compatibility: versionState.compatibility,
});
logClawHubPackageSummary({
detail,
version: versionState.version,
logger: params.logger,
});
const archive = await downloadClawHubPackageArchive({
name: parsed.name,
version: versionState.version,
baseUrl: params.baseUrl,
token: params.token,
});
try {
params.logger?.info?.(
`Downloading ${detail.package?.family === "bundle-plugin" ? "bundle" : "plugin"} ${parsed.name}@${versionState.version} from ClawHub…`,
);
const installResult = await installPluginFromArchive({
archivePath: archive.archivePath,
logger: params.logger,
mode: params.mode,
dryRun: params.dryRun,
expectedPluginId: params.expectedPluginId,
});
if (!installResult.ok) {
return installResult;
}
const pkg = detail.package!;
const clawhubFamily =
pkg.family === "code-plugin" || pkg.family === "bundle-plugin" ? pkg.family : null;
if (!clawhubFamily) {
throw new Error(`Unsupported ClawHub package family: ${pkg.family}`);
}
return {
...installResult,
packageName: parsed.name,
clawhub: {
source: "clawhub",
clawhubUrl:
params.baseUrl?.trim() ||
process.env.OPENCLAW_CLAWHUB_URL?.trim() ||
"https://clawhub.ai",
clawhubPackage: parsed.name,
clawhubFamily,
clawhubChannel: pkg.channel,
version: installResult.version ?? versionState.version,
integrity: archive.integrity,
resolvedAt: new Date().toISOString(),
},
};
} finally {
await fs.rm(archive.archivePath, { force: true }).catch(() => undefined);
await fs
.rm(path.dirname(archive.archivePath), { recursive: true, force: true })
.catch(() => undefined);
}
}

View File

@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const installPluginFromNpmSpecMock = vi.fn();
const installPluginFromMarketplaceMock = vi.fn();
const installPluginFromClawHubMock = vi.fn();
const resolveBundledPluginSourcesMock = vi.fn();
vi.mock("./install.js", () => ({
@ -16,6 +17,10 @@ vi.mock("./marketplace.js", () => ({
installPluginFromMarketplace: (...args: unknown[]) => installPluginFromMarketplaceMock(...args),
}));
vi.mock("./clawhub.js", () => ({
installPluginFromClawHub: (...args: unknown[]) => installPluginFromClawHubMock(...args),
}));
vi.mock("./bundled-sources.js", () => ({
resolveBundledPluginSources: (...args: unknown[]) => resolveBundledPluginSourcesMock(...args),
}));
@ -26,6 +31,7 @@ describe("updateNpmInstalledPlugins", () => {
beforeEach(() => {
installPluginFromNpmSpecMock.mockReset();
installPluginFromMarketplaceMock.mockReset();
installPluginFromClawHubMock.mockReset();
resolveBundledPluginSourcesMock.mockReset();
});
@ -248,6 +254,62 @@ describe("updateNpmInstalledPlugins", () => {
});
});
it("updates ClawHub-installed plugins via recorded package metadata", async () => {
installPluginFromClawHubMock.mockResolvedValue({
ok: true,
pluginId: "demo",
targetDir: "/tmp/demo",
version: "1.2.4",
clawhub: {
source: "clawhub",
clawhubUrl: "https://clawhub.ai",
clawhubPackage: "demo",
clawhubFamily: "code-plugin",
clawhubChannel: "official",
integrity: "sha256-next",
resolvedAt: "2026-03-22T00:00:00.000Z",
},
});
const result = await updateNpmInstalledPlugins({
config: {
plugins: {
installs: {
demo: {
source: "clawhub",
spec: "clawhub:demo",
installPath: "/tmp/demo",
clawhubUrl: "https://clawhub.ai",
clawhubPackage: "demo",
clawhubFamily: "code-plugin",
clawhubChannel: "official",
},
},
},
},
pluginIds: ["demo"],
});
expect(installPluginFromClawHubMock).toHaveBeenCalledWith(
expect.objectContaining({
spec: "clawhub:demo",
baseUrl: "https://clawhub.ai",
expectedPluginId: "demo",
mode: "update",
}),
);
expect(result.config.plugins?.installs?.demo).toMatchObject({
source: "clawhub",
spec: "clawhub:demo",
installPath: "/tmp/demo",
version: "1.2.4",
clawhubPackage: "demo",
clawhubFamily: "code-plugin",
clawhubChannel: "official",
integrity: "sha256-next",
});
});
it("skips recorded integrity checks when an explicit npm version override changes the spec", async () => {
installPluginFromNpmSpecMock.mockResolvedValue({
ok: true,

View File

@ -5,6 +5,7 @@ import { openBoundaryFileSync } from "../infra/boundary-file-read.js";
import type { UpdateChannel } from "../infra/update-channels.js";
import { resolveUserPath } from "../utils.js";
import { resolveBundledPluginSources } from "./bundled-sources.js";
import { installPluginFromClawHub } from "./clawhub.js";
import {
installPluginFromNpmSpec,
PLUGIN_INSTALL_ERROR_CODE,
@ -84,6 +85,15 @@ function formatMarketplaceInstallFailure(params: {
);
}
function formatClawHubInstallFailure(params: {
pluginId: string;
spec: string;
phase: "check" | "update";
error: string;
}): string {
return `Failed to ${params.phase} ${params.pluginId}: ${params.error} (ClawHub ${params.spec}).`;
}
type InstallIntegrityDrift = {
spec: string;
expectedIntegrity: string;
@ -321,7 +331,7 @@ export async function updateNpmInstalledPlugins(params: {
continue;
}
if (record.source !== "npm" && record.source !== "marketplace") {
if (record.source !== "npm" && record.source !== "marketplace" && record.source !== "clawhub") {
outcomes.push({
pluginId,
status: "skipped",
@ -331,7 +341,7 @@ export async function updateNpmInstalledPlugins(params: {
}
const effectiveSpec =
record.source === "npm" ? (params.specOverrides?.[pluginId] ?? record.spec) : undefined;
record.source === "npm" ? (params.specOverrides?.[pluginId] ?? record.spec) : record.spec;
const expectedIntegrity =
record.source === "npm" && effectiveSpec === record.spec
? expectedIntegrityForUpdate(record.spec, record.integrity)
@ -346,6 +356,15 @@ export async function updateNpmInstalledPlugins(params: {
continue;
}
if (record.source === "clawhub" && !record.clawhubPackage) {
outcomes.push({
pluginId,
status: "skipped",
message: `Skipping "${pluginId}" (missing ClawHub package metadata).`,
});
continue;
}
if (
record.source === "marketplace" &&
(!record.marketplaceSource || !record.marketplacePlugin)
@ -374,6 +393,7 @@ export async function updateNpmInstalledPlugins(params: {
if (params.dryRun) {
let probe:
| Awaited<ReturnType<typeof installPluginFromNpmSpec>>
| Awaited<ReturnType<typeof installPluginFromClawHub>>
| Awaited<ReturnType<typeof installPluginFromMarketplace>>;
try {
probe =
@ -392,14 +412,23 @@ export async function updateNpmInstalledPlugins(params: {
}),
logger,
})
: await installPluginFromMarketplace({
marketplace: record.marketplaceSource!,
plugin: record.marketplacePlugin!,
mode: "update",
dryRun: true,
expectedPluginId: pluginId,
logger,
});
: record.source === "clawhub"
? await installPluginFromClawHub({
spec: effectiveSpec ?? `clawhub:${record.clawhubPackage!}`,
baseUrl: record.clawhubUrl,
mode: "update",
dryRun: true,
expectedPluginId: pluginId,
logger,
})
: await installPluginFromMarketplace({
marketplace: record.marketplaceSource!,
plugin: record.marketplacePlugin!,
mode: "update",
dryRun: true,
expectedPluginId: pluginId,
logger,
});
} catch (err) {
outcomes.push({
pluginId,
@ -420,13 +449,20 @@ export async function updateNpmInstalledPlugins(params: {
phase: "check",
result: probe,
})
: formatMarketplaceInstallFailure({
pluginId,
marketplaceSource: record.marketplaceSource!,
marketplacePlugin: record.marketplacePlugin!,
phase: "check",
error: probe.error,
}),
: record.source === "clawhub"
? formatClawHubInstallFailure({
pluginId,
spec: effectiveSpec ?? `clawhub:${record.clawhubPackage!}`,
phase: "check",
error: probe.error,
})
: formatMarketplaceInstallFailure({
pluginId,
marketplaceSource: record.marketplaceSource!,
marketplacePlugin: record.marketplacePlugin!,
phase: "check",
error: probe.error,
}),
});
continue;
}
@ -455,6 +491,7 @@ export async function updateNpmInstalledPlugins(params: {
let result:
| Awaited<ReturnType<typeof installPluginFromNpmSpec>>
| Awaited<ReturnType<typeof installPluginFromClawHub>>
| Awaited<ReturnType<typeof installPluginFromMarketplace>>;
try {
result =
@ -472,13 +509,21 @@ export async function updateNpmInstalledPlugins(params: {
}),
logger,
})
: await installPluginFromMarketplace({
marketplace: record.marketplaceSource!,
plugin: record.marketplacePlugin!,
mode: "update",
expectedPluginId: pluginId,
logger,
});
: record.source === "clawhub"
? await installPluginFromClawHub({
spec: effectiveSpec ?? `clawhub:${record.clawhubPackage!}`,
baseUrl: record.clawhubUrl,
mode: "update",
expectedPluginId: pluginId,
logger,
})
: await installPluginFromMarketplace({
marketplace: record.marketplaceSource!,
plugin: record.marketplacePlugin!,
mode: "update",
expectedPluginId: pluginId,
logger,
});
} catch (err) {
outcomes.push({
pluginId,
@ -499,13 +544,20 @@ export async function updateNpmInstalledPlugins(params: {
phase: "update",
result: result,
})
: formatMarketplaceInstallFailure({
pluginId,
marketplaceSource: record.marketplaceSource!,
marketplacePlugin: record.marketplacePlugin!,
phase: "update",
error: result.error,
}),
: record.source === "clawhub"
? formatClawHubInstallFailure({
pluginId,
spec: effectiveSpec ?? `clawhub:${record.clawhubPackage!}`,
phase: "update",
error: result.error,
})
: formatMarketplaceInstallFailure({
pluginId,
marketplaceSource: record.marketplaceSource!,
marketplacePlugin: record.marketplacePlugin!,
phase: "update",
error: result.error,
}),
});
continue;
}
@ -525,6 +577,24 @@ export async function updateNpmInstalledPlugins(params: {
version: nextVersion,
...buildNpmResolutionInstallFields(result.npmResolution),
});
} else if (record.source === "clawhub") {
const clawhubResult = result as Extract<
Awaited<ReturnType<typeof installPluginFromClawHub>>,
{ ok: true }
>;
next = recordPluginInstall(next, {
pluginId: resolvedPluginId,
source: "clawhub",
spec: effectiveSpec ?? record.spec ?? `clawhub:${record.clawhubPackage!}`,
installPath: result.targetDir,
version: nextVersion,
integrity: clawhubResult.clawhub.integrity,
resolvedAt: clawhubResult.clawhub.resolvedAt,
clawhubUrl: clawhubResult.clawhub.clawhubUrl,
clawhubPackage: clawhubResult.clawhub.clawhubPackage,
clawhubFamily: clawhubResult.clawhub.clawhubFamily,
clawhubChannel: clawhubResult.clawhub.clawhubChannel,
});
} else {
const marketplaceResult = result as Extract<
Awaited<ReturnType<typeof installPluginFromMarketplace>>,