mirror of https://github.com/openclaw/openclaw.git
feat: add native clawhub install flows
This commit is contained in:
parent
c7788773bf
commit
91b2800241
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 what’s 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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>>,
|
||||
|
|
|
|||
Loading…
Reference in New Issue