From 91b2800241c191e0fa77643f44d5cc069040898e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Mar 2026 17:03:32 +0000 Subject: [PATCH] feat: add native clawhub install flows --- CHANGELOG.md | 1 + docs/cli/index.md | 5 +- docs/cli/plugins.md | 17 +- docs/cli/skills.md | 14 +- docs/help/faq.md | 18 +- docs/tools/clawhub.md | 57 +- docs/tools/skills.md | 15 +- src/agents/skills-clawhub.ts | 398 +++++++++++ src/cli/plugins-cli.test.ts | 147 +++- src/cli/plugins-cli.ts | 47 +- src/cli/skills-cli.commands.test.ts | 203 +++--- src/cli/skills-cli.format.ts | 2 +- src/cli/skills-cli.test.ts | 6 +- src/cli/skills-cli.ts | 121 ++++ src/config/types.installs.ts | 6 +- src/config/zod-schema.installs.ts | 7 + .../protocol/schema/agents-models-skills.ts | 54 +- .../server-methods/skills.clawhub.test.ts | 158 +++++ src/gateway/server-methods/skills.ts | 84 ++- src/infra/clawhub.test.ts | 67 ++ src/infra/clawhub.ts | 654 ++++++++++++++++++ src/plugins/clawhub.test.ts | 154 +++++ src/plugins/clawhub.ts | 250 +++++++ src/plugins/update.test.ts | 62 ++ src/plugins/update.ts | 132 +++- 25 files changed, 2471 insertions(+), 208 deletions(-) create mode 100644 src/agents/skills-clawhub.ts create mode 100644 src/gateway/server-methods/skills.clawhub.test.ts create mode 100644 src/infra/clawhub.test.ts create mode 100644 src/infra/clawhub.ts create mode 100644 src/plugins/clawhub.test.ts create mode 100644 src/plugins/clawhub.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 01ff46d6f0d..763e13ff723 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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:` 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. diff --git a/docs/cli/index.md b/docs/cli/index.md index adca030ce98..a8045998504 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -485,6 +485,9 @@ List and inspect available skills plus readiness info. Subcommands: +- `skills search [query...]`: search ClawHub skills. +- `skills install `: install a skill from ClawHub into the active workspace. +- `skills update `: update tracked ClawHub skills. - `skills list`: list skills (default when no subcommand). - `skills info `: 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` diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 3d4c482707f..36c766cd8b7 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -48,6 +48,7 @@ capabilities. ```bash openclaw plugins install openclaw plugins install --pin +openclaw plugins install clawhub: openclaw plugins install @ openclaw plugins install --marketplace ``` @@ -71,6 +72,18 @@ Supported archives: `.zip`, `.tgz`, `.tar.gz`, `.tar`. Claude marketplace installs are also supported. +ClawHub installs use an explicit `clawhub:` 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 --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 diff --git a/docs/cli/skills.md b/docs/cli/skills.md index 7dcf5a17189..073eb891af9 100644 --- a/docs/cli/skills.md +++ b/docs/cli/skills.md @@ -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 +openclaw skills install --version +openclaw skills update +openclaw skills update --all openclaw skills list openclaw skills list --eligible openclaw skills info 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. diff --git a/docs/help/faq.md b/docs/help/faq.md index fd454baa59e..e620d448c4e 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -992,18 +992,16 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, - 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 + 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. @@ -1075,11 +1073,11 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, Install skills: ```bash - clawhub install - clawhub update --all + openclaw skills install + openclaw skills update --all ``` - ClawHub installs into `./skills` under your current directory (or falls back to your configured OpenClaw workspace); OpenClaw treats that as `/skills` on the next session. For shared skills across agents, place them in `~/.openclaw/skills//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//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). diff --git a/docs/tools/clawhub.md b/docs/tools/clawhub.md index 8fe7fbedd82..3fc9057d314 100644 --- a/docs/tools/clawhub.md +++ b/docs/tools/clawhub.md @@ -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 +openclaw skills update --all +``` + +Plugins: + +```bash +openclaw plugins install clawhub: +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 ` -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 ` +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 `/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 `/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). diff --git a/docs/tools/skills.md b/docs/tools/skills.md index 5b91d79af59..f470e64b349 100644 --- a/docs/tools/skills.md +++ b/docs/tools/skills.md @@ -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 ` + - `openclaw skills install ` - 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 `/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 `/skills` on the next session. ## Security notes diff --git a/src/agents/skills-clawhub.ts b/src/agents/skills-clawhub.ts new file mode 100644 index 00000000000..60f801622f1 --- /dev/null +++ b/src/agents/skills-clawhub.ts @@ -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 { + 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 { + 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; + 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 { + 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 { + 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; + 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 { + 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 { + 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 { + 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 { + 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 { + const lock = await readClawHubSkillsLockfile(workspaceDir); + return Object.keys(lock.skills).toSorted(); +} + +export async function computeSkillFingerprint(skillDir: string): Promise { + 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"); +} diff --git a/src/cli/plugins-cli.test.ts b/src/cli/plugins-cli.test.ts index 4efb1990354..d46e0990260 100644 --- a/src/cli/plugins-cli.test.ts +++ b/src/cli/plugins-cli.test.ts @@ -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(); - 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(); - 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(); - 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(); - 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: { diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 93e3d22c8d5..238edb09296 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -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 (.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) diff --git a/src/cli/skills-cli.commands.test.ts b/src/cli/skills-cli.commands.test.ts index 48b4164903d..58bae163b7b 100644 --- a/src/cli/skills-cli.commands.test.ts +++ b/src/cli/skills-cli.commands.test.ts @@ -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([]); }); }); diff --git a/src/cli/skills-cli.format.ts b/src/cli/skills-cli.format.ts index 045281bc7d1..92af375111c 100644 --- a/src/cli/skills-cli.format.ts +++ b/src/cli/skills-cli.format.ts @@ -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 { diff --git a/src/cli/skills-cli.test.ts b/src/cli/skills-cli.test.ts index 27031fc0fdf..cb48d798a0d 100644 --- a/src/cli/skills-cli.test.ts +++ b/src/cli/skills-cli.test.ts @@ -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", () => { diff --git a/src/cli/skills-cli.ts b/src/cli/skills-cli.ts index 49f288f36c0..3c66a4b156b 100644 --- a/src/cli/skills-cli.ts +++ b/src/cli/skills-cli.ts @@ -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 ", "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("", "ClawHub skill slug") + .option("--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") diff --git a/src/config/types.installs.ts b/src/config/types.installs.ts index dfb7a4dec90..cec0d133dad 100644 --- a/src/config/types.installs.ts +++ b/src/config/types.installs.ts @@ -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"; }; diff --git a/src/config/zod-schema.installs.ts b/src/config/zod-schema.installs.ts index 7270e5c5d28..dc09d3ea48f 100644 --- a/src/config/zod-schema.installs.ts +++ b/src/config/zod-schema.installs.ts @@ -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 = { diff --git a/src/gateway/protocol/schema/agents-models-skills.ts b/src/gateway/protocol/schema/agents-models-skills.ts index 20ce701e08c..5bc57e163d6 100644 --- a/src/gateway/protocol/schema/agents-models-skills.ts +++ b/src/gateway/protocol/schema/agents-models-skills.ts @@ -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 }, ); diff --git a/src/gateway/server-methods/skills.clawhub.test.ts b/src/gateway/server-methods/skills.clawhub.test.ts new file mode 100644 index 00000000000..e881b5d3174 --- /dev/null +++ b/src/gateway/server-methods/skills.clawhub.test.ts @@ -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(); + }); +}); diff --git a/src/gateway/server-methods/skills.ts b/src/gateway/server-methods/skills.ts index 2dbcf9afae4..d083c67b0b8 100644 --- a/src/gateway/server-methods/skills.ts +++ b/src/gateway/server-methods/skills.ts @@ -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; diff --git a/src/infra/clawhub.test.ts b/src/infra/clawhub.test.ts new file mode 100644 index 00000000000..42857effbe6 --- /dev/null +++ b/src/infra/clawhub.test.ts @@ -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); + }); +}); diff --git a/src/infra/clawhub.ts b/src/infra/clawhub.ts new file mode 100644 index 00000000000..c1c702ed909 --- /dev/null +++ b/src/infra/clawhub.ts @@ -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; + 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; + 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; + 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; + 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): 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 { + try { + const text = (await response.text()).trim(); + return text || response.statusText || `HTTP ${response.status}`; + } catch { + return response.statusText || `HTTP ${response.status}`; + } +} + +async function fetchJson(params: ClawHubRequestParams): Promise { + 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 { + return await fetchJson({ + 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 { + return await fetchJson({ + 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 { + 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 { + 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 { + return await fetchJson({ + 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 { + return await fetchJson({ + 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 { + 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 { + 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); +} diff --git a/src/plugins/clawhub.test.ts b/src/plugins/clawhub.test.ts new file mode 100644 index 00000000000..8d371f6fc06 --- /dev/null +++ b/src/plugins/clawhub.test.ts @@ -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.', + ); + }); +}); diff --git a/src/plugins/clawhub.ts b/src/plugins/clawhub.ts new file mode 100644 index 00000000000..8d954c746ec --- /dev/null +++ b/src/plugins/clawhub.ts @@ -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; + 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 & { + clawhub: ClawHubPluginInstallRecordFields; + packageName: string; + }) + | Extract +> { + 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); + } +} diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index 96c15443ded..603835d806e 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -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, diff --git a/src/plugins/update.ts b/src/plugins/update.ts index 6898135e527..ef124acac1f 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -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> + | Awaited> | Awaited>; 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> + | Awaited> | Awaited>; 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>, + { 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>,