feat!: prefer clawhub plugin installs before npm

This commit is contained in:
Peter Steinberger 2026-03-22 18:16:11 +00:00
parent 13c239039a
commit 8d9686bd0f
5 changed files with 205 additions and 0 deletions

View File

@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
### Changes
- Breaking/Plugins: bare `openclaw plugins install <package>` 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:<package>` with tracked update metadata, gateway skill-install/update support for ClawHub-backed requests, and regression coverage/docs for the new source path.
- Models/Anthropic Vertex: add core `anthropic-vertex` provider support for Claude via Google Vertex AI, including GCP auth/discovery and main run-path routing. (#43356) Thanks @sallyom and @yossiovadia.
- Commands/btw: add `/btw` side questions for quick tool-less answers about the current session without changing future session context, with dismissible in-session TUI answers and explicit BTW replies on external channels. (#45444) Thanks @ngutman.

View File

@ -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

View File

@ -35,6 +35,12 @@ openclaw plugins install clawhub:<package>
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.

View File

@ -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: {

View File

@ -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(),