Plugins: centralize compatibility formatting

This commit is contained in:
Vincent Koc 2026-03-17 20:31:59 -07:00
parent 7ba8dd112f
commit 5c4903d3fd
8 changed files with 244 additions and 14 deletions

View File

@ -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,
}));

168
src/cli/plugins-cli.test.ts Normal file
View File

@ -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");
});
});

View File

@ -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");

View File

@ -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`)}`);

View File

@ -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`));

View File

@ -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,
});
});
});

View File

@ -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,
};
}

View File

@ -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`]
: []),