From 288876dda789ba129aa79022b09ab21d8fb2d0dd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 6 May 2026 20:00:17 +0100 Subject: [PATCH] fix(channels): list channel catalog status --- src/cli/channels-cli.ts | 2 +- ...profiles.test.ts => channels.list.test.ts} | 195 +++++++++-------- src/commands/channels/list.ts | 204 ++++++++++++------ 3 files changed, 249 insertions(+), 152 deletions(-) rename src/commands/{channels.list.auth-profiles.test.ts => channels.list.test.ts} (50%) diff --git a/src/cli/channels-cli.ts b/src/cli/channels-cli.ts index 4a448be9e34..6bae3035a22 100644 --- a/src/cli/channels-cli.ts +++ b/src/cli/channels-cli.ts @@ -100,7 +100,7 @@ export async function registerChannelsCli( "after", () => `\n${theme.heading("Examples:")}\n${formatHelpExamples([ - ["openclaw channels list", "List configured channels and auth profiles."], + ["openclaw channels list", "List available and configured channels."], ["openclaw channels status --probe", "Run channel status checks and probes."], [ "openclaw channels add --channel telegram --token ", diff --git a/src/commands/channels.list.auth-profiles.test.ts b/src/commands/channels.list.test.ts similarity index 50% rename from src/commands/channels.list.auth-profiles.test.ts rename to src/commands/channels.list.test.ts index bc22827f1de..7b067798e08 100644 --- a/src/commands/channels.list.auth-profiles.test.ts +++ b/src/commands/channels.list.test.ts @@ -10,10 +10,13 @@ const mocks = vi.hoisted(() => ({ effectiveConfig: config, diagnostics: [], })), - loadAuthProfileStoreWithoutExternalProfiles: vi.fn(), + listChatChannels: vi.fn(() => [ + { id: "discord", label: "Discord", order: 10 }, + { id: "telegram", label: "Telegram", order: 20 }, + ]), listReadOnlyChannelPluginsForConfig: vi.fn<() => ChannelPlugin[]>(() => []), + listTrustedChannelPluginCatalogEntries: vi.fn<() => any[]>(() => []), buildChannelAccountSnapshot: vi.fn(), - loadProviderUsageSummary: vi.fn(), })); vi.mock("../config/config.js", () => ({ @@ -28,8 +31,8 @@ vi.mock("../cli/command-secret-targets.js", () => ({ getChannelsCommandSecretTargetIds: () => new Set(), })); -vi.mock("../agents/auth-profiles.js", () => ({ - loadAuthProfileStoreWithoutExternalProfiles: mocks.loadAuthProfileStoreWithoutExternalProfiles, +vi.mock("../channels/chat-meta.js", () => ({ + listChatChannels: mocks.listChatChannels, })); vi.mock("../channels/plugins/read-only.js", () => ({ @@ -40,103 +43,108 @@ vi.mock("../channels/plugins/status.js", () => ({ buildChannelAccountSnapshot: mocks.buildChannelAccountSnapshot, })); -vi.mock("../infra/provider-usage.js", () => ({ - formatUsageReportLines: () => [], - loadProviderUsageSummary: mocks.loadProviderUsageSummary, +vi.mock("./channel-setup/trusted-catalog.js", () => ({ + listTrustedChannelPluginCatalogEntries: mocks.listTrustedChannelPluginCatalogEntries, })); import { channelsListCommand } from "./channels/list.js"; -function createMockChannelPlugin(accountIds: string[]): ChannelPlugin { +function createMockChannelPlugin(params: { + id?: string; + label?: string; + accountIds?: string[]; + order?: number; +}): ChannelPlugin { + const id = params.id ?? "telegram"; return { - id: "telegram", + id, meta: { - id: "telegram", - label: "Telegram", - selectionLabel: "Telegram", - docsPath: "/channels/telegram", - blurb: "Telegram", + id, + label: params.label ?? id, + selectionLabel: params.label ?? id, + docsPath: `/channels/${id}`, + blurb: params.label ?? id, + order: params.order, }, capabilities: { chatTypes: ["direct"] }, config: { - listAccountIds: () => accountIds, + listAccountIds: () => params.accountIds ?? [], resolveAccount: () => ({}), }, }; } -describe("channels list auth profiles", () => { +describe("channels list", () => { beforeEach(() => { mocks.readConfigFileSnapshot.mockReset(); mocks.resolveCommandConfigWithSecrets.mockClear(); - mocks.loadAuthProfileStoreWithoutExternalProfiles.mockReset(); - mocks.loadProviderUsageSummary.mockReset(); + mocks.listChatChannels.mockReset(); + mocks.listChatChannels.mockReturnValue([ + { id: "discord", label: "Discord", order: 10 }, + { id: "telegram", label: "Telegram", order: 20 }, + ]); + mocks.listTrustedChannelPluginCatalogEntries.mockReset(); + mocks.listTrustedChannelPluginCatalogEntries.mockReturnValue([]); mocks.listReadOnlyChannelPluginsForConfig.mockReset(); mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([]); mocks.buildChannelAccountSnapshot.mockReset(); }); - it("includes local auth profiles in JSON output without loading external profiles", async () => { + it("lists only channels in JSON output", async () => { const runtime = createTestRuntime(); mocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot, config: {}, }); - mocks.loadAuthProfileStoreWithoutExternalProfiles.mockReturnValue({ - version: 1, - profiles: { - "anthropic:default": { - type: "oauth", - provider: "anthropic", - access: "token", - refresh: "refresh", - expires: 0, - created: 0, - }, - "openai-codex:default": { - type: "oauth", - provider: "openai", - access: "token", - refresh: "refresh", - expires: 0, - created: 0, - }, - }, - }); await channelsListCommand({ json: true, usage: false }, runtime); expect(mocks.resolveCommandConfigWithSecrets).toHaveBeenCalledTimes(1); const payload = JSON.parse(runtime.log.mock.calls[0]?.[0] as string) as { - auth?: Array<{ id: string }>; + channels?: Array<{ id: string; configured: boolean; enabled: boolean; installed: boolean }>; + auth?: unknown; + usage?: unknown; }; - const ids = payload.auth?.map((entry) => entry.id) ?? []; - expect(ids).toContain("anthropic:default"); - expect(ids).toContain("openai-codex:default"); + expect(payload.auth).toBeUndefined(); + expect(payload.usage).toBeUndefined(); + expect(payload.channels?.map((entry) => entry.id)).toEqual(["discord", "telegram"]); + expect(payload.channels?.[0]).toMatchObject({ + id: "discord", + configured: false, + enabled: true, + installed: false, + }); }); - it("includes configured chat channel accounts in JSON output", async () => { + it("includes bundled/catalog/configured channels with status flags", async () => { const runtime = createTestRuntime(); mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([ - createMockChannelPlugin(["alerts", "default"]), + createMockChannelPlugin({ + id: "telegram", + label: "Telegram", + accountIds: ["alerts"], + order: 20, + }), + ]); + mocks.listTrustedChannelPluginCatalogEntries.mockReturnValue([ + { + id: "slack", + meta: { id: "slack", label: "Slack", selectionLabel: "Slack", order: 30 }, + install: { npmSpec: "@openclaw/slack" }, + }, ]); mocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot, config: { channels: { telegram: { - accounts: { - default: { botToken: "123:abc" }, - alerts: { botToken: "456:def" }, - }, + accounts: { alerts: { botToken: "456:def" } }, }, + slack: { enabled: false }, + custom: { webhookUrl: "https://example.invalid/hook" }, }, }, }); - mocks.loadAuthProfileStoreWithoutExternalProfiles.mockReturnValue({ - version: 1, - profiles: {}, - }); await channelsListCommand({ json: true, usage: false }, runtime); @@ -145,36 +153,50 @@ describe("channels list auth profiles", () => { expect.objectContaining({ includeSetupFallbackPlugins: true }), ); const payload = JSON.parse(runtime.log.mock.calls[0]?.[0] as string) as { - chat?: Record; + channels: Array<{ + id: string; + configured: boolean; + enabled: boolean; + installed: boolean; + accounts: string[]; + }>; }; - expect(payload.chat?.telegram).toEqual(["alerts", "default"]); + expect(payload.channels).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "telegram", + configured: true, + enabled: true, + installed: true, + accounts: ["alerts"], + }), + expect.objectContaining({ + id: "slack", + configured: false, + enabled: false, + installed: false, + accounts: [], + }), + expect.objectContaining({ + id: "custom", + configured: true, + enabled: true, + installed: false, + accounts: [], + }), + ]), + ); }); - it("keeps JSON output valid when usage loading fails", async () => { - const runtime = createTestRuntime(); - mocks.readConfigFileSnapshot.mockResolvedValue({ - ...baseConfigSnapshot, - config: {}, - }); - mocks.loadAuthProfileStoreWithoutExternalProfiles.mockReturnValue({ - version: 1, - profiles: {}, - }); - mocks.loadProviderUsageSummary.mockRejectedValue(new Error("fetch failed")); - - await channelsListCommand({ json: true }, runtime); - - const payload = JSON.parse(runtime.log.mock.calls[0]?.[0] as string) as { - usage?: unknown; - }; - expect(payload.usage).toBeUndefined(); - expect(runtime.error).not.toHaveBeenCalled(); - }); - - it("prints configured chat channel accounts before auth providers", async () => { + it("prints channel rows and account rows without auth providers", async () => { const runtime = createTestRuntime(); mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([ - createMockChannelPlugin(["default"]), + createMockChannelPlugin({ + id: "telegram", + label: "Telegram", + accountIds: ["default"], + order: 20, + }), ]); mocks.buildChannelAccountSnapshot.mockResolvedValue({ accountId: "default", @@ -194,21 +216,14 @@ describe("channels list auth profiles", () => { }, }, }); - mocks.loadAuthProfileStoreWithoutExternalProfiles.mockReturnValue({ - version: 1, - profiles: {}, - }); await channelsListCommand({ usage: false }, runtime); - expect(mocks.listReadOnlyChannelPluginsForConfig).toHaveBeenCalledWith( - expect.any(Object), - expect.objectContaining({ includeSetupFallbackPlugins: true }), - ); const output = stripAnsi(runtime.log.mock.calls[0]?.[0] as string); expect(output).toContain("Chat channels:"); - expect(output).toContain("Telegram default:"); - expect(output).toContain("configured"); - expect(output.indexOf("Telegram default:")).toBeLessThan(output.indexOf("Auth providers")); + expect(output).toContain("Discord: not configured, enabled, not installed"); + expect(output).toContain("Telegram: configured, enabled, installed"); + expect(output).toContain("Telegram default: configured, token=config, enabled"); + expect(output).not.toContain("Auth providers"); }); }); diff --git a/src/commands/channels/list.ts b/src/commands/channels/list.ts index cebda2cb1e4..0e783828c77 100644 --- a/src/commands/channels/list.ts +++ b/src/commands/channels/list.ts @@ -1,14 +1,15 @@ -import { loadAuthProfileStoreWithoutExternalProfiles } from "../../agents/auth-profiles.js"; +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { listChatChannels } from "../../channels/chat-meta.js"; import { isChannelVisibleInConfiguredLists } from "../../channels/plugins/exposure.js"; import { listReadOnlyChannelPluginsForConfig } from "../../channels/plugins/read-only.js"; import { buildChannelAccountSnapshot } from "../../channels/plugins/status.js"; import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js"; import type { ChannelAccountSnapshot } from "../../channels/plugins/types.public.js"; -import { withProgress } from "../../cli/progress.js"; -import { formatUsageReportLines, loadProviderUsageSummary } from "../../infra/provider-usage.js"; +import { isStaticallyChannelConfigured } from "../../config/channel-configured-shared.js"; import { defaultRuntime, type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; import { formatDocsLink } from "../../terminal/links.js"; import { theme } from "../../terminal/theme.js"; +import { listTrustedChannelPluginCatalogEntries } from "../channel-setup/trusted-catalog.js"; import { formatChannelAccountLabel, requireValidConfig } from "./shared.js"; export type ChannelsListOptions = { @@ -89,21 +90,51 @@ function formatAccountLine(params: { } return `- ${label}: ${bits.join(", ")}`; } -async function loadUsageWithProgress( - runtime: RuntimeEnv, - progress = true, -): Promise> | null> { - try { - return await withProgress( - { label: "Fetching usage snapshot…", indeterminate: true, enabled: progress }, - async () => await loadProviderUsageSummary({ skipPluginAuthWithoutCredentialSource: true }), - ); - } catch (err) { - if (progress) { - runtime.error(String(err)); +type ChannelListEntry = { + id: string; + label: string; + order: number; + configured: boolean; + enabled: boolean; + installed: boolean; + accounts: string[]; + source: "bundled" | "catalog" | "configured"; +}; + +const NON_CHANNEL_CONFIG_KEYS = new Set(["defaults"]); + +function resolveChannelEnabled(cfg: Record, channelId: string): boolean { + const channels = cfg.channels; + if (channels && typeof channels === "object" && !Array.isArray(channels)) { + const channelConfig = (channels as Record)[channelId]; + if (channelConfig && typeof channelConfig === "object" && !Array.isArray(channelConfig)) { + if ((channelConfig as Record).enabled === false) { + return false; + } } - return null; } + return true; +} + +function formatInstalled(value: boolean): string { + return value ? theme.success("installed") : theme.warn("not installed"); +} + +function formatChannelSummaryLine(entry: ChannelListEntry): string { + const bits = [ + formatConfigured(entry.configured), + formatEnabled(entry.enabled), + formatInstalled(entry.installed), + ]; + return `- ${theme.accent(entry.label)}: ${bits.join(", ")}`; +} + +function configuredChannelIdsFromConfig(cfg: Record): string[] { + const channels = cfg.channels; + if (!channels || typeof channels !== "object" || Array.isArray(channels)) { + return []; + } + return Object.keys(channels).filter((id) => !NON_CHANNEL_CONFIG_KEYS.has(id)); } export async function channelsListCommand( @@ -114,78 +145,129 @@ export async function channelsListCommand( if (!cfg) { return; } - const includeUsage = opts.usage !== false; + void opts.usage; const plugins = listReadOnlyChannelPluginsForConfig(cfg, { includeSetupFallbackPlugins: true, }); + const pluginById = new Map(plugins.map((plugin) => [plugin.id, plugin])); + const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)); + const catalogEntries = listTrustedChannelPluginCatalogEntries({ cfg, workspaceDir }); + const entries = new Map(); - const authStore = loadAuthProfileStoreWithoutExternalProfiles(); - const authProfiles = Object.entries(authStore.profiles).map(([profileId, profile]) => ({ - id: profileId, - provider: profile.provider, - type: profile.type, - isExternal: false, - })); - if (opts.json) { - const usage = includeUsage ? await loadUsageWithProgress(runtime, false) : undefined; - const chat: Record = {}; - for (const plugin of plugins) { - chat[plugin.id] = plugin.config.listAccountIds(cfg); + const upsert = (entry: ChannelListEntry) => { + const existing = entries.get(entry.id); + entries.set(entry.id, { + ...entry, + ...existing, + configured: Boolean(existing?.configured || entry.configured), + enabled: existing?.enabled === false || entry.enabled === false ? false : true, + installed: Boolean(existing?.installed || entry.installed), + accounts: existing?.accounts.length ? existing.accounts : entry.accounts, + source: existing?.source ?? entry.source, + }); + }; + + for (const meta of listChatChannels()) { + const plugin = pluginById.get(meta.id); + upsert({ + id: meta.id, + label: meta.label ?? meta.id, + order: meta.order ?? Number.MAX_SAFE_INTEGER, + configured: isStaticallyChannelConfigured(cfg, meta.id), + enabled: resolveChannelEnabled(cfg, meta.id), + installed: Boolean(plugin), + accounts: plugin?.config.listAccountIds(cfg) ?? [], + source: "bundled", + }); + } + + for (const plugin of plugins) { + const accounts = plugin.config.listAccountIds(cfg); + upsert({ + id: plugin.id, + label: plugin.meta.label ?? plugin.id, + order: plugin.meta.order ?? Number.MAX_SAFE_INTEGER, + configured: accounts.length > 0 || isStaticallyChannelConfigured(cfg, plugin.id), + enabled: resolveChannelEnabled(cfg, plugin.id), + installed: true, + accounts, + source: "bundled", + }); + } + + for (const entry of catalogEntries) { + const plugin = pluginById.get(entry.id); + const accounts = plugin?.config.listAccountIds(cfg) ?? []; + upsert({ + id: entry.id, + label: entry.meta.label ?? entry.id, + order: entry.meta.order ?? Number.MAX_SAFE_INTEGER, + configured: accounts.length > 0 || isStaticallyChannelConfigured(cfg, entry.id), + enabled: resolveChannelEnabled(cfg, entry.id), + installed: Boolean(plugin), + accounts, + source: "catalog", + }); + } + + for (const channelId of configuredChannelIdsFromConfig(cfg)) { + const plugin = pluginById.get(channelId); + const accounts = plugin?.config.listAccountIds(cfg) ?? []; + upsert({ + id: channelId, + label: plugin?.meta.label ?? channelId, + order: plugin?.meta.order ?? Number.MAX_SAFE_INTEGER, + configured: accounts.length > 0 || isStaticallyChannelConfigured(cfg, channelId), + enabled: resolveChannelEnabled(cfg, channelId), + installed: Boolean(plugin), + accounts, + source: "configured", + }); + } + + const channels = [...entries.values()].toSorted((left, right) => { + if (left.order !== right.order) { + return left.order - right.order; } - const payload = { chat, auth: authProfiles, ...(usage ? { usage } : {}) }; - writeRuntimeJson(runtime, payload); + return left.label.localeCompare(right.label); + }); + + if (opts.json) { + writeRuntimeJson(runtime, { channels }); return; } const lines: string[] = []; lines.push(theme.heading("Chat channels:")); - for (const plugin of plugins) { - const accounts = plugin.config.listAccountIds(cfg); - if (!accounts || accounts.length === 0) { + if (channels.length === 0) { + lines.push(theme.muted("- none")); + } + + for (const entry of channels) { + const plugin = pluginById.get(entry.id); + lines.push(formatChannelSummaryLine(entry)); + if (!plugin || entry.accounts.length === 0) { continue; } - for (const accountId of accounts) { + for (const accountId of entry.accounts) { const snapshot = await buildChannelAccountSnapshot({ plugin, cfg, accountId, }); lines.push( - formatAccountLine({ + ` ${formatAccountLine({ channel: plugin, snapshot, - }), + }).slice(2)}`, ); } } - lines.push(""); - lines.push(theme.heading("Auth providers (OAuth + API keys):")); - if (authProfiles.length === 0) { - lines.push(theme.muted("- none")); - } else { - for (const profile of authProfiles) { - const external = profile.isExternal ? theme.muted(" (synced)") : ""; - lines.push(`- ${theme.accent(profile.id)} (${theme.success(profile.type)}${external})`); - } - } - runtime.log(lines.join("\n")); - if (includeUsage) { - runtime.log(""); - const usage = await loadUsageWithProgress(runtime); - if (usage) { - const usageLines = formatUsageReportLines(usage); - if (usageLines.length > 0) { - usageLines[0] = theme.accent(usageLines[0]); - runtime.log(usageLines.join("\n")); - } - } - } - runtime.log(""); - runtime.log(`Docs: ${formatDocsLink("/gateway/configuration", "gateway/configuration")}`); + runtime.log(`Docs: ${formatDocsLink("/channels", "channels")}`); }