fix(plugins): keep bare installs on npm for launch

This commit is contained in:
Vincent Koc
2026-05-02 11:57:07 -07:00
parent a7a6d24147
commit cf21bcf9bf
14 changed files with 64 additions and 286 deletions

View File

@@ -17,7 +17,7 @@ Docs: https://docs.openclaw.ai
### Highlights
- Alpha prerelease support adds the `vYYYY.M.D-alpha.N` tag shape, npm `alpha` dist-tag, release workflow inputs, package acceptance, Telegram package checks, and upgrade-survivor validation paths.
- ClawHub-first plugin installation now covers diagnostics, onboarding, doctor repair, channel setup, install/update records, and artifact metadata while keeping npm fallback for cutovers. Thanks @vincentkoc.
- External plugin installation now covers diagnostics, onboarding, doctor repair, channel setup, install/update records, and artifact metadata while keeping bare package installs on npm for the first cutover. Thanks @vincentkoc.
- Gateway startup, session listing, task maintenance, prompt prep, plugin loading, and filesystem hot paths get targeted cache and fanout reductions for large or plugin-heavy installs.
- Control UI and WebChat reliability improves across Sessions, Cron, long-running Gateway WebSockets, grouped-message width, slash-command feedback, iOS PWA bounds, selection contrast, and Talk diagnostics.
- Channel and provider fixes cover Telegram topic commands and networking, Discord delivery and startup edge cases, OpenAI-compatible TTS/Realtime, OpenRouter/DeepSeek replay, Anthropic-compatible streaming, Brave/SearXNG/Firecrawl web search, and voice-call routing.
@@ -26,7 +26,7 @@ Docs: https://docs.openclaw.ai
- Release: add first-class alpha prerelease support across version parsing, release workflows, package specs, published-package validation, plugin publish planning, and release docs.
- Gateway/startup: skip plugin-backed auth-profile overlays during startup secrets preflight, reducing gateway readiness latency while keeping reload and OAuth recovery paths overlay-capable. (#68327) Thanks @JIRBOY.
- Plugins/ClawHub: make diagnostics, onboarding, doctor repair, and channel setup prefer ClawHub installs while carrying ClawPack metadata through install records and npm fallback paths. Thanks @vincentkoc.
- Plugins/ClawHub: make diagnostics, onboarding, doctor repair, and channel setup carry ClawPack metadata through install records while keeping explicit `clawhub:` installs on ClawHub and bare package installs on npm for the launch cutover. Thanks @vincentkoc.
- Plugins/runtime: scope broad runtime preloads to the effective plugin ids derived from config, startup planning, configured channels, slots, and auto-enable rules instead of importing every discoverable plugin.
- Agents/runtime: reuse the startup-loaded plugin registry for request-time providers, tools, channel actions, web/capability/memory/migration helpers, and memoized provider extra-params so stable embedded-run inputs no longer repeat plugin registry resolution while model-specific transport hook patches stay isolated. Thanks @DmitryPogodaev.
- Agents/runtime: memoize transcript replay-policy resolution for stable config and process-env runs while preserving custom-env provider hook behavior. Thanks @DmitryPogodaev.
@@ -46,8 +46,8 @@ Docs: https://docs.openclaw.ai
- Google Meet: add `googlemeet test-listen` and the matching `google_meet` `test_listen` action so transcribe-mode joins wait for real caption or transcript movement before reporting listen-first health. Refs #72478. Thanks @DougButdorf.
- Plugins/ClawHub: prefer versioned ClawPack artifacts when ClawHub publishes digest metadata, verifying the ClawPack response header and downloaded bytes before installing. Thanks @vincentkoc.
- Plugins/ClawHub: persist ClawPack digest metadata on ClawHub plugin install and update records so registry refreshes and download verification can reuse stored artifact facts. Thanks @vincentkoc.
- Plugins/ClawHub: allow official bundled-plugin cutovers to prefer ClawHub installs with npm fallback only when the ClawHub package or version is absent. Thanks @vincentkoc.
- Plugins/onboarding: allow install-on-demand provider setup entries to prefer ClawHub packages before npm/local fallback and persist ClawHub artifact metadata after install. Thanks @vincentkoc.
- Plugins/ClawHub: allow official bundled-plugin cutovers to record ClawHub artifact metadata while preserving npm as the launch default for bare package specs. Thanks @vincentkoc.
- Plugins/onboarding: allow install-on-demand provider setup entries to persist ClawHub artifact metadata after explicit ClawHub installs while retaining npm/local fallback paths. Thanks @vincentkoc.
- Plugins/Crestodian: add ClawHub plugin search plus Crestodian plugin list/search/install/uninstall operations, with approval and audit coverage for install and uninstall.
- Channels/thread bindings: replace split subagent/ACP thread-spawn toggles with `threadBindings.spawnSessions`, default thread-bound spawns on, and let `openclaw doctor --fix` migrate the legacy keys. (#75943)
- Providers/OpenAI: add `extraBody`/`extra_body` passthrough for OpenAI-compatible TTS endpoints, so custom speech servers can receive fields such as `lang` in `/audio/speech` requests. Fixes #39900. Thanks @R3NK0R.
@@ -60,7 +60,7 @@ Docs: https://docs.openclaw.ai
- Codex/app-server: resolve managed binaries from bundled `dist` chunks and from the `@openai/codex` package bin when installs do not provide a nearby `.bin/codex` shim, avoiding false missing-binary startup failures.
- Plugins/ClawHub: use the ClawHub artifact resolver response as the install decision before downloading, keeping legacy ZIP fallback and future ClawPack npm-pack installs on the same explicit resolver path. Thanks @vincentkoc.
- Plugins/ClawHub: gate bare plugin specs on ClawHub readiness before preferring ClawHub, so packages without deployed ClawPack readiness keep the npm fallback path instead of failing through a half-ready registry route. Thanks @vincentkoc.
- Plugins/ClawHub: keep bare plugin package specs on npm for the launch cutover and reserve ClawHub resolution for explicit `clawhub:` specs until ClawHub pack readiness is deployed. Thanks @vincentkoc.
- Plugins/source checkout: discover source-only plugins such as Codex from the `extensions/*` workspace while using npm package excludes as the packaged-core boundary, removing the stale core-bundle metadata path.
- Plugins/ClawHub: install ClawPack artifacts from the explicit npm-pack `.tgz` resolver path and persist artifact kind, npm integrity, shasum, and tarball metadata for update and diagnostics flows. Thanks @vincentkoc.
- Control UI: allow deployments to configure grouped chat message max-width with a validated `gateway.controlUi.chatMessageMaxWidth` setting instead of patching bundled CSS after upgrades. Fixes #67935. Thanks @xiew4589-lang.
@@ -73,7 +73,7 @@ Docs: https://docs.openclaw.ai
- Agents/failover: exempt run-level timeouts that fire during tool execution from model fallback, timeout-triggered compaction, and generic timeout payload synthesis, avoiding misleading "LLM request timed out" errors after the primary model has already responded. Fixes #52147. (#75873) Thanks @simonusa.
- Docker: copy Bun 1.3.13 from a digest-pinned image and keep CI on the same version. Fixes #74356. Thanks @fede-kamel and @sallyom.
- Agents/compaction: keep prior context on consecutive turns against z.ai-style providers (z.ai direct, openrouter z-ai/\*, in-house GLM gateways), avoiding accidental Pi state reset after successful turns. (#76056) Thanks @openperf.
- Doctor/plugins: run a one-time 2026.5.2 configured-plugin install repair based on `meta.lastTouchedVersion`, installing actively used downloadable OpenClaw plugins from ClawHub with npm fallback before marking the config touched for the release.
- Doctor/plugins: run a one-time 2026.5.2 configured-plugin install repair based on `meta.lastTouchedVersion`, installing actively used downloadable OpenClaw plugins through the configured external source before marking the config touched for the release.
- Sessions/transcripts: use one `session.writeLock.acquireTimeoutMs` policy for session transcript lock acquisitions and raise the default wait to 60 seconds, avoiding user-visible lock timeouts during legitimate slow prep, cleanup, compaction, and mirror work. Fixes #75894. Thanks @shandutta.
- Control UI: contain the standalone iOS PWA viewport with safe-area-aware document locking, so Add-to-Home-Screen launches cannot scroll past the device bounds. Refs #76072. Thanks @kvncrw.
- Agents/restart recovery: match cleaned transcript locks by exact transcript lock paths plus the canonical session fallback, so interrupted main sessions using topic-suffixed transcripts resume after gateway restart. Refs #76052. Thanks @anyech.

View File

@@ -197,7 +197,7 @@ openclaw hooks disable command-logger
## Install hook packs
```bash
openclaw plugins install <package> # ClawHub first, then npm
openclaw plugins install <package> # npm by default
openclaw plugins install npm:<package> # npm only
openclaw plugins install <package> --pin # pin version
openclaw plugins install <path> # local path

View File

@@ -68,7 +68,7 @@ Native OpenClaw plugins must ship `openclaw.plugin.json` with an inline JSON Sch
```bash
openclaw plugins search "calendar" # search ClawHub plugins
openclaw plugins install <package> # ClawHub first, then npm
openclaw plugins install <package> # npm by default
openclaw plugins install clawhub:<package> # ClawHub only
openclaw plugins install npm:<package> # npm only
openclaw plugins install git:github.com/<owner>/<repo> # git repo
@@ -83,7 +83,7 @@ openclaw plugins install <plugin> --marketplace https://github.com/<owner>/<repo
```
<Warning>
Bare package names are checked against ClawHub first, then npm. Treat plugin installs like running code. Prefer pinned versions.
Bare package names install from npm by default during the launch cutover. Use `clawhub:<package>` for ClawHub. Treat plugin installs like running code. Prefer pinned versions.
</Warning>
`plugins search` queries ClawHub for installable plugin packages and prints
@@ -129,7 +129,7 @@ current OpenClaw or a local checkout until a newer npm package is published.
Npm specs are **registry-only** (package name + optional **exact version** or **dist-tag**). Git/URL/file specs and semver ranges are rejected. Dependency installs run project-local with `--ignore-scripts` for safety, even when your shell has global npm install settings.
Use `npm:<package>` when you want to skip ClawHub lookup and install directly from npm. Bare package specs still prefer ClawHub and only fall back to npm when ClawHub does not have that package or version.
Use `npm:<package>` when you want to make npm resolution explicit. Bare package specs also install directly from npm during the launch cutover.
Bare specs and `@latest` stay on the stable track. If npm resolves either of those to a prerelease, OpenClaw stops and asks you to opt in explicitly with a prerelease tag such as `@beta`/`@rc` or an exact prerelease version such as `@1.2.3-beta.4`.
@@ -159,13 +159,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:
Bare npm-safe plugin specs install from npm by default during the launch cutover:
```bash
openclaw plugins install openclaw-codex-app-server
```
Use `npm:` to force npm-only resolution, for example when ClawHub is unreachable or you know the package exists only on npm:
Use `npm:` to make npm-only resolution explicit:
```bash
openclaw plugins install npm:openclaw-codex-app-server

View File

@@ -15,8 +15,8 @@ combination.
You do not need to add your plugin to the OpenClaw repository. Publish to
[ClawHub](/tools/clawhub) and users install with
`openclaw plugins install <package-name>`. OpenClaw tries ClawHub first and
falls back to npm automatically for packages that still use npm distribution.
`openclaw plugins install clawhub:<package-name>`. Bare package specs still
install from npm during the launch cutover.
## Prerequisites
@@ -141,9 +141,8 @@ and provider plugins have dedicated guides linked above.
openclaw plugins install clawhub:@myorg/openclaw-my-plugin
```
OpenClaw also checks ClawHub before npm for bare package specs like
`@myorg/openclaw-my-plugin`; npm remains a fallback for packages that have
not migrated to ClawHub yet.
Bare package specs like `@myorg/openclaw-my-plugin` install from npm during
the launch cutover. Use `clawhub:` when you want ClawHub resolution.
**In-repo plugins:** place under the bundled plugin workspace tree — automatically discovered.

View File

@@ -9,18 +9,18 @@ title: "Community plugins"
Community plugins are third-party packages that extend OpenClaw with new
channels, tools, providers, or other capabilities. They are built and maintained
by the community, usually published on [ClawHub](/tools/clawhub), and installable
with a single command. Npm remains a supported fallback for packages that have
not moved to ClawHub yet.
with a single command. Npm remains the launch default for bare package specs
while ClawHub pack installs roll out.
ClawHub is the canonical discovery surface for community plugins. Do not open
docs-only PRs just to add your plugin here for discoverability; publish it on
ClawHub instead.
```bash
openclaw plugins install <package-name>
openclaw plugins install clawhub:<package-name>
```
OpenClaw checks ClawHub first and falls back to npm automatically.
Use `openclaw plugins install <package-name>` for npm-hosted packages.
## Listed plugins

View File

@@ -495,12 +495,12 @@ The `ChannelSetupWizard` type supports `credentials`, `textInputs`, `dmPolicy`,
**External plugins:** publish to [ClawHub](/tools/clawhub), then install:
<Tabs>
<Tab title="Auto (ClawHub then npm)">
<Tab title="npm">
```bash
openclaw plugins install @myorg/openclaw-my-plugin
```
OpenClaw tries ClawHub first and falls back to npm automatically.
Bare package specs install from npm during the launch cutover.
</Tab>
<Tab title="ClawHub only">

View File

@@ -66,16 +66,15 @@ Site: [clawhub.ai](https://clawhub.ai)
```
`plugins search` queries the ClawHub plugin catalog and prints install-ready
package names. Bare npm-safe plugin specs use ClawHub only after package
readiness says the package is install-ready for OpenClaw; otherwise OpenClaw
preserves npm fallback:
package names. Use `clawhub:<package>` when you want ClawHub resolution.
Bare npm-safe plugin specs install from npm during the launch cutover:
```bash
openclaw plugins install openclaw-codex-app-server
```
Use `npm:<package>` when you want npm-only resolution without a
ClawHub lookup:
`npm:<package>` is also npm-only and is useful when a spec could otherwise
be ambiguous:
```bash
openclaw plugins install npm:openclaw-codex-app-server

View File

@@ -90,7 +90,7 @@ If you prefer chat-native control, enable `commands.plugins: true` and use:
The install path uses the same resolver as the CLI: local path/archive, explicit
`clawhub:<pkg>`, explicit `npm:<pkg>`, explicit `git:<repo>`, or bare package
spec (ClawHub first, then npm fallback).
spec through npm.
If config is invalid, install normally fails closed and points you at
`openclaw doctor --fix`. The only recovery exception is a narrow bundled-plugin
@@ -467,7 +467,7 @@ openclaw plugins registry # inspect persisted registry state
openclaw plugins registry --refresh # rebuild persisted registry
openclaw doctor --fix # repair plugin registry state
openclaw plugins install <package> # install (readiness-gated ClawHub, then npm)
openclaw plugins install <package> # install from npm by default
openclaw plugins install clawhub:<pkg> # install from ClawHub only
openclaw plugins install npm:<pkg> # install from npm only
openclaw plugins install git:<repo> # install from git

View File

@@ -19,9 +19,7 @@ vi.mock("../../cli/npm-resolution.js", () => ({
vi.mock("../../cli/plugins-command-helpers.js", () => ({
createPluginInstallLogger: vi.fn(() => ({})),
decidePreferredClawHubFallback: vi.fn(() => "fallback_to_npm"),
resolveFileNpmSpecToLocalPath: vi.fn(() => null),
resolvePreferredClawHubSpec: vi.fn(() => null),
}));
vi.mock("../../cli/plugins-install-persist.js", () => ({

View File

@@ -2,9 +2,7 @@ import fs from "node:fs";
import { buildNpmInstallRecordFields } from "../../cli/npm-resolution.js";
import {
createPluginInstallLogger,
decidePreferredClawHubFallback,
resolveFileNpmSpecToLocalPath,
resolvePreferredClawHubSpec,
} from "../../cli/plugins-command-helpers.js";
import { persistPluginInstall } from "../../cli/plugins-install-persist.js";
import type { ConfigSnapshotForInstallPersist } from "../../cli/plugins-install-persist.js";
@@ -256,36 +254,6 @@ async function installPluginFromPluginsCommand(params: {
return { ok: true, pluginId: result.pluginId };
}
const preferredClawHubSpec = await resolvePreferredClawHubSpec(params.raw);
if (preferredClawHubSpec) {
const clawhubResult = await installPluginFromClawHub({
spec: preferredClawHubSpec,
logger: createPluginInstallLogger(),
});
if (clawhubResult.ok) {
await persistPluginInstall({
snapshot: params.snapshot,
pluginId: clawhubResult.pluginId,
install: {
source: "clawhub",
spec: preferredClawHubSpec,
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,
},
});
return { ok: true, pluginId: clawhubResult.pluginId };
}
if (decidePreferredClawHubFallback(clawhubResult) !== "fallback_to_npm") {
return { ok: false, error: clawhubResult.error };
}
}
const result = await installPluginFromNpmSpec({
spec: params.raw,
logger: createPluginInstallLogger(),

View File

@@ -11,8 +11,6 @@ type UnknownMock = Mock<(...args: unknown[]) => unknown>;
type AsyncUnknownMock = Mock<(...args: unknown[]) => Promise<unknown>>;
type LoadConfigFn = (typeof import("../config/config.js"))["loadConfig"];
type ParseClawHubPluginSpecFn = (typeof import("../infra/clawhub.js"))["parseClawHubPluginSpec"];
type FetchClawHubPackageReadinessFn =
(typeof import("../infra/clawhub.js"))["fetchClawHubPackageReadiness"];
type InstallPluginFromMarketplaceFn =
(typeof import("../plugins/marketplace.js"))["installPluginFromMarketplace"];
type InstallPluginFromGitSpecFn =
@@ -80,7 +78,6 @@ export const installPluginFromNpmSpec: AsyncUnknownMock = vi.fn();
export const installPluginFromPath: AsyncUnknownMock = vi.fn();
export const installPluginFromClawHub: AsyncUnknownMock = vi.fn();
export const parseClawHubPluginSpec: Mock<ParseClawHubPluginSpecFn> = vi.fn();
export const fetchClawHubPackageReadiness: Mock<FetchClawHubPackageReadinessFn> = vi.fn();
export const installHooksFromNpmSpec: AsyncUnknownMock = vi.fn();
export const installHooksFromPath: AsyncUnknownMock = vi.fn();
export const recordHookInstall: UnknownMock = vi.fn();
@@ -563,16 +560,6 @@ vi.mock("../plugins/clawhub.js", () => ({
}));
vi.mock("../infra/clawhub.js", () => ({
fetchClawHubPackageReadiness: ((
...args: Parameters<(typeof import("../infra/clawhub.js"))["fetchClawHubPackageReadiness"]>
) =>
invokeMock<
Parameters<(typeof import("../infra/clawhub.js"))["fetchClawHubPackageReadiness"]>,
ReturnType<(typeof import("../infra/clawhub.js"))["fetchClawHubPackageReadiness"]>
>(
fetchClawHubPackageReadiness,
...args,
)) as (typeof import("../infra/clawhub.js"))["fetchClawHubPackageReadiness"],
parseClawHubPluginSpec: ((
...args: Parameters<(typeof import("../infra/clawhub.js"))["parseClawHubPluginSpec"]>
) =>
@@ -634,7 +621,6 @@ export function resetPluginsCliTestState() {
installPluginFromPath.mockReset();
installPluginFromClawHub.mockReset();
parseClawHubPluginSpec.mockReset();
fetchClawHubPackageReadiness.mockReset();
installHooksFromNpmSpec.mockReset();
installHooksFromPath.mockReset();
recordHookInstall.mockReset();

View File

@@ -8,7 +8,6 @@ import {
applyExclusiveSlotSelection,
buildPluginSnapshotReport,
enablePluginInConfig,
fetchClawHubPackageReadiness,
installHooksFromNpmSpec,
installHooksFromPath,
installPluginFromClawHub,
@@ -653,112 +652,10 @@ describe("plugins cli install", () => {
});
});
it("prefers ClawHub before npm for bare plugin specs", async () => {
const cfg = {
plugins: {
entries: {},
},
} as OpenClawConfig;
const enabledCfg = createEnabledPluginConfig("demo");
loadConfig.mockReturnValue(cfg);
fetchClawHubPackageReadiness.mockResolvedValue({ readyForOpenClaw: true });
installPluginFromClawHub.mockResolvedValue(
createClawHubInstallResult({
pluginId: "demo",
packageName: "demo",
version: "1.2.3",
channel: "community",
}),
);
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
applyExclusiveSlotSelection.mockReturnValue({
config: enabledCfg,
warnings: [],
});
await runPluginsCommand(["plugins", "install", "demo"]);
expect(installPluginFromClawHub).toHaveBeenCalledWith(
expect.objectContaining({
spec: "clawhub:demo",
}),
);
expect(installPluginFromNpmSpec).not.toHaveBeenCalled();
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({
demo: expect.objectContaining({
source: "clawhub",
spec: "clawhub:demo",
installPath: cliInstallPath("demo"),
version: "1.2.3",
clawhubPackage: "demo",
}),
});
expect(writeConfigFile).toHaveBeenCalledWith(enabledCfg);
});
it("keeps explicit bare ClawHub selectors in install records", async () => {
const cfg = {
plugins: {
entries: {},
},
} as OpenClawConfig;
const enabledCfg = createEnabledPluginConfig("demo");
loadConfig.mockReturnValue(cfg);
fetchClawHubPackageReadiness.mockResolvedValue({ phase: "legacy-zip-only" });
installPluginFromClawHub.mockResolvedValue(
createClawHubInstallResult({
pluginId: "demo",
packageName: "demo",
version: "1.2.3-beta.1",
channel: "community",
}),
);
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
applyExclusiveSlotSelection.mockReturnValue({
config: enabledCfg,
warnings: [],
});
await runPluginsCommand(["plugins", "install", "demo@beta"]);
expect(installPluginFromClawHub).toHaveBeenCalledWith(
expect.objectContaining({
spec: "clawhub:demo@beta",
}),
);
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({
demo: expect.objectContaining({
source: "clawhub",
spec: "clawhub:demo@beta",
version: "1.2.3-beta.1",
clawhubPackage: "demo",
}),
});
});
it("falls back to npm when ClawHub does not have the package", async () => {
primeNpmPluginFallback();
fetchClawHubPackageReadiness.mockResolvedValue({ phase: "ready-for-openclaw" });
await runPluginsCommand(["plugins", "install", "demo"]);
expect(installPluginFromClawHub).toHaveBeenCalledWith(
expect.objectContaining({
spec: "clawhub:demo",
}),
);
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "demo",
}),
);
});
it("preserves npm install behavior for bare specs until ClawHub readiness is available", async () => {
it("installs bare plugin specs through npm without ClawHub lookup", async () => {
const cfg = createEmptyPluginConfig();
const enabledCfg = createEnabledPluginConfig("demo");
loadConfig.mockReturnValue(cfg);
fetchClawHubPackageReadiness.mockRejectedValue(new Error("not deployed"));
installPluginFromNpmSpec.mockResolvedValue(createNpmPluginInstallResult("demo"));
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
applyExclusiveSlotSelection.mockReturnValue({
@@ -768,13 +665,42 @@ describe("plugins cli install", () => {
await runPluginsCommand(["plugins", "install", "demo"]);
expect(fetchClawHubPackageReadiness).toHaveBeenCalledWith({ name: "demo" });
expect(installPluginFromClawHub).not.toHaveBeenCalled();
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "demo",
}),
);
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({
demo: expect.objectContaining({
source: "npm",
spec: "demo",
installPath: cliInstallPath("demo"),
version: "1.2.3",
}),
});
expect(writeConfigFile).toHaveBeenCalledWith(enabledCfg);
});
it("passes bare npm selectors through npm without ClawHub lookup", async () => {
const cfg = createEmptyPluginConfig();
const enabledCfg = createEnabledPluginConfig("demo");
loadConfig.mockReturnValue(cfg);
installPluginFromNpmSpec.mockResolvedValue(createNpmPluginInstallResult("demo"));
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
applyExclusiveSlotSelection.mockReturnValue({
config: enabledCfg,
warnings: [],
});
await runPluginsCommand(["plugins", "install", "demo@beta"]);
expect(installPluginFromClawHub).not.toHaveBeenCalled();
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "demo@beta",
}),
);
});
it("installs directly from npm when npm: prefix is used", async () => {
@@ -1537,15 +1463,17 @@ describe("plugins cli install", () => {
expect(runtimeLogs.some((line) => line.includes("Installed hook pack: demo-hooks"))).toBe(true);
});
it("does not fall back to npm when ClawHub rejects a real package", async () => {
fetchClawHubPackageReadiness.mockResolvedValue({ phase: "ready-for-openclaw" });
it("does not fall back to npm when explicit ClawHub rejects a real package", async () => {
parseClawHubPluginSpec.mockReturnValue({ name: "demo" });
installPluginFromClawHub.mockResolvedValue({
ok: false,
error: 'Use "openclaw skills install demo" instead.',
code: "skill_package",
});
await expect(runPluginsCommand(["plugins", "install", "demo"])).rejects.toThrow("__exit__:1");
await expect(runPluginsCommand(["plugins", "install", "clawhub:demo"])).rejects.toThrow(
"__exit__:1",
);
expect(installPluginFromNpmSpec).not.toHaveBeenCalled();
expect(runtimeErrors.at(-1)).toContain('Use "openclaw skills install demo" instead.');

View File

@@ -1,7 +1,4 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { fetchClawHubPackageReadiness, type ClawHubPackageReadiness } from "../infra/clawhub.js";
import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js";
import { CLAWHUB_INSTALL_ERROR_CODE } from "../plugins/clawhub.js";
import type { PluginKind } from "../plugins/plugin-kind.types.js";
import { loadPluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
import { applyExclusiveSlotSelection } from "../plugins/slots.js";
@@ -204,53 +201,6 @@ export function logSlotWarnings(warnings: string[], runtime: RuntimeEnv = defaul
}
}
export function buildPreferredClawHubSpec(raw: string): string | null {
const parsed = parseRegistryNpmSpec(raw);
if (!parsed) {
return null;
}
return `clawhub:${parsed.name}${parsed.selector ? `@${parsed.selector}` : ""}`;
}
function normalizeReadinessPhase(readiness: ClawHubPackageReadiness): string {
return normalizeLowercaseStringOrEmpty(readiness.phase ?? readiness.status ?? "");
}
export function isClawHubReadinessInstallReady(
readiness: ClawHubPackageReadiness | null | undefined,
): boolean {
if (!readiness) {
return false;
}
if (
readiness.ready === true ||
readiness.readyForOpenClaw === true ||
readiness.installReady === true
) {
return true;
}
const phase = normalizeReadinessPhase(readiness);
return (
phase === "ready-for-openclaw" || phase === "clawpack-ready" || phase === "legacy-zip-only"
);
}
export async function resolvePreferredClawHubSpec(raw: string): Promise<string | null> {
const parsed = parseRegistryNpmSpec(raw);
if (!parsed) {
return null;
}
try {
const readiness = await fetchClawHubPackageReadiness({ name: parsed.name });
if (!isClawHubReadinessInstallReady(readiness)) {
return null;
}
} catch {
return null;
}
return `clawhub:${parsed.name}${parsed.selector ? `@${parsed.selector}` : ""}`;
}
export function parseNpmPrefixSpec(raw: string): string | null {
const trimmed = raw.trim();
if (!normalizeLowercaseStringOrEmpty(trimmed).startsWith("npm:")) {
@@ -258,23 +208,3 @@ export function parseNpmPrefixSpec(raw: string): string | null {
}
return trimmed.slice("npm:".length).trim();
}
const PREFERRED_CLAWHUB_FALLBACK_DECISION = {
FALLBACK_TO_NPM: "fallback_to_npm",
STOP: "stop",
} as const;
export type PreferredClawHubFallbackDecision =
(typeof PREFERRED_CLAWHUB_FALLBACK_DECISION)[keyof typeof PREFERRED_CLAWHUB_FALLBACK_DECISION];
export function decidePreferredClawHubFallback(params: {
code?: string;
}): PreferredClawHubFallbackDecision {
if (
params.code === CLAWHUB_INSTALL_ERROR_CODE.PACKAGE_NOT_FOUND ||
params.code === CLAWHUB_INSTALL_ERROR_CODE.VERSION_NOT_FOUND
) {
return PREFERRED_CLAWHUB_FALLBACK_DECISION.FALLBACK_TO_NPM;
}
return PREFERRED_CLAWHUB_FALLBACK_DECISION.STOP;
}

View File

@@ -40,10 +40,8 @@ import {
import {
createHookPackInstallLogger,
createPluginInstallLogger,
decidePreferredClawHubFallback,
formatPluginInstallWithHookFallbackError,
parseNpmPrefixSpec,
resolvePreferredClawHubSpec,
} from "./plugins-command-helpers.js";
import { persistHookPackInstall, persistPluginInstall } from "./plugins-install-persist.js";
import type { ConfigSnapshotForInstallPersist } from "./plugins-install-persist.js";
@@ -776,34 +774,6 @@ export async function runPluginInstallCommand(params: {
return;
}
const preferredClawHubSpec = await resolvePreferredClawHubSpec(raw);
if (preferredClawHubSpec) {
const clawhubResult = await installPluginFromClawHub({
...safetyOverrides,
mode: installMode,
spec: preferredClawHubSpec,
extensionsDir,
logger: createPluginInstallLogger(runtime),
});
if (clawhubResult.ok) {
await persistPluginInstall({
snapshot,
pluginId: clawhubResult.pluginId,
install: {
...buildClawHubPluginInstallRecordFields(clawhubResult.clawhub),
spec: preferredClawHubSpec,
installPath: clawhubResult.targetDir,
},
runtime,
});
return;
}
if (decidePreferredClawHubFallback(clawhubResult) !== "fallback_to_npm") {
runtime.error(clawhubResult.error);
return runtime.exit(1);
}
}
const npmResult = await tryInstallPluginOrHookPackFromNpmSpec({
snapshot,
installMode,