Plugins: add inspect matrix and trim export

This commit is contained in:
Vincent Koc 2026-03-17 10:33:35 -07:00
parent 3983928958
commit 0d80897476
7 changed files with 269 additions and 9 deletions

View File

@ -430,10 +430,6 @@
"types": "./dist/plugin-sdk/image-generation.d.ts",
"default": "./dist/plugin-sdk/image-generation.js"
},
"./plugin-sdk/image-generation-runtime": {
"types": "./dist/plugin-sdk/image-generation-runtime.d.ts",
"default": "./dist/plugin-sdk/image-generation-runtime.js"
},
"./plugin-sdk/reply-history": {
"types": "./dist/plugin-sdk/reply-history.d.ts",
"default": "./dist/plugin-sdk/reply-history.js"

View File

@ -97,7 +97,6 @@
"provider-usage",
"provider-web-search",
"image-generation",
"image-generation-runtime",
"reply-history",
"media-understanding",
"google",

View File

@ -62,6 +62,20 @@ describe("handleCommands /plugins", () => {
expect(showResult.reply?.text).toContain('"id": "superpowers"');
expect(showResult.reply?.text).toContain('"bundleFormat": "claude"');
expect(showResult.reply?.text).toContain('"shape":');
const inspectAllParams = buildCommandTestParams(
"/plugins inspect all",
buildCfg(),
undefined,
{
workspaceDir,
},
);
inspectAllParams.command.senderIsOwner = true;
const inspectAllResult = await handleCommands(inspectAllParams);
expect(inspectAllResult.reply?.text).toContain("```json");
expect(inspectAllResult.reply?.text).toContain('"plugin"');
expect(inspectAllResult.reply?.text).toContain('"superpowers"');
});
});

View File

