From caf25fac9140fd1d4b77fcce241cfc2aad1d7d6b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 25 Apr 2026 05:12:14 -0700 Subject: [PATCH] feat(plugins): add registry repair command --- CHANGELOG.md | 1 + src/cli/plugins-cli-test-helpers.ts | 44 ++++++++++++++++ src/cli/plugins-cli.list.test.ts | 45 ++++++++++++++++ src/cli/plugins-cli.ts | 79 +++++++++++++++++++++++++++++ 4 files changed, 169 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e273a2b555..e0624a84b0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai - CLI/Crestodian: shorten the startup greeting to the active planner/model, config state, Gateway probe result, and next debug action instead of dumping every discovered backend. - Plugins: migrate the local plugin registry automatically during package install/update, preserving legacy config and install-ledger state while indexing existing plugin manifests for the new cold registry path. Thanks @vincentkoc. - Diagnostics/OTEL: align model-call GenAI span attributes with OpenTelemetry stability opt-in semantics, keeping legacy `gen_ai.system` by default while emitting `gen_ai.provider.name` under `OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental`. Thanks @vincentkoc. +- Plugins/CLI: add `openclaw plugins registry` for explicit persisted-registry inspection and `--refresh` repair without making normal startup rescan plugin locations. Thanks @vincentkoc. - Diagnostics/OTEL: add bounded outbound message delivery lifecycle diagnostics and export them as low-cardinality delivery spans/metrics without message body, recipient, room, or media-path data. (#71471) Thanks @vincentkoc and @jlapenna. - Diagnostics/OTEL: emit bounded exec-process diagnostics and export them as `openclaw.exec` spans without exposing command text, working directories, or container identifiers. (#71451) Thanks @vincentkoc and @jlapenna. - Diagnostics/OTEL: support `OPENCLAW_OTEL_PRELOADED=1` so the plugin can reuse an already-registered OpenTelemetry SDK while keeping OpenClaw diagnostic listeners wired. (#71450) Thanks @vincentkoc and @jlapenna. diff --git a/src/cli/plugins-cli-test-helpers.ts b/src/cli/plugins-cli-test-helpers.ts index 1c5ff1171d8..0f078736855 100644 --- a/src/cli/plugins-cli-test-helpers.ts +++ b/src/cli/plugins-cli-test-helpers.ts @@ -38,6 +38,8 @@ export const buildPluginSnapshotReport: UnknownMock = vi.fn(); export const buildPluginInspectReport: UnknownMock = vi.fn(); export const buildPluginDiagnosticsReport: UnknownMock = vi.fn(); export const buildPluginCompatibilityNotices: UnknownMock = vi.fn(); +export const inspectPluginRegistry: AsyncUnknownMock = vi.fn(); +export const refreshPluginRegistry: AsyncUnknownMock = vi.fn(); export const applyExclusiveSlotSelection: UnknownMock = vi.fn(); export const uninstallPlugin: AsyncUnknownMock = vi.fn(); export const updateNpmInstalledPlugins: AsyncUnknownMock = vi.fn(); @@ -200,6 +202,29 @@ vi.mock("../plugins/status.js", () => ({ formatPluginCompatibilityNotice: (entry: { message: string }) => entry.message, })); +vi.mock("../plugins/plugin-registry.js", () => ({ + inspectPluginRegistry: (( + ...args: Parameters<(typeof import("../plugins/plugin-registry.js"))["inspectPluginRegistry"]> + ) => + invokeMock< + Parameters<(typeof import("../plugins/plugin-registry.js"))["inspectPluginRegistry"]>, + ReturnType<(typeof import("../plugins/plugin-registry.js"))["inspectPluginRegistry"]> + >( + inspectPluginRegistry, + ...args, + )) as (typeof import("../plugins/plugin-registry.js"))["inspectPluginRegistry"], + refreshPluginRegistry: (( + ...args: Parameters<(typeof import("../plugins/plugin-registry.js"))["refreshPluginRegistry"]> + ) => + invokeMock< + Parameters<(typeof import("../plugins/plugin-registry.js"))["refreshPluginRegistry"]>, + ReturnType<(typeof import("../plugins/plugin-registry.js"))["refreshPluginRegistry"]> + >( + refreshPluginRegistry, + ...args, + )) as (typeof import("../plugins/plugin-registry.js"))["refreshPluginRegistry"], +})); + vi.mock("../plugins/slots.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -392,6 +417,8 @@ export function resetPluginsCliTestState() { buildPluginInspectReport.mockReset(); buildPluginDiagnosticsReport.mockReset(); buildPluginCompatibilityNotices.mockReset(); + inspectPluginRegistry.mockReset(); + refreshPluginRegistry.mockReset(); applyExclusiveSlotSelection.mockReset(); uninstallPlugin.mockReset(); updateNpmInstalledPlugins.mockReset(); @@ -452,6 +479,23 @@ export function resetPluginsCliTestState() { buildPluginSnapshotReport.mockReturnValue(defaultPluginReport); buildPluginDiagnosticsReport.mockReturnValue(defaultPluginReport); buildPluginCompatibilityNotices.mockReturnValue([]); + const defaultRegistryIndex = { + version: 1, + hostContractVersion: "2026.4.25", + compatRegistryVersion: "compat-v1", + migrationVersion: 1, + policyHash: "policy-v1", + generatedAtMs: 1777118400000, + plugins: [], + diagnostics: [], + }; + inspectPluginRegistry.mockResolvedValue({ + state: "fresh", + refreshReasons: [], + persisted: defaultRegistryIndex, + current: defaultRegistryIndex, + }); + refreshPluginRegistry.mockResolvedValue(defaultRegistryIndex); applyExclusiveSlotSelection.mockImplementation((({ config }: { config: OpenClawConfig }) => ({ config, warnings: [], diff --git a/src/cli/plugins-cli.list.test.ts b/src/cli/plugins-cli.list.test.ts index 424c33bb09b..0bde971364b 100644 --- a/src/cli/plugins-cli.list.test.ts +++ b/src/cli/plugins-cli.list.test.ts @@ -4,7 +4,9 @@ import { buildPluginDiagnosticsReport, buildPluginInspectReport, buildPluginSnapshotReport, + inspectPluginRegistry, resetPluginsCliTestState, + refreshPluginRegistry, runPluginsCommand, runtimeLogs, } from "./plugins-cli-test-helpers.js"; @@ -66,6 +68,49 @@ describe("plugins cli list", () => { expect(runtimeLogs).toContain("No plugin issues detected."); }); + it("reports persisted plugin registry state without refreshing", async () => { + inspectPluginRegistry.mockResolvedValue({ + state: "stale", + refreshReasons: ["stale-manifest"], + persisted: { + plugins: [{ pluginId: "demo", enabled: true }], + }, + current: { + plugins: [ + { pluginId: "demo", enabled: true }, + { pluginId: "next", enabled: false }, + ], + }, + }); + + await runPluginsCommand(["plugins", "registry"]); + + expect(inspectPluginRegistry).toHaveBeenCalledWith({ config: {} }); + expect(refreshPluginRegistry).not.toHaveBeenCalled(); + expect(runtimeLogs.join("\n")).toContain("State:"); + expect(runtimeLogs.join("\n")).toContain("stale"); + expect(runtimeLogs.join("\n")).toContain("Refresh reasons:"); + expect(runtimeLogs.join("\n")).toContain("openclaw plugins registry --refresh"); + }); + + it("refreshes the persisted plugin registry on request", async () => { + refreshPluginRegistry.mockResolvedValue({ + plugins: [ + { pluginId: "demo", enabled: true }, + { pluginId: "off", enabled: false }, + ], + }); + + await runPluginsCommand(["plugins", "registry", "--refresh"]); + + expect(refreshPluginRegistry).toHaveBeenCalledWith({ + config: {}, + reason: "manual", + }); + expect(inspectPluginRegistry).not.toHaveBeenCalled(); + expect(runtimeLogs.join("\n")).toContain("Plugin registry refreshed: 1/2 enabled"); + }); + it("shows conversation-access hook policy in inspect output", async () => { buildPluginInspectReport.mockReturnValue({ workspaceDir: "/workspace", diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 7eb584fde3d..2e5eb5fc4fe 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -7,6 +7,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import { enablePluginInConfig } from "../plugins/enable.js"; import { listMarketplacePlugins } from "../plugins/marketplace.js"; +import { inspectPluginRegistry, refreshPluginRegistry } from "../plugins/plugin-registry.js"; import { defaultSlotIdForKey } from "../plugins/slots.js"; import { formatPluginSourceForTable, resolvePluginSourceRoots } from "../plugins/source-display.js"; import { @@ -69,6 +70,11 @@ export type PluginUninstallOptions = { dryRun?: boolean; }; +export type PluginRegistryOptions = { + json?: boolean; + refresh?: boolean; +}; + const quietPluginJsonLogger: PluginLogger = { debug: () => undefined, info: () => undefined, @@ -137,6 +143,20 @@ function formatInstallLines(install: PluginInstallRecord | undefined): string[] return lines; } +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); +} + export function registerPluginsCli(program: Command) { const plugins = program .command("plugins") @@ -748,6 +768,65 @@ export function registerPluginsCli(program: Command) { await runPluginUpdateCommand({ id, opts }); }); + plugins + .command("registry") + .description("Inspect or rebuild the persisted plugin registry") + .option("--json", "Print JSON") + .option("--refresh", "Rebuild the persisted registry from current plugin manifests", false) + .action(async (opts: PluginRegistryOptions) => { + const cfg = loadConfig(); + + 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")); + }); + plugins .command("doctor") .description("Report plugin load issues")