diff --git a/CHANGELOG.md b/CHANGELOG.md index 35736016302..ba647b05cf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ Docs: https://docs.openclaw.ai ### Fixes -- CLI/models: lazy-load model runtime helpers for command actions so `openclaw models --help` renders without importing the model runtime path. +- CLI: lazy-load model and plugin runtime helpers for command actions so parent/help output renders without importing those runtime paths. - Security/sandbox: include Windows `USERPROFILE` in the sandbox blocked home roots so credential-bearing binds (such as `.codex`, `.openclaw`, or `.ssh` under the Windows user profile) are denied even when `HOME` points at a different shell home. (#63074) Thanks @luoyanglang. - Models config/auth: stop inferring provider env-var markers from broad `^[A-Z_][A-Z0-9_]*$` strings, and resolve config-backed provider `apiKey` values only through structured env SecretRefs (`secrets.providers[id]` / `secrets.defaults`), so unrelated env vars cannot accidentally become provider credentials. Thanks @sallyom. - Media fetch: skip allocating and buffering the response body for bodyless media responses (HEAD probes and 204-style empty bodies), avoiding wasted heap on streams that carry no payload. Thanks @shakkernerd. diff --git a/src/cli/plugins-cli.lazy.test.ts b/src/cli/plugins-cli.lazy.test.ts new file mode 100644 index 00000000000..6af944d4dfa --- /dev/null +++ b/src/cli/plugins-cli.lazy.test.ts @@ -0,0 +1,69 @@ +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("plugins cli lazy runtime boundary", () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.doUnmock("./plugins-cli.runtime.js"); + vi.resetModules(); + }); + + it("renders parent help without importing the plugins runtime", async () => { + const runtimeLoaded = vi.fn(); + vi.doMock("./plugins-cli.runtime.js", () => { + runtimeLoaded(); + return { + runPluginMarketplaceListCommand: vi.fn(), + runPluginsDisableCommand: vi.fn(), + runPluginsDoctorCommand: vi.fn(), + runPluginsEnableCommand: vi.fn(), + runPluginsInstallAction: vi.fn(), + runPluginsRegistryCommand: vi.fn(), + }; + }); + + const { registerPluginsCli } = await import("./plugins-cli.js"); + const program = new Command(); + program.exitOverride(); + program.configureOutput({ + writeErr: () => {}, + writeOut: () => {}, + }); + registerPluginsCli(program); + + await expect(program.parseAsync(["plugins", "--help"], { from: "user" })).rejects.toMatchObject( + { + exitCode: 0, + }, + ); + expect(runtimeLoaded).not.toHaveBeenCalled(); + }); + + it("loads the plugins runtime for runtime-backed actions", async () => { + const runPluginsRegistryCommand = vi.fn().mockResolvedValue(undefined); + const runtimeLoaded = vi.fn(); + vi.doMock("./plugins-cli.runtime.js", () => { + runtimeLoaded(); + return { + runPluginMarketplaceListCommand: vi.fn(), + runPluginsDisableCommand: vi.fn(), + runPluginsDoctorCommand: vi.fn(), + runPluginsEnableCommand: vi.fn(), + runPluginsInstallAction: vi.fn(), + runPluginsRegistryCommand, + }; + }); + + const { registerPluginsCli } = await import("./plugins-cli.js"); + const program = new Command(); + registerPluginsCli(program); + + await program.parseAsync(["plugins", "registry", "--json"], { from: "user" }); + + expect(runtimeLoaded).toHaveBeenCalledTimes(1); + expect(runPluginsRegistryCommand).toHaveBeenCalledWith(expect.objectContaining({ json: true })); + }); +}); diff --git a/src/cli/plugins-cli.runtime.ts b/src/cli/plugins-cli.runtime.ts new file mode 100644 index 00000000000..94b799d12b5 --- /dev/null +++ b/src/cli/plugins-cli.runtime.ts @@ -0,0 +1,361 @@ +import { + assertConfigWriteAllowedInCurrentMode, + getRuntimeConfig, + readConfigFileSnapshot, + replaceConfigFile, +} from "../config/config.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { tracePluginLifecyclePhaseAsync } from "../plugins/plugin-lifecycle-trace.js"; +import { defaultRuntime } from "../runtime.js"; +import { formatDocsLink } from "../terminal/links.js"; +import { theme } from "../terminal/theme.js"; +import { shortenHomeInString } from "../utils.js"; +import { formatMissingPluginMessage } from "./error-format.js"; +import type { PluginMarketplaceListOptions, PluginRegistryOptions } from "./plugins-cli.js"; + +type PluginInstallActionOptions = { + dangerouslyForceUnsafeInstall?: boolean; + force?: boolean; + link?: boolean; + pin?: boolean; + marketplace?: string; +}; + +function countEnabledPlugins(plugins: readonly { enabled: boolean }[]): number { + return plugins.filter((plugin) => plugin.enabled).length; +} + +function formatRegistryState(state: "missing" | "fresh" | "stale"): string { + if (state === "fresh") { + return theme.success(state); + } + if (state === "stale") { + return theme.warn(state); + } + return theme.warn(state); +} + +function reportMissingPlugin(id: string) { + defaultRuntime.error(formatMissingPluginMessage({ id, includeSearch: true })); + return defaultRuntime.exit(1); +} + +function matchesPluginId(plugin: { id: string }, id: string) { + return plugin.id === id; +} + +function isConfigSelectedShadowDiagnostic(entry: { level?: string; message?: string }): boolean { + return ( + entry.level === "warn" && + typeof entry.message === "string" && + entry.message.includes("duplicate plugin id resolved by explicit config-selected plugin") + ); +} + +function isErroredConfigSelectedShadowDiagnostic(params: { + entry: { level?: string; message?: string; pluginId?: string }; + plugins: readonly { id: string; origin: string; status: string }[]; +}): boolean { + if (!params.entry.pluginId || !isConfigSelectedShadowDiagnostic(params.entry)) { + return false; + } + return params.plugins.some( + (plugin) => + plugin.id === params.entry.pluginId && + plugin.origin === "config" && + plugin.status === "error", + ); +} + +export async function runPluginsEnableCommand(id: string): Promise { + assertConfigWriteAllowedInCurrentMode(); + + const { enablePluginInConfig } = await import("../plugins/enable.js"); + const { normalizePluginId } = await import("../plugins/config-state.js"); + const { buildPluginRegistrySnapshotReport } = await import("../plugins/status.js"); + const { applySlotSelectionForPlugin, logSlotWarnings } = + await import("./plugins-command-helpers.js"); + const { refreshPluginRegistryAfterConfigMutation } = + await import("./plugins-registry-refresh.js"); + const snapshot = await readConfigFileSnapshot(); + const cfg = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig; + const report = buildPluginRegistrySnapshotReport({ config: cfg }); + id = normalizePluginId(id); + if (!report.plugins.some((plugin) => matchesPluginId(plugin, id))) { + return reportMissingPlugin(id); + } + const enableResult = enablePluginInConfig(cfg, id, { + updateChannelConfig: false, + }); + let next: OpenClawConfig = enableResult.config; + const slotResult = applySlotSelectionForPlugin(next, id); + next = slotResult.config; + await replaceConfigFile({ + nextConfig: next, + ...(snapshot.hash !== undefined ? { baseHash: snapshot.hash } : {}), + }); + await refreshPluginRegistryAfterConfigMutation({ + config: next, + reason: "policy-changed", + policyPluginIds: [enableResult.pluginId], + logger: { + warn: (message) => defaultRuntime.log(theme.warn(message)), + }, + }); + logSlotWarnings(slotResult.warnings); + if (enableResult.enabled) { + defaultRuntime.log(`Enabled plugin "${id}". Restart the gateway to apply.`); + return; + } + defaultRuntime.log( + theme.warn(`Plugin "${id}" could not be enabled (${enableResult.reason ?? "unknown reason"}).`), + ); +} + +export async function runPluginsDisableCommand(id: string): Promise { + assertConfigWriteAllowedInCurrentMode(); + + const { normalizePluginId } = await import("../plugins/config-state.js"); + const { buildPluginRegistrySnapshotReport } = await import("../plugins/status.js"); + const { setPluginEnabledInConfig } = await import("./plugins-config.js"); + const { refreshPluginRegistryAfterConfigMutation } = + await import("./plugins-registry-refresh.js"); + const snapshot = await readConfigFileSnapshot(); + const cfg = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig; + const report = buildPluginRegistrySnapshotReport({ config: cfg }); + id = normalizePluginId(id); + if (!report.plugins.some((plugin) => matchesPluginId(plugin, id))) { + return reportMissingPlugin(id); + } + const next = setPluginEnabledInConfig(cfg, id, false, { + updateChannelConfig: false, + }); + await replaceConfigFile({ + nextConfig: next, + ...(snapshot.hash !== undefined ? { baseHash: snapshot.hash } : {}), + }); + await refreshPluginRegistryAfterConfigMutation({ + config: next, + reason: "policy-changed", + policyPluginIds: [id], + logger: { + warn: (message) => defaultRuntime.log(theme.warn(message)), + }, + }); + defaultRuntime.log(`Disabled plugin "${id}". Restart the gateway to apply.`); +} + +export async function runPluginsInstallAction( + raw: string, + opts: PluginInstallActionOptions, +): Promise { + await tracePluginLifecyclePhaseAsync( + "install command", + async () => { + const { runPluginInstallCommand } = await import("./plugins-install-command.js"); + await runPluginInstallCommand({ raw, opts }); + }, + { command: "install" }, + ); +} + +export async function runPluginsRegistryCommand(opts: PluginRegistryOptions): Promise { + const { inspectPluginRegistry, refreshPluginRegistry } = + await import("../plugins/plugin-registry.js"); + const cfg = getRuntimeConfig(); + + if (opts.refresh) { + const index = await refreshPluginRegistry({ + config: cfg, + reason: "manual", + }); + if (opts.json) { + defaultRuntime.writeJson({ + refreshed: true, + registry: index, + }); + return; + } + const total = index.plugins.length; + const enabled = countEnabledPlugins(index.plugins); + defaultRuntime.log(`Plugin registry refreshed: ${enabled}/${total} enabled plugins indexed.`); + return; + } + + const inspection = await inspectPluginRegistry({ config: cfg }); + if (opts.json) { + defaultRuntime.writeJson({ + state: inspection.state, + refreshReasons: inspection.refreshReasons, + persisted: inspection.persisted, + current: inspection.current, + }); + return; + } + + const currentTotal = inspection.current.plugins.length; + const currentEnabled = countEnabledPlugins(inspection.current.plugins); + const persistedTotal = inspection.persisted?.plugins.length ?? 0; + const persistedEnabled = inspection.persisted + ? countEnabledPlugins(inspection.persisted.plugins) + : 0; + const lines = [ + `${theme.muted("State:")} ${formatRegistryState(inspection.state)}`, + `${theme.muted("Current:")} ${currentEnabled}/${currentTotal} enabled plugins`, + `${theme.muted("Persisted:")} ${persistedEnabled}/${persistedTotal} enabled plugins`, + ]; + if (inspection.refreshReasons.length > 0) { + lines.push(`${theme.muted("Refresh reasons:")} ${inspection.refreshReasons.join(", ")}`); + lines.push(`${theme.muted("Repair:")} ${theme.command("openclaw plugins registry --refresh")}`); + } + defaultRuntime.log(lines.join("\n")); +} + +export async function runPluginsDoctorCommand(): Promise { + const { + buildPluginCompatibilityNotices, + buildPluginDiagnosticsReport, + formatPluginCompatibilityNotice, + } = await import("../plugins/status.js"); + const { + collectStalePluginConfigWarnings, + isStalePluginAutoRepairBlocked, + scanStalePluginConfig, + } = await import("../commands/doctor/shared/stale-plugin-config.js"); + const cfg = getRuntimeConfig(); + const configSnapshot = await readConfigFileSnapshot().catch(() => null); + const sourceCfg = (configSnapshot?.sourceConfig ?? configSnapshot?.config ?? cfg) as + | OpenClawConfig + | undefined; + const report = buildPluginDiagnosticsReport({ config: cfg, effectiveOnly: true }); + const errors = report.plugins.filter((p) => p.status === "error"); + const diags = report.diagnostics.filter((d) => d.level === "error"); + const shadowed = report.diagnostics.filter((entry) => + isErroredConfigSelectedShadowDiagnostic({ entry, plugins: report.plugins }), + ); + const compatibility = buildPluginCompatibilityNotices({ report }); + const stalePluginConfigHits = scanStalePluginConfig(sourceCfg ?? cfg, process.env); + const stalePluginConfigWarnings = collectStalePluginConfigWarnings({ + hits: stalePluginConfigHits, + doctorFixCommand: "openclaw doctor --fix", + autoRepairBlocked: isStalePluginAutoRepairBlocked(sourceCfg ?? cfg, process.env), + }); + const hasInstallTreeIssues = + errors.length > 0 || diags.length > 0 || shadowed.length > 0 || compatibility.length > 0; + + if (!hasInstallTreeIssues && stalePluginConfigWarnings.length === 0) { + defaultRuntime.log("No plugin issues detected."); + return; + } + + const lines: string[] = []; + if (errors.length > 0) { + lines.push(theme.error("Plugin errors:")); + for (const entry of errors) { + const phase = entry.failurePhase ? ` [${entry.failurePhase}]` : ""; + lines.push(`- ${entry.id}${phase}: ${entry.error ?? "failed to load"} (${entry.source})`); + } + } + if (diags.length > 0) { + if (lines.length > 0) { + lines.push(""); + } + lines.push(theme.warn("Diagnostics:")); + for (const diag of diags) { + const target = diag.pluginId ? `${diag.pluginId}: ` : ""; + lines.push(`- ${target}${diag.message}`); + } + } + if (shadowed.length > 0) { + if (lines.length > 0) { + lines.push(""); + } + lines.push(theme.warn("Plugin source shadowing:")); + for (const diag of shadowed) { + const active = report.plugins.find((plugin) => plugin.id === diag.pluginId); + const target = diag.pluginId ? `${diag.pluginId}: ` : ""; + lines.push(`- ${target}${diag.message}`); + if (active) { + lines.push(` active: ${shortenHomeInString(active.source)} (${active.origin})`); + if (active.status === "error") { + lines.push(` active status: error${active.error ? `: ${active.error}` : ""}`); + } + } + if (diag.source) { + lines.push(` shadowed: ${shortenHomeInString(diag.source)}`); + } + lines.push(" repair:"); + lines.push(" openclaw plugins inspect " + (diag.pluginId ?? "")); + lines.push(" edit or remove the config-selected plugin source"); + lines.push(" openclaw plugins registry --refresh"); + lines.push(" openclaw gateway restart --force"); + } + } + if (compatibility.length > 0) { + if (lines.length > 0) { + lines.push(""); + } + lines.push(theme.warn("Compatibility:")); + for (const notice of compatibility) { + const marker = notice.severity === "warn" ? theme.warn("warn") : theme.muted("info"); + lines.push(`- ${formatPluginCompatibilityNotice(notice)} [${marker}]`); + } + } + if (stalePluginConfigWarnings.length > 0) { + if (lines.length > 0) { + lines.push(""); + } + lines.push(theme.warn("Plugin configuration:")); + lines.push(...stalePluginConfigWarnings); + } + if (!hasInstallTreeIssues && stalePluginConfigWarnings.length > 0) { + if (lines.length > 0) { + lines.push(""); + } + lines.push("No plugin install-tree issues detected; configuration warnings remain."); + } + const docs = formatDocsLink("/plugin", "docs.openclaw.ai/plugin"); + lines.push(""); + lines.push(`${theme.muted("Docs:")} ${docs}`); + defaultRuntime.log(lines.join("\n")); +} + +export async function runPluginMarketplaceListCommand( + source: string, + opts: PluginMarketplaceListOptions, +): Promise { + const { listMarketplacePlugins } = await import("../plugins/marketplace.js"); + const { createPluginInstallLogger } = await import("./plugins-command-helpers.js"); + const result = await listMarketplacePlugins({ + marketplace: source, + logger: createPluginInstallLogger(), + }); + if (!result.ok) { + defaultRuntime.error(result.error); + return defaultRuntime.exit(1); + } + + if (opts.json) { + defaultRuntime.writeJson({ + source: result.sourceLabel, + name: result.manifest.name, + version: result.manifest.version, + plugins: result.manifest.plugins, + }); + return; + } + + if (result.manifest.plugins.length === 0) { + defaultRuntime.log(`No plugins found in marketplace ${result.sourceLabel}.`); + return; + } + + defaultRuntime.log( + `${theme.heading("Marketplace")} ${theme.muted(result.manifest.name ?? result.sourceLabel)}`, + ); + for (const plugin of result.manifest.plugins) { + const suffix = plugin.version ? theme.muted(` v${plugin.version}`) : ""; + const desc = plugin.description ? ` - ${theme.muted(plugin.description)}` : ""; + defaultRuntime.log(`${theme.command(plugin.name)}${suffix}${desc}`); + } +} diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 0c64e135eaa..d46729d0e14 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -1,17 +1,6 @@ import type { Command } from "commander"; -import { - assertConfigWriteAllowedInCurrentMode, - getRuntimeConfig, - readConfigFileSnapshot, - replaceConfigFile, -} from "../config/config.js"; -import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { tracePluginLifecyclePhaseAsync } from "../plugins/plugin-lifecycle-trace.js"; -import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import { theme } from "../terminal/theme.js"; -import { shortenHomeInString } from "../utils.js"; -import { formatMissingPluginMessage } from "./error-format.js"; import type { PluginInspectOptions } from "./plugins-inspect-command.js"; import type { PluginsListOptions } from "./plugins-list-command.js"; import { applyParentDefaultHelpAction } from "./program/parent-default-help.js"; @@ -44,52 +33,6 @@ export type PluginRegistryOptions = { refresh?: boolean; }; -function countEnabledPlugins(plugins: readonly { enabled: boolean }[]): number { - return plugins.filter((plugin) => plugin.enabled).length; -} - -function formatRegistryState(state: "missing" | "fresh" | "stale"): string { - if (state === "fresh") { - return theme.success(state); - } - if (state === "stale") { - return theme.warn(state); - } - return theme.warn(state); -} - -function reportMissingPlugin(id: string) { - defaultRuntime.error(formatMissingPluginMessage({ id, includeSearch: true })); - return defaultRuntime.exit(1); -} - -function matchesPluginId(plugin: { id: string }, id: string) { - return plugin.id === id; -} - -function isConfigSelectedShadowDiagnostic(entry: { level?: string; message?: string }): boolean { - return ( - entry.level === "warn" && - typeof entry.message === "string" && - entry.message.includes("duplicate plugin id resolved by explicit config-selected plugin") - ); -} - -function isErroredConfigSelectedShadowDiagnostic(params: { - entry: { level?: string; message?: string; pluginId?: string }; - plugins: readonly { id: string; origin: string; status: string }[]; -}): boolean { - if (!params.entry.pluginId || !isConfigSelectedShadowDiagnostic(params.entry)) { - return false; - } - return params.plugins.some( - (plugin) => - plugin.id === params.entry.pluginId && - plugin.origin === "config" && - plugin.status === "error", - ); -} - export function registerPluginsCli(program: Command) { const plugins = program .command("plugins") @@ -140,50 +83,8 @@ export function registerPluginsCli(program: Command) { .description("Enable a plugin in config") .argument("", "Plugin id") .action(async (id: string) => { - assertConfigWriteAllowedInCurrentMode(); - - const { enablePluginInConfig } = await import("../plugins/enable.js"); - const { normalizePluginId } = await import("../plugins/config-state.js"); - const { buildPluginRegistrySnapshotReport } = await import("../plugins/status.js"); - const { applySlotSelectionForPlugin, logSlotWarnings } = - await import("./plugins-command-helpers.js"); - const { refreshPluginRegistryAfterConfigMutation } = - await import("./plugins-registry-refresh.js"); - const snapshot = await readConfigFileSnapshot(); - const cfg = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig; - const report = buildPluginRegistrySnapshotReport({ config: cfg }); - id = normalizePluginId(id); - if (!report.plugins.some((plugin) => matchesPluginId(plugin, id))) { - return reportMissingPlugin(id); - } - const enableResult = enablePluginInConfig(cfg, id, { - updateChannelConfig: false, - }); - let next: OpenClawConfig = enableResult.config; - const slotResult = applySlotSelectionForPlugin(next, id); - next = slotResult.config; - await replaceConfigFile({ - nextConfig: next, - ...(snapshot.hash !== undefined ? { baseHash: snapshot.hash } : {}), - }); - await refreshPluginRegistryAfterConfigMutation({ - config: next, - reason: "policy-changed", - policyPluginIds: [enableResult.pluginId], - logger: { - warn: (message) => defaultRuntime.log(theme.warn(message)), - }, - }); - logSlotWarnings(slotResult.warnings); - if (enableResult.enabled) { - defaultRuntime.log(`Enabled plugin "${id}". Restart the gateway to apply.`); - return; - } - defaultRuntime.log( - theme.warn( - `Plugin "${id}" could not be enabled (${enableResult.reason ?? "unknown reason"}).`, - ), - ); + const { runPluginsEnableCommand } = await import("./plugins-cli.runtime.js"); + await runPluginsEnableCommand(id); }); plugins @@ -191,36 +92,8 @@ export function registerPluginsCli(program: Command) { .description("Disable a plugin in config") .argument("", "Plugin id") .action(async (id: string) => { - assertConfigWriteAllowedInCurrentMode(); - - const { normalizePluginId } = await import("../plugins/config-state.js"); - const { buildPluginRegistrySnapshotReport } = await import("../plugins/status.js"); - const { setPluginEnabledInConfig } = await import("./plugins-config.js"); - const { refreshPluginRegistryAfterConfigMutation } = - await import("./plugins-registry-refresh.js"); - const snapshot = await readConfigFileSnapshot(); - const cfg = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig; - const report = buildPluginRegistrySnapshotReport({ config: cfg }); - id = normalizePluginId(id); - if (!report.plugins.some((plugin) => matchesPluginId(plugin, id))) { - return reportMissingPlugin(id); - } - const next = setPluginEnabledInConfig(cfg, id, false, { - updateChannelConfig: false, - }); - await replaceConfigFile({ - nextConfig: next, - ...(snapshot.hash !== undefined ? { baseHash: snapshot.hash } : {}), - }); - await refreshPluginRegistryAfterConfigMutation({ - config: next, - reason: "policy-changed", - policyPluginIds: [id], - logger: { - warn: (message) => defaultRuntime.log(theme.warn(message)), - }, - }); - defaultRuntime.log(`Disabled plugin "${id}". Restart the gateway to apply.`); + const { runPluginsDisableCommand } = await import("./plugins-cli.runtime.js"); + await runPluginsDisableCommand(id); }); plugins @@ -268,14 +141,8 @@ export function registerPluginsCli(program: Command) { marketplace?: string; }, ) => { - await tracePluginLifecyclePhaseAsync( - "install command", - async () => { - const { runPluginInstallCommand } = await import("./plugins-install-command.js"); - await runPluginInstallCommand({ raw, opts }); - }, - { command: "install" }, - ); + const { runPluginsInstallAction } = await import("./plugins-cli.runtime.js"); + await runPluginsInstallAction(raw, opts); }, ); @@ -301,171 +168,16 @@ export function registerPluginsCli(program: Command) { .option("--json", "Print JSON") .option("--refresh", "Rebuild the persisted registry from current plugin manifests", false) .action(async (opts: PluginRegistryOptions) => { - const { inspectPluginRegistry, refreshPluginRegistry } = - await import("../plugins/plugin-registry.js"); - const cfg = getRuntimeConfig(); - - if (opts.refresh) { - const index = await refreshPluginRegistry({ - config: cfg, - reason: "manual", - }); - if (opts.json) { - defaultRuntime.writeJson({ - refreshed: true, - registry: index, - }); - return; - } - const total = index.plugins.length; - const enabled = countEnabledPlugins(index.plugins); - defaultRuntime.log( - `Plugin registry refreshed: ${enabled}/${total} enabled plugins indexed.`, - ); - return; - } - - const inspection = await inspectPluginRegistry({ config: cfg }); - if (opts.json) { - defaultRuntime.writeJson({ - state: inspection.state, - refreshReasons: inspection.refreshReasons, - persisted: inspection.persisted, - current: inspection.current, - }); - return; - } - - const currentTotal = inspection.current.plugins.length; - const currentEnabled = countEnabledPlugins(inspection.current.plugins); - const persistedTotal = inspection.persisted?.plugins.length ?? 0; - const persistedEnabled = inspection.persisted - ? countEnabledPlugins(inspection.persisted.plugins) - : 0; - const lines = [ - `${theme.muted("State:")} ${formatRegistryState(inspection.state)}`, - `${theme.muted("Current:")} ${currentEnabled}/${currentTotal} enabled plugins`, - `${theme.muted("Persisted:")} ${persistedEnabled}/${persistedTotal} enabled plugins`, - ]; - if (inspection.refreshReasons.length > 0) { - lines.push(`${theme.muted("Refresh reasons:")} ${inspection.refreshReasons.join(", ")}`); - lines.push( - `${theme.muted("Repair:")} ${theme.command("openclaw plugins registry --refresh")}`, - ); - } - defaultRuntime.log(lines.join("\n")); + const { runPluginsRegistryCommand } = await import("./plugins-cli.runtime.js"); + await runPluginsRegistryCommand(opts); }); plugins .command("doctor") .description("Report plugin load issues") .action(async () => { - const { - buildPluginCompatibilityNotices, - buildPluginDiagnosticsReport, - formatPluginCompatibilityNotice, - } = await import("../plugins/status.js"); - const { - collectStalePluginConfigWarnings, - isStalePluginAutoRepairBlocked, - scanStalePluginConfig, - } = await import("../commands/doctor/shared/stale-plugin-config.js"); - const cfg = getRuntimeConfig(); - const configSnapshot = await readConfigFileSnapshot().catch(() => null); - const sourceCfg = (configSnapshot?.sourceConfig ?? configSnapshot?.config ?? cfg) as - | OpenClawConfig - | undefined; - const report = buildPluginDiagnosticsReport({ config: cfg, effectiveOnly: true }); - const errors = report.plugins.filter((p) => p.status === "error"); - const diags = report.diagnostics.filter((d) => d.level === "error"); - const shadowed = report.diagnostics.filter((entry) => - isErroredConfigSelectedShadowDiagnostic({ entry, plugins: report.plugins }), - ); - const compatibility = buildPluginCompatibilityNotices({ report }); - const stalePluginConfigHits = scanStalePluginConfig(sourceCfg ?? cfg, process.env); - const stalePluginConfigWarnings = collectStalePluginConfigWarnings({ - hits: stalePluginConfigHits, - doctorFixCommand: "openclaw doctor --fix", - autoRepairBlocked: isStalePluginAutoRepairBlocked(sourceCfg ?? cfg, process.env), - }); - const hasInstallTreeIssues = - errors.length > 0 || diags.length > 0 || shadowed.length > 0 || compatibility.length > 0; - - if (!hasInstallTreeIssues && stalePluginConfigWarnings.length === 0) { - defaultRuntime.log("No plugin issues detected."); - return; - } - - const lines: string[] = []; - if (errors.length > 0) { - lines.push(theme.error("Plugin errors:")); - for (const entry of errors) { - const phase = entry.failurePhase ? ` [${entry.failurePhase}]` : ""; - lines.push(`- ${entry.id}${phase}: ${entry.error ?? "failed to load"} (${entry.source})`); - } - } - if (diags.length > 0) { - if (lines.length > 0) { - lines.push(""); - } - lines.push(theme.warn("Diagnostics:")); - for (const diag of diags) { - const target = diag.pluginId ? `${diag.pluginId}: ` : ""; - lines.push(`- ${target}${diag.message}`); - } - } - if (shadowed.length > 0) { - if (lines.length > 0) { - lines.push(""); - } - lines.push(theme.warn("Plugin source shadowing:")); - for (const diag of shadowed) { - const active = report.plugins.find((plugin) => plugin.id === diag.pluginId); - const target = diag.pluginId ? `${diag.pluginId}: ` : ""; - lines.push(`- ${target}${diag.message}`); - if (active) { - lines.push(` active: ${shortenHomeInString(active.source)} (${active.origin})`); - if (active.status === "error") { - lines.push(` active status: error${active.error ? `: ${active.error}` : ""}`); - } - } - if (diag.source) { - lines.push(` shadowed: ${shortenHomeInString(diag.source)}`); - } - lines.push(" repair:"); - lines.push(" openclaw plugins inspect " + (diag.pluginId ?? "")); - lines.push(" edit or remove the config-selected plugin source"); - lines.push(" openclaw plugins registry --refresh"); - lines.push(" openclaw gateway restart --force"); - } - } - if (compatibility.length > 0) { - if (lines.length > 0) { - lines.push(""); - } - lines.push(theme.warn("Compatibility:")); - for (const notice of compatibility) { - const marker = notice.severity === "warn" ? theme.warn("warn") : theme.muted("info"); - lines.push(`- ${formatPluginCompatibilityNotice(notice)} [${marker}]`); - } - } - if (stalePluginConfigWarnings.length > 0) { - if (lines.length > 0) { - lines.push(""); - } - lines.push(theme.warn("Plugin configuration:")); - lines.push(...stalePluginConfigWarnings); - } - if (!hasInstallTreeIssues && stalePluginConfigWarnings.length > 0) { - if (lines.length > 0) { - lines.push(""); - } - lines.push("No plugin install-tree issues detected; configuration warnings remain."); - } - const docs = formatDocsLink("/plugin", "docs.openclaw.ai/plugin"); - lines.push(""); - lines.push(`${theme.muted("Docs:")} ${docs}`); - defaultRuntime.log(lines.join("\n")); + const { runPluginsDoctorCommand } = await import("./plugins-cli.runtime.js"); + await runPluginsDoctorCommand(); }); const marketplace = plugins @@ -478,40 +190,8 @@ export function registerPluginsCli(program: Command) { .argument("", "Local marketplace path/repo or git/GitHub source") .option("--json", "Print JSON") .action(async (source: string, opts: PluginMarketplaceListOptions) => { - const { listMarketplacePlugins } = await import("../plugins/marketplace.js"); - const { createPluginInstallLogger } = await import("./plugins-command-helpers.js"); - const result = await listMarketplacePlugins({ - marketplace: source, - logger: createPluginInstallLogger(), - }); - if (!result.ok) { - defaultRuntime.error(result.error); - return defaultRuntime.exit(1); - } - - if (opts.json) { - defaultRuntime.writeJson({ - source: result.sourceLabel, - name: result.manifest.name, - version: result.manifest.version, - plugins: result.manifest.plugins, - }); - return; - } - - if (result.manifest.plugins.length === 0) { - defaultRuntime.log(`No plugins found in marketplace ${result.sourceLabel}.`); - return; - } - - defaultRuntime.log( - `${theme.heading("Marketplace")} ${theme.muted(result.manifest.name ?? result.sourceLabel)}`, - ); - for (const plugin of result.manifest.plugins) { - const suffix = plugin.version ? theme.muted(` v${plugin.version}`) : ""; - const desc = plugin.description ? ` - ${theme.muted(plugin.description)}` : ""; - defaultRuntime.log(`${theme.command(plugin.name)}${suffix}${desc}`); - } + const { runPluginMarketplaceListCommand } = await import("./plugins-cli.runtime.js"); + await runPluginMarketplaceListCommand(source, opts); }); applyParentDefaultHelpAction(plugins);