@ -7,6 +7,7 @@ import type { OpenClawConfig } from "../../config/config.js";
import type { PluginInstallRecord } from "../../config/types.plugins.js";
import type { PluginRecord } from "../../plugins/registry.js";
import {
buildAllPluginInspectReports,
buildPluginInspectReport,
buildPluginStatusReport,
type PluginStatusReport,
@ -48,6 +49,22 @@ function buildPluginInspectJson(params: {
};
}
function buildAllPluginInspectJson(params: {
config: OpenClawConfig;
report: PluginStatusReport;
}): Array<{
inspect: ReturnType<typeof buildAllPluginInspectReports>[number];
install: PluginInstallRecord | null;
}> {
return buildAllPluginInspectReports({
config: params.config,
report: params.report,
}).map((inspect) => ({
inspect,
install: params.config.plugins?.installs?.[inspect.plugin.id] ?? null,
}));
}
function formatPluginLabel(plugin: PluginRecord): string {
if (!plugin.name || plugin.name === plugin.id) {
return plugin.id;
@ -164,6 +181,14 @@ export const handlePluginsCommand: CommandHandler = async (params, allowTextComm
reply: { text: formatPluginsList(loaded.report) },
};
}
if (pluginsCommand.name.toLowerCase() === "all") {
return {
shouldContinue: false,
reply: {
text: renderJsonBlock("🔌 Plugins", buildAllPluginInspectJson(loaded)),
},
};
}
const payload = buildPluginInspectJson({
id: pluginsCommand.name,
config: loaded.config,

View File

@ -20,7 +20,11 @@ import {
import type { PluginRecord } from "../plugins/registry.js";
import { applyExclusiveSlotSelection } from "../plugins/slots.js";
import { resolvePluginSourceRoots, formatPluginSourceForTable } from "../plugins/source-display.js";
import { buildPluginInspectReport, buildPluginStatusReport } from "../plugins/status.js";
import {
buildAllPluginInspectReports,
buildPluginInspectReport,
buildPluginStatusReport,
} from "../plugins/status.js";
import { resolveUninstallDirectoryTarget, uninstallPlugin } from "../plugins/uninstall.js";
import { updateNpmInstalledPlugins } from "../plugins/update.js";
import { defaultRuntime } from "../runtime.js";
@ -45,6 +49,7 @@ export type PluginsListOptions = {
export type PluginInspectOptions = {
json?: boolean;
all?: boolean;
};
export type PluginUpdateOptions = {
@ -141,6 +146,37 @@ function formatInspectSection(title: string, lines: string[]): string[] {
return ["", `${theme.muted(`${title}:`)}`, ...lines];
}
function formatCapabilityKinds(
capabilities: Array<{
kind: string;
}>,
): string {
if (capabilities.length === 0) {
return "-";
}
return capabilities.map((entry) => entry.kind).join(", ");
}
function formatHookSummary(params: {
usesLegacyBeforeAgentStart: boolean;
typedHookCount: number;
customHookCount: number;
}): string {
const parts: string[] = [];
if (params.usesLegacyBeforeAgentStart) {
parts.push("before_agent_start");
}
const nonLegacyTypedHookCount =
params.typedHookCount - (params.usesLegacyBeforeAgentStart ? 1 : 0);
if (nonLegacyTypedHookCount > 0) {
parts.push(`${nonLegacyTypedHookCount} typed`);
}
if (params.customHookCount > 0) {
parts.push(`${params.customHookCount} custom`);
}
return parts.length > 0 ? parts.join(", ") : "-";
}
function formatInstallLines(install: PluginInstallRecord | undefined): string[] {
if (!install) {
return [];
@ -576,11 +612,74 @@ export function registerPluginsCli(program: Command) {
.command("inspect")
.alias("info")
.description("Inspect plugin details")
.argument("<id>", "Plugin id")
.argument("[id]", "Plugin id")
.option("--all", "Inspect all plugins")
.option("--json", "Print JSON")
.action((id: string, opts: PluginInspectOptions) => {
.action((id: string | undefined, opts: PluginInspectOptions) => {
const cfg = loadConfig();
const report = buildPluginStatusReport({ config: cfg });
if (opts.all) {
if (id) {
defaultRuntime.error("Pass either a plugin id or --all, not both.");
process.exit(1);
}
const inspectAll = buildAllPluginInspectReports({
config: cfg,
report,
});
const inspectAllWithInstall = inspectAll.map((inspect) => ({
...inspect,
install: cfg.plugins?.installs?.[inspect.plugin.id],
}));
if (opts.json) {
defaultRuntime.log(JSON.stringify(inspectAllWithInstall, null, 2));
return;
}
const tableWidth = getTerminalTableWidth();
const rows = inspectAll.map((inspect) => ({
Name: inspect.plugin.name || inspect.plugin.id,
ID:
inspect.plugin.name && inspect.plugin.name !== inspect.plugin.id
? inspect.plugin.id
: "",
Status:
inspect.plugin.status === "loaded"
? theme.success("loaded")
: inspect.plugin.status === "disabled"
? theme.warn("disabled")
: theme.error("error"),
Shape: inspect.shape,
Capabilities: formatCapabilityKinds(inspect.capabilities),
Hooks: formatHookSummary({
usesLegacyBeforeAgentStart: inspect.usesLegacyBeforeAgentStart,
typedHookCount: inspect.typedHooks.length,
customHookCount: inspect.customHooks.length,
}),
}));
defaultRuntime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "Name", header: "Name", minWidth: 14, flex: true },
{ key: "ID", header: "ID", minWidth: 10, flex: true },
{ key: "Status", header: "Status", minWidth: 10 },
{ key: "Shape", header: "Shape", minWidth: 18 },
{ key: "Capabilities", header: "Capabilities", minWidth: 28, flex: true },
{ key: "Hooks", header: "Hooks", minWidth: 20, flex: true },
],
rows,
}).trimEnd(),
);
return;
}
if (!id) {
defaultRuntime.error("Provide a plugin id or use --all.");
process.exit(1);
}
const inspect = buildPluginInspectReport({
id,
config: cfg,

View File

@ -4,6 +4,7 @@ const loadConfigMock = vi.fn();
const loadOpenClawPluginsMock = vi.fn();
let buildPluginStatusReport: typeof import("./status.js").buildPluginStatusReport;
let buildPluginInspectReport: typeof import("./status.js").buildPluginInspectReport;
let buildAllPluginInspectReports: typeof import("./status.js").buildAllPluginInspectReports;
vi.mock("../config/config.js", () => ({
loadConfig: () => loadConfigMock(),
@ -47,7 +48,8 @@ describe("buildPluginStatusReport", () => {
services: [],
commands: [],
});
({ buildPluginInspectReport, buildPluginStatusReport } = await import("./status.js"));
({ buildAllPluginInspectReports, buildPluginInspectReport, buildPluginStatusReport } =
await import("./status.js"));
});
it("forwards an explicit env to plugin loading", () => {
@ -156,4 +158,103 @@ describe("buildPluginStatusReport", () => {
{ level: "warn", pluginId: "google", message: "watch this seam" },
]);
});
it("builds inspect reports for every loaded plugin", () => {
loadOpenClawPluginsMock.mockReturnValue({
plugins: [
{
id: "lca",
name: "LCA",
description: "Legacy hook plugin",
source: "/tmp/lca/index.ts",
origin: "workspace",
enabled: true,
status: "loaded",
toolNames: [],
hookNames: [],
channelIds: [],
providerIds: [],
speechProviderIds: [],
mediaUnderstandingProviderIds: [],
imageGenerationProviderIds: [],
webSearchProviderIds: [],
gatewayMethods: [],
cliCommands: [],
services: [],
commands: [],
httpRoutes: 0,
hookCount: 1,
configSchema: false,
},
{
id: "microsoft",
name: "Microsoft",
description: "Hybrid capability plugin",
source: "/tmp/microsoft/index.ts",
origin: "bundled",
enabled: true,
status: "loaded",
toolNames: [],
hookNames: [],
channelIds: [],
providerIds: ["microsoft"],
speechProviderIds: [],
mediaUnderstandingProviderIds: [],
imageGenerationProviderIds: [],
webSearchProviderIds: ["microsoft"],
gatewayMethods: [],
cliCommands: [],
services: [],
commands: [],
httpRoutes: 0,
hookCount: 0,
configSchema: false,
},
],
diagnostics: [],
channels: [],
channelSetups: [],
providers: [],
speechProviders: [],
mediaUnderstandingProviders: [],
imageGenerationProviders: [],
webSearchProviders: [],
tools: [],
hooks: [
{
pluginId: "lca",
events: ["message"],
entry: {
hook: {
name: "legacy",
handler: () => undefined,
},
},
},
],
typedHooks: [
{
pluginId: "lca",
hookName: "before_agent_start",
handler: () => undefined,
source: "/tmp/lca/index.ts",
},
],
httpRoutes: [],
gatewayHandlers: {},
cliRegistrars: [],
services: [],
commands: [],
});
const inspect = buildAllPluginInspectReports();
expect(inspect.map((entry) => entry.plugin.id)).toEqual(["lca", "microsoft"]);
expect(inspect.map((entry) => entry.shape)).toEqual(["hook-only", "hybrid-capability"]);
expect(inspect[0]?.usesLegacyBeforeAgentStart).toBe(true);
expect(inspect[1]?.capabilities.map((entry) => entry.kind)).toEqual([
"text-inference",
"web-search",
]);
});
});

View File

@ -212,3 +212,29 @@ export function buildPluginInspectReport(params: {
usesLegacyBeforeAgentStart: typedHooks.some((entry) => entry.name === "before_agent_start"),
};
}
export function buildAllPluginInspectReports(params?: {
config?: ReturnType<typeof loadConfig>;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
report?: PluginStatusReport;
}): PluginInspectReport[] {
const config = params?.config ?? loadConfig();
const report =
params?.report ??
buildPluginStatusReport({
config,
workspaceDir: params?.workspaceDir,
env: params?.env,
});
return report.plugins
.map((plugin) =>
buildPluginInspectReport({
id: plugin.id,
config,
report,
}),
)
.filter((entry): entry is PluginInspectReport => entry !== null);
}