diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cbbd78e5bf..ab6365af7df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Changes +- Breaking/Plugins: bare `openclaw plugins install ` now prefers ClawHub before npm for npm-safe names, and only falls back to npm when ClawHub does not have that package or version. - 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. diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 36c766cd8b7..8241b8c6aa0 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -79,6 +79,13 @@ openclaw plugins install clawhub:openclaw-codex-app-server openclaw plugins install clawhub:openclaw-codex-app-server@1.2.3 ``` +OpenClaw now also prefers ClawHub for bare npm-safe plugin specs. It only falls +back to npm if ClawHub does not have that package or version: + +```bash +openclaw plugins install openclaw-codex-app-server +``` + 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 diff --git a/docs/tools/clawhub.md b/docs/tools/clawhub.md index 3fc9057d314..255ae68794b 100644 --- a/docs/tools/clawhub.md +++ b/docs/tools/clawhub.md @@ -35,6 +35,12 @@ openclaw plugins install clawhub: openclaw plugins update --all ``` +Bare npm-safe plugin specs are also tried against ClawHub before npm: + +```bash +openclaw plugins install openclaw-codex-app-server +``` + Native `openclaw` commands install into your active workspace and persist source metadata so later `update` calls can stay on ClawHub. diff --git a/src/cli/plugins-cli.test.ts b/src/cli/plugins-cli.test.ts index d46e0990260..77b9412590e 100644 --- a/src/cli/plugins-cli.test.ts +++ b/src/cli/plugins-cli.test.ts @@ -340,6 +340,137 @@ describe("plugins cli", () => { expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); }); + it("prefers ClawHub before npm for bare plugin specs", 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", + }, + }, + }, + } as OpenClawConfig; + + loadConfig.mockReturnValue(cfg); + 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: "community", + 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", "demo"]); + + expect(installPluginFromClawHub).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "clawhub:demo", + }), + ); + expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); + expect(writeConfigFile).toHaveBeenCalledWith(installedCfg); + }); + + it("falls back to npm when ClawHub does not have the package", async () => { + const cfg = { + plugins: { + entries: {}, + }, + } as OpenClawConfig; + const enabledCfg = { + plugins: { + entries: { + demo: { + enabled: true, + }, + }, + }, + } as OpenClawConfig; + + loadConfig.mockReturnValue(cfg); + installPluginFromClawHub.mockResolvedValue({ + ok: false, + error: "ClawHub /api/v1/packages/demo failed (404): Package not found", + }); + installPluginFromNpmSpec.mockResolvedValue({ + ok: true, + pluginId: "demo", + targetDir: "/tmp/openclaw-state/extensions/demo", + version: "1.2.3", + npmResolution: { + packageName: "demo", + resolvedVersion: "1.2.3", + tarballUrl: "https://registry.npmjs.org/demo/-/demo-1.2.3.tgz", + }, + }); + enablePluginInConfig.mockReturnValue({ config: enabledCfg }); + recordPluginInstall.mockReturnValue(enabledCfg); + applyExclusiveSlotSelection.mockReturnValue({ + config: enabledCfg, + warnings: [], + }); + + await runCommand(["plugins", "install", "demo"]); + + expect(installPluginFromClawHub).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "clawhub:demo", + }), + ); + expect(installPluginFromNpmSpec).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "demo", + }), + ); + }); + + it("does not fall back to npm when ClawHub rejects a real package", async () => { + installPluginFromClawHub.mockResolvedValue({ + ok: false, + error: 'Use "openclaw skills install demo" instead.', + }); + + await expect(runCommand(["plugins", "install", "demo"])).rejects.toThrow("__exit__:1"); + + expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); + expect(runtimeErrors.at(-1)).toContain('Use "openclaw skills install demo" instead.'); + }); + 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 238edb09296..33e05460cf3 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -289,6 +289,26 @@ function logSlotWarnings(warnings: string[]) { } } +function buildPreferredClawHubSpec(raw: string): string | null { + const parsed = parseRegistryNpmSpec(raw); + if (!parsed) { + return null; + } + return formatClawHubSpecifier({ + name: parsed.name, + version: parsed.selector, + }); +} + +function shouldFallbackFromClawHubToNpm(error: string): boolean { + const normalized = error.trim(); + return ( + /Package not found on ClawHub/i.test(normalized) || + /ClawHub .* failed \(404\)/i.test(normalized) || + /Version not found/i.test(normalized) + ); +} + async function installBundledPluginSource(params: { config: OpenClawConfig; rawSpec: string; @@ -545,6 +565,46 @@ async function runPluginInstallCommand(params: { return; } + const preferredClawHubSpec = buildPreferredClawHubSpec(raw); + if (preferredClawHubSpec) { + const clawhubResult = await installPluginFromClawHub({ + spec: preferredClawHubSpec, + logger: createPluginInstallLogger(), + }); + if (clawhubResult.ok) { + clearPluginManifestRegistryCache(); + + let next = enablePluginInConfig(cfg, clawhubResult.pluginId).config; + next = recordPluginInstall(next, { + pluginId: clawhubResult.pluginId, + source: "clawhub", + spec: formatClawHubSpecifier({ + name: clawhubResult.clawhub.clawhubPackage, + version: clawhubResult.clawhub.version, + }), + installPath: clawhubResult.targetDir, + version: clawhubResult.version, + integrity: clawhubResult.clawhub.integrity, + resolvedAt: clawhubResult.clawhub.resolvedAt, + clawhubUrl: clawhubResult.clawhub.clawhubUrl, + clawhubPackage: clawhubResult.clawhub.clawhubPackage, + clawhubFamily: clawhubResult.clawhub.clawhubFamily, + clawhubChannel: clawhubResult.clawhub.clawhubChannel, + }); + const slotResult = applySlotSelectionForPlugin(next, clawhubResult.pluginId); + next = slotResult.config; + await writeConfigFile(next); + logSlotWarnings(slotResult.warnings); + defaultRuntime.log(`Installed plugin: ${clawhubResult.pluginId}`); + defaultRuntime.log(`Restart the gateway to load plugins.`); + return; + } + if (!shouldFallbackFromClawHubToNpm(clawhubResult.error)) { + defaultRuntime.error(clawhubResult.error); + return defaultRuntime.exit(1); + } + } + const result = await installPluginFromNpmSpec({ spec: raw, logger: createPluginInstallLogger(),