diff --git a/src/commands/channels/capabilities.test.ts b/src/commands/channels/capabilities.test.ts index f907ac4ca0e..6752924b9a5 100644 --- a/src/commands/channels/capabilities.test.ts +++ b/src/commands/channels/capabilities.test.ts @@ -7,6 +7,10 @@ import { channelsCapabilitiesCommand } from "./capabilities.js"; const logs: string[] = []; const errors: string[] = []; +const mocks = vi.hoisted(() => ({ + writeConfigFile: vi.fn(), + resolveInstallableChannelPlugin: vi.fn(), +})); vi.mock("./shared.js", () => ({ requireValidConfig: vi.fn(async () => ({ channels: {} })), @@ -20,6 +24,18 @@ vi.mock("../../channels/plugins/index.js", () => ({ getChannelPlugin: vi.fn(), })); +vi.mock("../../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + writeConfigFile: mocks.writeConfigFile, + }; +}); + +vi.mock("../channel-setup/channel-plugin-resolution.js", () => ({ + resolveInstallableChannelPlugin: mocks.resolveInstallableChannelPlugin, +})); + const runtime = { log: (...args: unknown[]) => { logs.push(args.map(String).join(" ")); @@ -77,6 +93,11 @@ describe("channelsCapabilitiesCommand", () => { beforeEach(() => { resetOutput(); vi.clearAllMocks(); + mocks.writeConfigFile.mockResolvedValue(undefined); + mocks.resolveInstallableChannelPlugin.mockResolvedValue({ + cfg: { channels: {} }, + configChanged: false, + }); }); it("prints Slack bot + user scopes when user token is configured", async () => { @@ -106,6 +127,12 @@ describe("channelsCapabilitiesCommand", () => { }; vi.mocked(listChannelPlugins).mockReturnValue([plugin]); vi.mocked(getChannelPlugin).mockReturnValue(plugin); + mocks.resolveInstallableChannelPlugin.mockResolvedValue({ + cfg: { channels: {} }, + channelId: "slack", + plugin, + configChanged: false, + }); await channelsCapabilitiesCommand({ channel: "slack" }, runtime); @@ -139,6 +166,12 @@ describe("channelsCapabilitiesCommand", () => { }; vi.mocked(listChannelPlugins).mockReturnValue([plugin]); vi.mocked(getChannelPlugin).mockReturnValue(plugin); + mocks.resolveInstallableChannelPlugin.mockResolvedValue({ + cfg: { channels: {} }, + channelId: "msteams", + plugin, + configChanged: false, + }); await channelsCapabilitiesCommand({ channel: "msteams" }, runtime); @@ -146,4 +179,41 @@ describe("channelsCapabilitiesCommand", () => { expect(output).toContain("ChannelMessage.Read.All (channel history)"); expect(output).toContain("Files.Read.All (files (OneDrive))"); }); + + it("installs an explicit optional channel before rendering capabilities", async () => { + const plugin = buildPlugin({ + id: "whatsapp", + probe: { ok: true }, + }); + plugin.status = { + ...plugin.status, + formatCapabilitiesProbe: () => [{ text: "Probe: linked" }], + }; + mocks.resolveInstallableChannelPlugin.mockResolvedValue({ + cfg: { + channels: {}, + plugins: { entries: { whatsapp: { enabled: true } } }, + }, + channelId: "whatsapp", + plugin, + configChanged: true, + }); + vi.mocked(listChannelPlugins).mockReturnValue([]); + vi.mocked(getChannelPlugin).mockReturnValue(undefined); + + await channelsCapabilitiesCommand({ channel: "whatsapp" }, runtime); + + expect(mocks.resolveInstallableChannelPlugin).toHaveBeenCalledWith( + expect.objectContaining({ + rawChannel: "whatsapp", + allowInstall: true, + }), + ); + expect(mocks.writeConfigFile).toHaveBeenCalledWith( + expect.objectContaining({ + plugins: { entries: { whatsapp: { enabled: true } } }, + }), + ); + expect(logs.join("\n")).toContain("Probe: linked"); + }); }); diff --git a/src/commands/channels/capabilities.ts b/src/commands/channels/capabilities.ts index eccd96824da..d2165eb284d 100644 --- a/src/commands/channels/capabilities.ts +++ b/src/commands/channels/capabilities.ts @@ -1,5 +1,5 @@ import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js"; -import { getChannelPlugin, listChannelPlugins } from "../../channels/plugins/index.js"; +import { listChannelPlugins } from "../../channels/plugins/index.js"; import { createMessageActionDiscoveryContext, resolveMessageActionDiscoveryForPlugin, @@ -10,10 +10,11 @@ import type { ChannelCapabilitiesDisplayLine, ChannelPlugin, } from "../../channels/plugins/types.js"; -import type { OpenClawConfig } from "../../config/config.js"; +import { writeConfigFile, type OpenClawConfig } from "../../config/config.js"; import { danger } from "../../globals.js"; import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; import { theme } from "../../terminal/theme.js"; +import { resolveInstallableChannelPlugin } from "../channel-setup/channel-plugin-resolution.js"; import { formatChannelAccountLabel, requireValidConfig } from "./shared.js"; export type ChannelsCapabilitiesOptions = { @@ -25,6 +26,7 @@ export type ChannelsCapabilitiesOptions = { }; type ChannelCapabilitiesReport = { + plugin: ChannelPlugin; channel: string; accountId: string; accountName?: string; @@ -183,6 +185,7 @@ async function resolveChannelReports(params: { ); reports.push({ + plugin, channel: plugin.id, accountId, accountName: @@ -204,10 +207,11 @@ export async function channelsCapabilitiesCommand( opts: ChannelsCapabilitiesOptions, runtime: RuntimeEnv = defaultRuntime, ) { - const cfg = await requireValidConfig(runtime); - if (!cfg) { + const loadedCfg = await requireValidConfig(runtime); + if (!loadedCfg) { return; } + let cfg = loadedCfg; const timeoutMs = normalizeTimeout(opts.timeout, 10_000); const rawChannel = typeof opts.channel === "string" ? opts.channel.trim().toLowerCase() : ""; const rawTarget = typeof opts.target === "string" ? opts.target.trim() : ""; @@ -227,12 +231,18 @@ export async function channelsCapabilitiesCommand( const selected = !rawChannel || rawChannel === "all" ? plugins - : (() => { - const plugin = getChannelPlugin(rawChannel); - if (!plugin) { - return null; + : await (async () => { + const resolved = await resolveInstallableChannelPlugin({ + cfg, + runtime, + rawChannel, + allowInstall: true, + }); + if (resolved.configChanged) { + cfg = resolved.cfg; + await writeConfigFile(cfg); } - return [plugin]; + return resolved.plugin ? [resolved.plugin] : null; })(); if (!selected || selected.length === 0) { @@ -280,7 +290,7 @@ export async function channelsCapabilitiesCommand( lines.push(`Status: ${configuredLabel}, ${enabledLabel}`); } const probeLines = - getChannelPlugin(report.channel)?.status?.formatCapabilitiesProbe?.({ + report.plugin.status?.formatCapabilitiesProbe?.({ probe: report.probe, }) ?? formatGenericProbeLines(report.probe); if (probeLines.length > 0) {