diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 6f3cb103cfd..0934a0289c6 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -48,6 +48,10 @@ Security note: treat plugin installs like running code. Prefer pinned versions. Npm specs are **registry-only** (package name + optional version/tag). Git/URL/file specs are rejected. Dependency installs run with `--ignore-scripts` for safety. +If a bare install spec matches a bundled plugin id (for example `diffs`), OpenClaw +installs the bundled plugin directly. To install an npm package with the same +name, use an explicit scoped spec (for example `@scope/diffs`). + Supported archives: `.zip`, `.tgz`, `.tar.gz`, `.tar`. Use `--link` to avoid copying a local directory (adds to `plugins.load.paths`): diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 258cdd2a7fb..c8126d9ce49 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -6,7 +6,11 @@ import type { OpenClawConfig } from "../config/config.js"; import { loadConfig, writeConfigFile } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; import { resolveArchiveKind } from "../infra/archive.js"; -import { findBundledPluginByNpmSpec } from "../plugins/bundled-sources.js"; +import { + type BundledPluginSource, + findBundledPluginByNpmSpec, + findBundledPluginByPluginId, +} from "../plugins/bundled-sources.js"; import { enablePluginInConfig } from "../plugins/enable.js"; import { installPluginFromNpmSpec, installPluginFromPath } from "../plugins/install.js"; import { recordPluginInstall } from "../plugins/installs.js"; @@ -159,19 +163,53 @@ function isPackageNotFoundInstallError(message: string): boolean { ); } -/** - * True when npm downloaded a package successfully but it is not a valid - * OpenClaw plugin (e.g. `diffs` resolves to the unrelated npm package - * `diffs@0.1.1` instead of `@openclaw/diffs`). - * See: https://github.com/openclaw/openclaw/issues/32019 - */ -function isNotAnOpenClawPluginError(message: string): boolean { - const lower = message.toLowerCase(); - return ( - lower.includes("missing openclaw.extensions") || lower.includes("openclaw.extensions is empty") - ); +function isBareNpmPackageName(spec: string): boolean { + const trimmed = spec.trim(); + return /^[a-z0-9][a-z0-9-._~]*$/.test(trimmed); } +async function installBundledPluginSource(params: { + config: OpenClawConfig; + rawSpec: string; + bundledSource: BundledPluginSource; + warning: string; +}) { + const existing = params.config.plugins?.load?.paths ?? []; + const mergedPaths = Array.from(new Set([...existing, params.bundledSource.localPath])); + let next: OpenClawConfig = { + ...params.config, + plugins: { + ...params.config.plugins, + load: { + ...params.config.plugins?.load, + paths: mergedPaths, + }, + entries: { + ...params.config.plugins?.entries, + [params.bundledSource.pluginId]: { + ...(params.config.plugins?.entries?.[params.bundledSource.pluginId] as + | object + | undefined), + enabled: true, + }, + }, + }, + }; + next = recordPluginInstall(next, { + pluginId: params.bundledSource.pluginId, + source: "path", + spec: params.rawSpec, + sourcePath: params.bundledSource.localPath, + installPath: params.bundledSource.localPath, + }); + const slotResult = applySlotSelectionForPlugin(next, params.bundledSource.pluginId); + next = slotResult.config; + await writeConfigFile(next); + logSlotWarnings(slotResult.warnings); + defaultRuntime.log(theme.warn(params.warning)); + defaultRuntime.log(`Installed plugin: ${params.bundledSource.pluginId}`); + defaultRuntime.log(`Restart the gateway to load plugins.`); +} export function registerPluginsCli(program: Command) { const plugins = program .command("plugins") @@ -633,59 +671,38 @@ export function registerPluginsCli(program: Command) { process.exit(1); } + const bundledByPluginId = isBareNpmPackageName(raw) + ? findBundledPluginByPluginId({ pluginId: raw }) + : undefined; + if (bundledByPluginId) { + await installBundledPluginSource({ + config: cfg, + rawSpec: raw, + bundledSource: bundledByPluginId, + warning: `Using bundled plugin "${bundledByPluginId.pluginId}" from ${shortenHomePath(bundledByPluginId.localPath)} for bare install spec "${raw}". To install an npm package with the same name, use a scoped package name (for example @scope/${raw}).`, + }); + return; + } + const result = await installPluginFromNpmSpec({ spec: raw, logger: createPluginInstallLogger(), }); if (!result.ok) { - const isNpmNotFound = isPackageNotFoundInstallError(result.error); - const isNotPlugin = isNotAnOpenClawPluginError(result.error); - const bundledFallback = - isNpmNotFound || isNotPlugin ? findBundledPluginByNpmSpec({ spec: raw }) : undefined; + const bundledFallback = isPackageNotFoundInstallError(result.error) + ? findBundledPluginByNpmSpec({ spec: raw }) + : undefined; if (!bundledFallback) { defaultRuntime.error(result.error); process.exit(1); } - const existing = cfg.plugins?.load?.paths ?? []; - const mergedPaths = Array.from(new Set([...existing, bundledFallback.localPath])); - let next: OpenClawConfig = { - ...cfg, - plugins: { - ...cfg.plugins, - load: { - ...cfg.plugins?.load, - paths: mergedPaths, - }, - entries: { - ...cfg.plugins?.entries, - [bundledFallback.pluginId]: { - ...(cfg.plugins?.entries?.[bundledFallback.pluginId] as object | undefined), - enabled: true, - }, - }, - }, - }; - next = recordPluginInstall(next, { - pluginId: bundledFallback.pluginId, - source: "path", - spec: raw, - sourcePath: bundledFallback.localPath, - installPath: bundledFallback.localPath, + await installBundledPluginSource({ + config: cfg, + rawSpec: raw, + bundledSource: bundledFallback, + warning: `npm package unavailable for ${raw}; using bundled plugin at ${shortenHomePath(bundledFallback.localPath)}.`, }); - const slotResult = applySlotSelectionForPlugin(next, bundledFallback.pluginId); - next = slotResult.config; - await writeConfigFile(next); - logSlotWarnings(slotResult.warnings); - defaultRuntime.log( - theme.warn( - isNpmNotFound - ? `npm package unavailable for ${raw}; using bundled plugin at ${shortenHomePath(bundledFallback.localPath)}.` - : `npm package "${raw}" is not a valid OpenClaw plugin; using bundled plugin at ${shortenHomePath(bundledFallback.localPath)}.`, - ), - ); - defaultRuntime.log(`Installed plugin: ${bundledFallback.pluginId}`); - defaultRuntime.log(`Restart the gateway to load plugins.`); return; } // Ensure config validation sees newly installed plugin(s) even if the cache was warmed at startup. diff --git a/src/plugins/bundled-sources.test.ts b/src/plugins/bundled-sources.test.ts index 603b387fc90..2cbb3b2bb76 100644 --- a/src/plugins/bundled-sources.test.ts +++ b/src/plugins/bundled-sources.test.ts @@ -1,5 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { findBundledPluginByNpmSpec, resolveBundledPluginSources } from "./bundled-sources.js"; +import { + findBundledPluginByNpmSpec, + findBundledPluginByPluginId, + resolveBundledPluginSources, +} from "./bundled-sources.js"; const discoverOpenClawPluginsMock = vi.fn(); const loadPluginManifestMock = vi.fn(); @@ -95,25 +99,25 @@ describe("bundled plugin sources", () => { expect(missing).toBeUndefined(); }); - it("finds bundled source by plugin id when npm spec does not match (#32019)", () => { + it("finds bundled source by plugin id", () => { discoverOpenClawPluginsMock.mockReturnValue({ candidates: [ { origin: "bundled", rootDir: "/app/extensions/diffs", packageName: "@openclaw/diffs", - packageManifest: {}, + packageManifest: { install: { npmSpec: "@openclaw/diffs" } }, }, ], diagnostics: [], }); loadPluginManifestMock.mockReturnValue({ ok: true, manifest: { id: "diffs" } }); - // Searching by unscoped name "diffs" should match by pluginId even though - // the npmSpec is "@openclaw/diffs". - const resolved = findBundledPluginByNpmSpec({ spec: "diffs" }); + const resolved = findBundledPluginByPluginId({ pluginId: "diffs" }); + const missing = findBundledPluginByPluginId({ pluginId: "not-found" }); expect(resolved?.pluginId).toBe("diffs"); expect(resolved?.localPath).toBe("/app/extensions/diffs"); + expect(missing).toBeUndefined(); }); }); diff --git a/src/plugins/bundled-sources.ts b/src/plugins/bundled-sources.ts index 097e980ca35..b855ce99c2d 100644 --- a/src/plugins/bundled-sources.ts +++ b/src/plugins/bundled-sources.ts @@ -64,3 +64,15 @@ export function findBundledPluginByNpmSpec(params: { } return undefined; } + +export function findBundledPluginByPluginId(params: { + pluginId: string; + workspaceDir?: string; +}): BundledPluginSource | undefined { + const targetPluginId = params.pluginId.trim(); + if (!targetPluginId) { + return undefined; + } + const bundled = resolveBundledPluginSources({ workspaceDir: params.workspaceDir }); + return bundled.get(targetPluginId); +}