From 5c4903d3fd179086e47458b845cdb29ff67f9ed7 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 17 Mar 2026 20:31:59 -0700 Subject: [PATCH] Plugins: centralize compatibility formatting --- src/auto-reply/reply/commands-plugins.ts | 5 +- src/cli/plugins-cli.test.ts | 168 +++++++++++++++++++++++ src/cli/plugins-cli.ts | 5 +- src/commands/status-all/diagnosis.ts | 7 +- src/commands/status.command.ts | 11 +- src/plugins/status.test.ts | 33 +++++ src/plugins/status.ts | 22 ++- src/wizard/setup.ts | 7 +- 8 files changed, 244 insertions(+), 14 deletions(-) create mode 100644 src/cli/plugins-cli.test.ts diff --git a/src/auto-reply/reply/commands-plugins.ts b/src/auto-reply/reply/commands-plugins.ts index 3b5dcdb9b60..07fc7630eb0 100644 --- a/src/auto-reply/reply/commands-plugins.ts +++ b/src/auto-reply/reply/commands-plugins.ts @@ -10,6 +10,7 @@ import { buildAllPluginInspectReports, buildPluginInspectReport, buildPluginStatusReport, + formatPluginCompatibilityNotice, type PluginStatusReport, } from "../../plugins/status.js"; import { setPluginEnabledInConfig } from "../../plugins/toggle-config.js"; @@ -48,7 +49,7 @@ function buildPluginInspectJson(params: { compatibilityWarnings: inspect.compatibility.map((warning) => ({ code: warning.code, severity: warning.severity, - message: `${warning.pluginId} ${warning.message}`, + message: formatPluginCompatibilityNotice(warning), })), install: params.config.plugins?.installs?.[inspect.plugin.id] ?? null, }; @@ -69,7 +70,7 @@ function buildAllPluginInspectJson(params: { compatibilityWarnings: inspect.compatibility.map((warning) => ({ code: warning.code, severity: warning.severity, - message: `${warning.pluginId} ${warning.message}`, + message: formatPluginCompatibilityNotice(warning), })), install: params.config.plugins?.installs?.[inspect.plugin.id] ?? null, })); diff --git a/src/cli/plugins-cli.test.ts b/src/cli/plugins-cli.test.ts new file mode 100644 index 00000000000..44d1d2ff262 --- /dev/null +++ b/src/cli/plugins-cli.test.ts @@ -0,0 +1,168 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { runRegisteredCli } from "../test-utils/command-runner.js"; + +const mocks = vi.hoisted(() => ({ + loadConfig: vi.fn(() => ({})), + buildPluginStatusReport: vi.fn(() => ({ + plugins: [], + diagnostics: [], + hooks: [], + typedHooks: [], + })), + buildPluginInspectReport: vi.fn(), + buildAllPluginInspectReports: vi.fn(() => []), + buildPluginCompatibilityNotices: vi.fn(() => []), + defaultRuntime: { + log: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("../config/config.js", () => ({ + loadConfig: mocks.loadConfig, + writeConfigFile: vi.fn(), +})); + +vi.mock("../plugins/status.js", () => ({ + buildPluginStatusReport: mocks.buildPluginStatusReport, + buildPluginInspectReport: mocks.buildPluginInspectReport, + buildAllPluginInspectReports: mocks.buildAllPluginInspectReports, + buildPluginCompatibilityNotices: mocks.buildPluginCompatibilityNotices, +})); + +vi.mock("../runtime.js", () => ({ + defaultRuntime: mocks.defaultRuntime, +})); + +let registerPluginsCli: typeof import("./plugins-cli.js").registerPluginsCli; + +beforeAll(async () => { + ({ registerPluginsCli } = await import("./plugins-cli.js")); +}); + +describe("plugins cli", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.loadConfig.mockReturnValue({}); + mocks.buildPluginStatusReport.mockReturnValue({ + plugins: [], + diagnostics: [], + hooks: [], + typedHooks: [], + }); + mocks.buildPluginInspectReport.mockReset(); + mocks.buildAllPluginInspectReports.mockReturnValue([]); + mocks.buildPluginCompatibilityNotices.mockReturnValue([]); + }); + + it("renders compatibility warnings in plugins inspect output", async () => { + mocks.buildPluginStatusReport.mockReturnValue({ + plugins: [ + { + id: "legacy-plugin", + name: "Legacy Plugin", + description: "legacy seam", + source: "/tmp/legacy.ts", + origin: "workspace", + enabled: true, + status: "loaded", + format: "openclaw", + bundleFormat: undefined, + version: "1.0.0", + bundleCapabilities: [], + }, + ], + diagnostics: [], + hooks: [], + typedHooks: [], + }); + mocks.buildPluginInspectReport.mockReturnValue({ + plugin: { + id: "legacy-plugin", + name: "Legacy Plugin", + description: "legacy seam", + source: "/tmp/legacy.ts", + origin: "workspace", + status: "loaded", + format: "openclaw", + bundleFormat: undefined, + version: "1.0.0", + bundleCapabilities: [], + }, + shape: "hook-only", + capabilityMode: "none", + capabilityCount: 0, + capabilities: [], + typedHooks: [{ name: "before_agent_start" }], + customHooks: [], + tools: [], + commands: [], + cliCommands: [], + services: [], + gatewayMethods: [], + httpRouteCount: 0, + diagnostics: [], + policy: { + allowPromptInjection: undefined, + allowModelOverride: undefined, + allowedModels: [], + hasAllowedModelsConfig: false, + }, + usesLegacyBeforeAgentStart: true, + compatibility: [ + { + pluginId: "legacy-plugin", + code: "legacy-before-agent-start", + severity: "warn", + message: + "still relies on legacy before_agent_start; keep upgrade coverage on this plugin and prefer before_model_resolve/before_prompt_build for new work.", + }, + { + pluginId: "legacy-plugin", + code: "hook-only", + severity: "info", + message: + "is hook-only; this remains supported for compatibility, but it has not migrated to explicit capability registration.", + }, + ], + }); + + await runRegisteredCli({ + register: registerPluginsCli as (program: import("commander").Command) => void, + argv: ["plugins", "inspect", "legacy-plugin"], + }); + + const output = mocks.defaultRuntime.log.mock.calls.map((call) => String(call[0])).join("\n"); + expect(output).toContain("Compatibility warnings"); + expect(output).toContain("legacy-plugin still relies on legacy before_agent_start"); + expect(output).toContain("legacy-plugin is hook-only"); + }); + + it("renders compatibility notices in plugins doctor", async () => { + mocks.buildPluginStatusReport.mockReturnValue({ + plugins: [], + diagnostics: [], + hooks: [], + typedHooks: [], + }); + mocks.buildPluginCompatibilityNotices.mockReturnValue([ + { + pluginId: "legacy-plugin", + code: "legacy-before-agent-start", + severity: "warn", + message: + "still relies on legacy before_agent_start; keep upgrade coverage on this plugin and prefer before_model_resolve/before_prompt_build for new work.", + }, + ]); + + await runRegisteredCli({ + register: registerPluginsCli as (program: import("commander").Command) => void, + argv: ["plugins", "doctor"], + }); + + const output = mocks.defaultRuntime.log.mock.calls.map((call) => String(call[0])).join("\n"); + expect(output).toContain("Compatibility:"); + expect(output).toContain("legacy-plugin"); + expect(output).toContain("still relies on legacy before_agent_start"); + }); +}); diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index ad52aa4559d..8342b6c58b3 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -25,6 +25,7 @@ import { buildPluginCompatibilityNotices, buildPluginInspectReport, buildPluginStatusReport, + formatPluginCompatibilityNotice, } from "../plugins/status.js"; import { resolveUninstallDirectoryTarget, uninstallPlugin } from "../plugins/uninstall.js"; import { updateNpmInstalledPlugins } from "../plugins/update.js"; @@ -762,7 +763,7 @@ export function registerPluginsCli(program: Command) { lines.push( ...formatInspectSection( "Compatibility warnings", - inspect.compatibility.map((warning) => `${warning.pluginId} ${warning.message}`), + inspect.compatibility.map(formatPluginCompatibilityNotice), ), ); lines.push( @@ -1103,7 +1104,7 @@ export function registerPluginsCli(program: Command) { lines.push(theme.warn("Compatibility:")); for (const notice of compatibility) { const marker = notice.severity === "warn" ? theme.warn("warn") : theme.muted("info"); - lines.push(`- ${notice.pluginId} [${marker}]: ${notice.message}`); + lines.push(`- ${formatPluginCompatibilityNotice(notice)} [${marker}]`); } } const docs = formatDocsLink("/plugin", "docs.openclaw.ai/plugin"); diff --git a/src/commands/status-all/diagnosis.ts b/src/commands/status-all/diagnosis.ts index 66ae5d02ecd..289fdb7a16e 100644 --- a/src/commands/status-all/diagnosis.ts +++ b/src/commands/status-all/diagnosis.ts @@ -6,7 +6,10 @@ import { type RestartSentinelPayload, summarizeRestartSentinel, } from "../../infra/restart-sentinel.js"; -import type { PluginCompatibilityNotice } from "../../plugins/status.js"; +import { + formatPluginCompatibilityNotice, + type PluginCompatibilityNotice, +} from "../../plugins/status.js"; import { formatTimeAgo, redactSecrets } from "./format.js"; import { readFileTailLines, summarizeLogTail } from "./gateway.js"; @@ -184,7 +187,7 @@ export async function appendStatusAllDiagnosis(params: { ); for (const notice of params.pluginCompatibility.slice(0, 12)) { const severity = notice.severity === "warn" ? "warn" : "info"; - lines.push(` - ${notice.pluginId} [${severity}] ${notice.message}`); + lines.push(` - [${severity}] ${formatPluginCompatibilityNotice(notice)}`); } if (params.pluginCompatibility.length > 12) { lines.push(` ${muted(`… +${params.pluginCompatibility.length - 12} more`)}`); diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index 18e4c53ebf7..363828ed550 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -13,6 +13,10 @@ import { resolveMemoryVectorState, type Tone, } from "../memory/status-format.js"; +import { + formatPluginCompatibilityNotice, + summarizePluginCompatibility, +} from "../plugins/status.js"; import type { RuntimeEnv } from "../runtime.js"; import { getTerminalTableWidth, renderTable } from "../terminal/table.js"; import { theme } from "../terminal/theme.js"; @@ -421,11 +425,12 @@ export async function statusCommand( const updateLine = formatUpdateOneLiner(update).replace(/^Update:\s*/i, ""); const channelLabel = channelInfo.label; const gitLabel = formatGitInstallLabel(update); + const pluginCompatibilitySummary = summarizePluginCompatibility(pluginCompatibility); const pluginCompatibilityValue = - pluginCompatibility.length === 0 + pluginCompatibilitySummary.noticeCount === 0 ? ok("none") : warn( - `${pluginCompatibility.length} notice${pluginCompatibility.length === 1 ? "" : "s"} · ${new Set(pluginCompatibility.map((entry) => entry.pluginId)).size} plugin${new Set(pluginCompatibility.map((entry) => entry.pluginId)).size === 1 ? "" : "s"}`, + `${pluginCompatibilitySummary.noticeCount} notice${pluginCompatibilitySummary.noticeCount === 1 ? "" : "s"} · ${pluginCompatibilitySummary.pluginCount} plugin${pluginCompatibilitySummary.pluginCount === 1 ? "" : "s"}`, ); const overviewRows = [ @@ -484,7 +489,7 @@ export async function statusCommand( runtime.log(theme.heading("Plugin compatibility")); for (const notice of pluginCompatibility.slice(0, 8)) { const label = notice.severity === "warn" ? theme.warn("WARN") : theme.muted("INFO"); - runtime.log(` ${label} ${notice.pluginId} ${notice.message}`); + runtime.log(` ${label} ${formatPluginCompatibilityNotice(notice)}`); } if (pluginCompatibility.length > 8) { runtime.log(theme.muted(` … +${pluginCompatibility.length - 8} more`)); diff --git a/src/plugins/status.test.ts b/src/plugins/status.test.ts index 7cbdffb4e04..04ba3c9679f 100644 --- a/src/plugins/status.test.ts +++ b/src/plugins/status.test.ts @@ -7,6 +7,8 @@ let buildPluginInspectReport: typeof import("./status.js").buildPluginInspectRep let buildAllPluginInspectReports: typeof import("./status.js").buildAllPluginInspectReports; let buildPluginCompatibilityNotices: typeof import("./status.js").buildPluginCompatibilityNotices; let buildPluginCompatibilityWarnings: typeof import("./status.js").buildPluginCompatibilityWarnings; +let formatPluginCompatibilityNotice: typeof import("./status.js").formatPluginCompatibilityNotice; +let summarizePluginCompatibility: typeof import("./status.js").summarizePluginCompatibility; vi.mock("../config/config.js", () => ({ loadConfig: () => loadConfigMock(), @@ -56,6 +58,8 @@ describe("buildPluginStatusReport", () => { buildPluginCompatibilityWarnings, buildPluginInspectReport, buildPluginStatusReport, + formatPluginCompatibilityNotice, + summarizePluginCompatibility, } = await import("./status.js")); }); @@ -488,4 +492,33 @@ describe("buildPluginStatusReport", () => { expect(buildPluginCompatibilityNotices()).toEqual([]); expect(buildPluginCompatibilityWarnings()).toEqual([]); }); + + it("formats and summarizes compatibility notices", () => { + const notice = { + pluginId: "legacy-plugin", + code: "legacy-before-agent-start" as const, + severity: "warn" as const, + message: + "still relies on legacy before_agent_start; keep upgrade coverage on this plugin and prefer before_model_resolve/before_prompt_build for new work.", + }; + + expect(formatPluginCompatibilityNotice(notice)).toBe( + "legacy-plugin still relies on legacy before_agent_start; keep upgrade coverage on this plugin and prefer before_model_resolve/before_prompt_build for new work.", + ); + expect( + summarizePluginCompatibility([ + notice, + { + pluginId: "legacy-plugin", + code: "hook-only", + severity: "info", + message: + "is hook-only; this remains supported for compatibility, but it has not migrated to explicit capability registration.", + }, + ]), + ).toEqual({ + noticeCount: 2, + pluginCount: 1, + }); + }); }); diff --git a/src/plugins/status.ts b/src/plugins/status.ts index 47a7b7f845e..154ea25262e 100644 --- a/src/plugins/status.ts +++ b/src/plugins/status.ts @@ -33,6 +33,11 @@ export type PluginCompatibilityNotice = { message: string; }; +export type PluginCompatibilitySummary = { + noticeCount: number; + pluginCount: number; +}; + export type PluginInspectReport = { workspaceDir?: string; plugin: PluginRegistry["plugins"][number]; @@ -288,9 +293,7 @@ export function buildPluginCompatibilityWarnings(params?: { env?: NodeJS.ProcessEnv; report?: PluginStatusReport; }): string[] { - return buildAllPluginInspectReports(params).flatMap((inspect) => - inspect.compatibility.map((warning) => `${warning.pluginId} ${warning.message}`), - ); + return buildPluginCompatibilityNotices(params).map(formatPluginCompatibilityNotice); } export function buildPluginCompatibilityNotices(params?: { @@ -301,3 +304,16 @@ export function buildPluginCompatibilityNotices(params?: { }): PluginCompatibilityNotice[] { return buildAllPluginInspectReports(params).flatMap((inspect) => inspect.compatibility); } + +export function formatPluginCompatibilityNotice(notice: PluginCompatibilityNotice): string { + return `${notice.pluginId} ${notice.message}`; +} + +export function summarizePluginCompatibility( + notices: PluginCompatibilityNotice[], +): PluginCompatibilitySummary { + return { + noticeCount: notices.length, + pluginCount: new Set(notices.map((notice) => notice.pluginId)).size, + }; +} diff --git a/src/wizard/setup.ts b/src/wizard/setup.ts index 92abd51a20e..5e87a967c25 100644 --- a/src/wizard/setup.ts +++ b/src/wizard/setup.ts @@ -13,7 +13,10 @@ import { writeConfigFile, } from "../config/config.js"; import { normalizeSecretInputString } from "../config/types.secrets.js"; -import { buildPluginCompatibilityNotices } from "../plugins/status.js"; +import { + buildPluginCompatibilityNotices, + formatPluginCompatibilityNotice, +} from "../plugins/status.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { resolveUserPath } from "../utils.js"; @@ -112,7 +115,7 @@ export async function runSetupWizard( `Detected ${compatibilityNotices.length} plugin compatibility notice${compatibilityNotices.length === 1 ? "" : "s"} in the current config.`, ...compatibilityNotices .slice(0, 4) - .map((notice) => `- ${notice.pluginId}: ${notice.message}`), + .map((notice) => `- ${formatPluginCompatibilityNotice(notice)}`), ...(compatibilityNotices.length > 4 ? [`- ... +${compatibilityNotices.length - 4} more`] : []),