diff --git a/CHANGELOG.md b/CHANGELOG.md index f2cfe8a8393..982b05d81af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai - Telegram: treat successful same-chat `message` tool outbound sends during an inbound telegram turn as delivered when deciding whether to emit the rewritten silent reply fallback (#78685). Thanks @neeravmakwana. - Gateway/tasks: reconcile stale CLI run-context tasks whose live run context disappeared even when a child session row remains, and apply the default bounded reload deferral timeout to channel hot reloads so stale task records cannot block Discord/Slack/Telegram reloads forever. - Discord/voice: make `openclaw channels capabilities --channel discord --target channel:` and `channels status --probe` audit voice-channel permissions, including auto-join targets, so missing Connect/Speak/Read Message History permissions show up before `/vc join`. +- Channels CLI: make `openclaw channels list` channel-only — drop the `Auth providers (OAuth + API keys)` block (use `openclaw models auth list`), drop the per-provider usage/quota fetch and the `--no-usage` flag (use `openclaw status` or `openclaw models list`), add `--all` to surface bundled-unconfigured, catalog-not-installed, and catalog-installed-but-unconfigured channels, and render explicit `installed` / `configured` / `enabled` tags per row plus an `origin` + `installed` field in JSON. Fixes WeCom-class catalog channels disappearing from `--all` when installed on disk but not yet configured. (#78456) - CLI/cron: add computed `status` field to `cron list --json` and `cron show --json` output, mirroring the human-readable status column (disabled/running/ok/error/skipped/idle) so external tooling can determine job state without re-deriving it from raw state fields. (#78701) Thanks @aweiker. - Docs/iMessage: deprecate BlueBubbles for new OpenClaw setups, document the upstream server-release rationale, and point new iMessage deployments toward the native `imsg` path while keeping BlueBubbles as a supported legacy fallback. - Discord/voice: make voice capture less choppy by extending the default post-speech silence grace to 2.5s, add `voice.captureSilenceGraceMs` for noisy Discord sessions, and tighten the spoken-output prompt around live STT fragments. Thanks @vincentkoc. diff --git a/docs/cli/channels.md b/docs/cli/channels.md index eab3ef8314f..996f414fb77 100644 --- a/docs/cli/channels.md +++ b/docs/cli/channels.md @@ -19,6 +19,7 @@ Related docs: ```bash openclaw channels list +openclaw channels list --all openclaw channels status openclaw channels capabilities openclaw channels capabilities --channel discord --target channel:123 @@ -27,6 +28,8 @@ openclaw channels resolve --channel slack "#general" "@jane" openclaw channels logs --channel all ``` +`channels list` shows chat channels only: configured accounts by default, with `installed`, `configured`, and `enabled` status tags per account. Pass `--all` to also surface bundled channels that have no configured account yet and installable catalog channels that are not yet on disk. Auth providers (OAuth + API keys) and model-provider usage/quota snapshots are no longer printed here; use `openclaw models auth list` for provider auth profiles and `openclaw status` or `openclaw models list` for usage. + ## Status / capabilities / resolve / logs - `channels status`: `--probe`, `--timeout `, `--json` @@ -109,7 +112,7 @@ openclaw channels logout --channel whatsapp - Run `openclaw status --deep` for a broad probe. - Use `openclaw doctor` for guided fixes. -- `openclaw channels list` prints `Claude: HTTP 403 ... user:profile` → usage snapshot needs the `user:profile` scope. Use `--no-usage`, or provide a claude.ai session key (`CLAUDE_WEB_SESSION_KEY` / `CLAUDE_WEB_COOKIE`), or re-auth via Claude CLI. +- `openclaw channels list` no longer prints model provider usage/quota snapshots. For those, use `openclaw status` (overview) or `openclaw models list` (per-provider). - `openclaw channels status` falls back to config-only summaries when the gateway is unreachable. If a supported channel credential is configured via SecretRef but unavailable in the current command path, it reports that account as configured with degraded notes instead of showing it as not configured. ## Capabilities probe diff --git a/src/cli/channels-cli.ts b/src/cli/channels-cli.ts index 4a448be9e34..158a95e9d19 100644 --- a/src/cli/channels-cli.ts +++ b/src/cli/channels-cli.ts @@ -115,8 +115,8 @@ export async function registerChannelsCli( channels .command("list") - .description("List configured channels + auth profiles") - .option("--no-usage", "Skip model provider usage/quota snapshots") + .description("List chat channels (configured by default; pass --all for installable catalog)") + .option("--all", "Include bundled and installable catalog channels", false) .option("--json", "Output JSON", false) .action(async (opts) => { await runChannelsCommand(async () => { diff --git a/src/cli/program/route-args.ts b/src/cli/program/route-args.ts index d8f259c5419..0f0d6d3aca8 100644 --- a/src/cli/program/route-args.ts +++ b/src/cli/program/route-args.ts @@ -251,7 +251,7 @@ export function parseModelsStatusRouteArgs(argv: string[]) { export function parseChannelsListRouteArgs(argv: string[]) { return { json: hasFlag(argv, "--json"), - usage: !hasFlag(argv, "--no-usage"), + all: hasFlag(argv, "--all"), }; } diff --git a/src/cli/program/routes.test.ts b/src/cli/program/routes.test.ts index cd8f8acdf9b..4054ac072ed 100644 --- a/src/cli/program/routes.test.ts +++ b/src/cli/program/routes.test.ts @@ -115,11 +115,11 @@ describe("program routes", () => { it("passes parsed channel read-only route flags through", async () => { const listRoute = expectRoute(["channels", "list"]); - await expect( - listRoute?.run(["node", "openclaw", "channels", "list", "--json", "--no-usage"]), - ).resolves.toBe(true); + await expect(listRoute?.run(["node", "openclaw", "channels", "list", "--json"])).resolves.toBe( + true, + ); expect(channelsListCommandMock).toHaveBeenCalledWith( - { json: true, usage: false }, + { json: true, all: false }, expect.any(Object), ); diff --git a/src/commands/channels.list.auth-profiles.test.ts b/src/commands/channels.list.auth-profiles.test.ts deleted file mode 100644 index bc22827f1de..00000000000 --- a/src/commands/channels.list.auth-profiles.test.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; -import { stripAnsi } from "../terminal/ansi.js"; -import { baseConfigSnapshot, createTestRuntime } from "./test-runtime-config-helpers.js"; - -const mocks = vi.hoisted(() => ({ - readConfigFileSnapshot: vi.fn(), - resolveCommandConfigWithSecrets: vi.fn(async ({ config }: { config: unknown }) => ({ - resolvedConfig: config, - effectiveConfig: config, - diagnostics: [], - })), - loadAuthProfileStoreWithoutExternalProfiles: vi.fn(), - listReadOnlyChannelPluginsForConfig: vi.fn<() => ChannelPlugin[]>(() => []), - buildChannelAccountSnapshot: vi.fn(), - loadProviderUsageSummary: vi.fn(), -})); - -vi.mock("../config/config.js", () => ({ - readConfigFileSnapshot: mocks.readConfigFileSnapshot, -})); - -vi.mock("../cli/command-config-resolution.js", () => ({ - resolveCommandConfigWithSecrets: mocks.resolveCommandConfigWithSecrets, -})); - -vi.mock("../cli/command-secret-targets.js", () => ({ - getChannelsCommandSecretTargetIds: () => new Set(), -})); - -vi.mock("../agents/auth-profiles.js", () => ({ - loadAuthProfileStoreWithoutExternalProfiles: mocks.loadAuthProfileStoreWithoutExternalProfiles, -})); - -vi.mock("../channels/plugins/read-only.js", () => ({ - listReadOnlyChannelPluginsForConfig: mocks.listReadOnlyChannelPluginsForConfig, -})); - -vi.mock("../channels/plugins/status.js", () => ({ - buildChannelAccountSnapshot: mocks.buildChannelAccountSnapshot, -})); - -vi.mock("../infra/provider-usage.js", () => ({ - formatUsageReportLines: () => [], - loadProviderUsageSummary: mocks.loadProviderUsageSummary, -})); - -import { channelsListCommand } from "./channels/list.js"; - -function createMockChannelPlugin(accountIds: string[]): ChannelPlugin { - return { - id: "telegram", - meta: { - id: "telegram", - label: "Telegram", - selectionLabel: "Telegram", - docsPath: "/channels/telegram", - blurb: "Telegram", - }, - capabilities: { chatTypes: ["direct"] }, - config: { - listAccountIds: () => accountIds, - resolveAccount: () => ({}), - }, - }; -} - -describe("channels list auth profiles", () => { - beforeEach(() => { - mocks.readConfigFileSnapshot.mockReset(); - mocks.resolveCommandConfigWithSecrets.mockClear(); - mocks.loadAuthProfileStoreWithoutExternalProfiles.mockReset(); - mocks.loadProviderUsageSummary.mockReset(); - mocks.listReadOnlyChannelPluginsForConfig.mockReset(); - mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([]); - mocks.buildChannelAccountSnapshot.mockReset(); - }); - - it("includes local auth profiles in JSON output without loading external profiles", 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 }>; - }; - const ids = payload.auth?.map((entry) => entry.id) ?? []; - expect(ids).toContain("anthropic:default"); - expect(ids).toContain("openai-codex:default"); - }); - - it("includes configured chat channel accounts in JSON output", async () => { - const runtime = createTestRuntime(); - mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([ - createMockChannelPlugin(["alerts", "default"]), - ]); - mocks.readConfigFileSnapshot.mockResolvedValue({ - ...baseConfigSnapshot, - config: { - channels: { - telegram: { - accounts: { - default: { botToken: "123:abc" }, - alerts: { botToken: "456:def" }, - }, - }, - }, - }, - }); - mocks.loadAuthProfileStoreWithoutExternalProfiles.mockReturnValue({ - version: 1, - profiles: {}, - }); - - await channelsListCommand({ json: true, usage: false }, runtime); - - expect(mocks.listReadOnlyChannelPluginsForConfig).toHaveBeenCalledWith( - expect.any(Object), - expect.objectContaining({ includeSetupFallbackPlugins: true }), - ); - const payload = JSON.parse(runtime.log.mock.calls[0]?.[0] as string) as { - chat?: Record; - }; - expect(payload.chat?.telegram).toEqual(["alerts", "default"]); - }); - - 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 () => { - const runtime = createTestRuntime(); - mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([ - createMockChannelPlugin(["default"]), - ]); - mocks.buildChannelAccountSnapshot.mockResolvedValue({ - accountId: "default", - configured: true, - tokenSource: "config", - enabled: true, - }); - mocks.readConfigFileSnapshot.mockResolvedValue({ - ...baseConfigSnapshot, - config: { - channels: { - telegram: { - accounts: { - default: { botToken: "123:abc" }, - }, - }, - }, - }, - }); - 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")); - }); -}); diff --git a/src/commands/channels.list.test.ts b/src/commands/channels.list.test.ts new file mode 100644 index 00000000000..f63dbe032ae --- /dev/null +++ b/src/commands/channels.list.test.ts @@ -0,0 +1,364 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ChannelPluginCatalogEntry } from "../channels/plugins/catalog.js"; +import type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; +import { stripAnsi } from "../terminal/ansi.js"; +import { baseConfigSnapshot, createTestRuntime } from "./test-runtime-config-helpers.js"; + +const mocks = vi.hoisted(() => ({ + readConfigFileSnapshot: vi.fn(), + resolveCommandConfigWithSecrets: vi.fn(async ({ config }: { config: unknown }) => ({ + resolvedConfig: config, + effectiveConfig: config, + diagnostics: [], + })), + listReadOnlyChannelPluginsForConfig: vi.fn<() => ChannelPlugin[]>(() => []), + buildChannelAccountSnapshot: vi.fn(), + listTrustedChannelPluginCatalogEntries: vi.fn<() => ChannelPluginCatalogEntry[]>(() => []), + isCatalogChannelInstalled: vi.fn<(params: { entry: ChannelPluginCatalogEntry }) => boolean>( + () => true, + ), + resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"), + resolveDefaultAgentId: vi.fn(() => "main"), +})); + +vi.mock("../config/config.js", () => ({ + readConfigFileSnapshot: mocks.readConfigFileSnapshot, +})); + +vi.mock("../cli/command-config-resolution.js", () => ({ + resolveCommandConfigWithSecrets: mocks.resolveCommandConfigWithSecrets, +})); + +vi.mock("../cli/command-secret-targets.js", () => ({ + getChannelsCommandSecretTargetIds: () => new Set(), +})); + +vi.mock("../channels/plugins/read-only.js", () => ({ + listReadOnlyChannelPluginsForConfig: mocks.listReadOnlyChannelPluginsForConfig, +})); + +vi.mock("../channels/plugins/status.js", () => ({ + buildChannelAccountSnapshot: mocks.buildChannelAccountSnapshot, +})); + +vi.mock("./channel-setup/trusted-catalog.js", () => ({ + listTrustedChannelPluginCatalogEntries: mocks.listTrustedChannelPluginCatalogEntries, +})); + +vi.mock("./channel-setup/discovery.js", () => ({ + isCatalogChannelInstalled: mocks.isCatalogChannelInstalled, +})); + +vi.mock("../agents/agent-scope.js", () => ({ + resolveAgentWorkspaceDir: mocks.resolveAgentWorkspaceDir, + resolveDefaultAgentId: mocks.resolveDefaultAgentId, +})); + +import { channelsListCommand } from "./channels/list.js"; + +function createMockChannelPlugin(overrides: { + id?: string; + label?: string; + accountIds?: string[]; +}): ChannelPlugin { + const id = overrides.id ?? "telegram"; + return { + id, + meta: { + id, + label: overrides.label ?? "Telegram", + selectionLabel: overrides.label ?? "Telegram", + docsPath: `/channels/${id}`, + blurb: overrides.label ?? "Telegram", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => overrides.accountIds ?? [], + resolveAccount: () => ({}), + }, + }; +} + +function createCatalogEntry(id: string, label: string): ChannelPluginCatalogEntry { + return { + id, + label, + pluginId: `@openclaw/${id}`, + origin: "official", + meta: { + id, + label, + selectionLabel: label, + docsPath: `/channels/${id}`, + blurb: label, + }, + install: { npmSpec: `@openclaw/${id}` }, + } as unknown as ChannelPluginCatalogEntry; +} + +describe("channels list", () => { + beforeEach(() => { + mocks.readConfigFileSnapshot.mockReset(); + mocks.resolveCommandConfigWithSecrets.mockClear(); + mocks.listReadOnlyChannelPluginsForConfig.mockReset(); + mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([]); + mocks.buildChannelAccountSnapshot.mockReset(); + mocks.listTrustedChannelPluginCatalogEntries.mockReset(); + mocks.listTrustedChannelPluginCatalogEntries.mockReturnValue([]); + mocks.isCatalogChannelInstalled.mockReset(); + mocks.isCatalogChannelInstalled.mockReturnValue(true); + }); + + it("does not include auth providers in JSON output (auth section was removed)", async () => { + const runtime = createTestRuntime(); + mocks.readConfigFileSnapshot.mockResolvedValue({ + ...baseConfigSnapshot, + config: {}, + }); + + await channelsListCommand({ json: true }, runtime); + + const payload = JSON.parse(runtime.log.mock.calls[0]?.[0] as string) as Record; + expect(payload.auth).toBeUndefined(); + expect(payload).toHaveProperty("chat"); + }); + + it("includes configured chat channel accounts in JSON output with installed flag", async () => { + const runtime = createTestRuntime(); + mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([ + createMockChannelPlugin({ accountIds: ["alerts", "default"] }), + ]); + mocks.readConfigFileSnapshot.mockResolvedValue({ + ...baseConfigSnapshot, + config: { + channels: { + telegram: { + accounts: { + default: { botToken: "123:abc" }, + alerts: { botToken: "456:def" }, + }, + }, + }, + }, + }); + + await channelsListCommand({ json: true }, runtime); + + expect(mocks.listReadOnlyChannelPluginsForConfig).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ includeSetupFallbackPlugins: true }), + ); + const payload = JSON.parse(runtime.log.mock.calls[0]?.[0] as string) as { + chat?: Record; + }; + expect(payload.chat?.telegram).toEqual({ + accounts: ["alerts", "default"], + installed: true, + origin: "configured", + }); + }); + + it("keeps JSON output valid when only channels are provided (no usage field)", async () => { + const runtime = createTestRuntime(); + mocks.readConfigFileSnapshot.mockResolvedValue({ + ...baseConfigSnapshot, + config: {}, + }); + + 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("text output prints chat channels but no longer renders an Auth providers section", async () => { + const runtime = createTestRuntime(); + mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([ + createMockChannelPlugin({ accountIds: ["default"] }), + ]); + mocks.buildChannelAccountSnapshot.mockResolvedValue({ + accountId: "default", + configured: true, + tokenSource: "config", + enabled: true, + }); + mocks.readConfigFileSnapshot.mockResolvedValue({ + ...baseConfigSnapshot, + config: { + channels: { + telegram: { + accounts: { + default: { botToken: "123:abc" }, + }, + }, + }, + }, + }); + + await channelsListCommand({}, 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("installed"); + expect(output).toContain("configured"); + expect(output).toContain("enabled"); + expect(output).not.toContain("Auth providers"); + }); + + it("default output does NOT show installable catalog channels (only configured ones)", async () => { + const runtime = createTestRuntime(); + mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([]); + mocks.listTrustedChannelPluginCatalogEntries.mockReturnValue([ + createCatalogEntry("qqbot", "QQ Bot"), + ]); + mocks.isCatalogChannelInstalled.mockReturnValue(false); + mocks.readConfigFileSnapshot.mockResolvedValue({ + ...baseConfigSnapshot, + config: {}, + }); + + await channelsListCommand({}, runtime); + + const output = stripAnsi(runtime.log.mock.calls[0]?.[0] as string); + expect(output).toContain("Chat channels:"); + expect(output).not.toContain("QQ Bot"); + // Hint user about --all + expect(output).toContain("--all"); + }); + + it("--all surfaces uninstalled catalog channels with installed=false / not configured / not enabled", async () => { + const runtime = createTestRuntime(); + mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([]); + mocks.listTrustedChannelPluginCatalogEntries.mockReturnValue([ + createCatalogEntry("qqbot", "QQ Bot"), + ]); + mocks.isCatalogChannelInstalled.mockReturnValue(false); + mocks.readConfigFileSnapshot.mockResolvedValue({ + ...baseConfigSnapshot, + config: {}, + }); + + await channelsListCommand({ all: true }, runtime); + + const output = stripAnsi(runtime.log.mock.calls[0]?.[0] as string); + expect(output).toContain("QQ Bot"); + expect(output).toContain("not installed"); + expect(output).toContain("not configured"); + }); + + it("--all surfaces bundled-but-unconfigured plugins with installed=true / not configured", async () => { + const runtime = createTestRuntime(); + mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([ + createMockChannelPlugin({ id: "discord", label: "Discord", accountIds: [] }), + ]); + mocks.buildChannelAccountSnapshot.mockResolvedValue({ + accountId: "default", + configured: false, + enabled: false, + }); + mocks.readConfigFileSnapshot.mockResolvedValue({ + ...baseConfigSnapshot, + config: {}, + }); + + // Without --all: discord should not appear. + await channelsListCommand({}, runtime); + const noAllOutput = stripAnsi(runtime.log.mock.calls[0]?.[0] as string); + expect(noAllOutput).not.toContain("Discord default:"); + + runtime.log.mockClear(); + + // With --all: discord is rendered with installed + not configured + disabled. + await channelsListCommand({ all: true }, runtime); + const allOutput = stripAnsi(runtime.log.mock.calls[0]?.[0] as string); + expect(allOutput).toContain("Discord default:"); + expect(allOutput).toContain("installed"); + expect(allOutput).toContain("not configured"); + expect(allOutput).toContain("disabled"); + }); + + it("--all JSON exposes 'origin' tag (configured / available / installable)", async () => { + const runtime = createTestRuntime(); + mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([ + createMockChannelPlugin({ id: "telegram", accountIds: ["default"] }), + createMockChannelPlugin({ id: "discord", label: "Discord", accountIds: [] }), + ]); + mocks.buildChannelAccountSnapshot.mockResolvedValue({ + accountId: "default", + configured: false, + enabled: false, + }); + mocks.listTrustedChannelPluginCatalogEntries.mockReturnValue([ + createCatalogEntry("qqbot", "QQ Bot"), + ]); + mocks.isCatalogChannelInstalled.mockImplementation(({ entry }) => entry.id !== "qqbot"); + mocks.readConfigFileSnapshot.mockResolvedValue({ + ...baseConfigSnapshot, + config: { + channels: { + telegram: { accounts: { default: { botToken: "x:y" } } }, + }, + }, + }); + + await channelsListCommand({ json: true, all: true }, runtime); + + const payload = JSON.parse(runtime.log.mock.calls[0]?.[0] as string) as { + chat: Record; + }; + expect(payload.chat.telegram).toMatchObject({ origin: "configured", installed: true }); + expect(payload.chat.discord).toMatchObject({ origin: "available", installed: true }); + expect(payload.chat.qqbot).toMatchObject({ origin: "installable", installed: false }); + }); + + it( + "--all still surfaces catalog channels that are installed on disk but have no " + + "plugin object loaded and no config entry (regression: WeCom-like channels " + + "disappearing when the read-only loader only surfaces configured channels)", + async () => { + const runtime = createTestRuntime(); + // Read-only loader returns nothing for wecom because the user has no + // configured wecom channel, so the loader never activates it. + mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([]); + // But catalog knows about wecom, and isCatalogChannelInstalled sees + // the wecom npm package on disk. + mocks.listTrustedChannelPluginCatalogEntries.mockReturnValue([ + createCatalogEntry("wecom", "WeCom"), + ]); + mocks.isCatalogChannelInstalled.mockReturnValue(true); + mocks.readConfigFileSnapshot.mockResolvedValue({ + ...baseConfigSnapshot, + config: {}, + }); + + await channelsListCommand({ all: true }, runtime); + + const output = stripAnsi(runtime.log.mock.calls[0]?.[0] as string); + expect(output).toContain("WeCom"); + expect(output).toContain("installed"); + expect(output).not.toContain("not installed"); + expect(output).toContain("not configured"); + expect(output).toContain("disabled"); + + // JSON side: origin should be "available" (installed, but user has + // not written a config entry for it). + runtime.log.mockClear(); + await channelsListCommand({ json: true, all: true }, runtime); + const payload = JSON.parse(runtime.log.mock.calls[0]?.[0] as string) as { + chat: Record; + }; + expect(payload.chat.wecom).toMatchObject({ + origin: "available", + installed: true, + }); + }, + ); +}); diff --git a/src/commands/channels/list.ts b/src/commands/channels/list.ts index cebda2cb1e4..f3ee045c89c 100644 --- a/src/commands/channels/list.ts +++ b/src/commands/channels/list.ts @@ -1,19 +1,20 @@ -import { loadAuthProfileStoreWithoutExternalProfiles } from "../../agents/auth-profiles.js"; +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import type { ChannelPluginCatalogEntry } from "../../channels/plugins/catalog.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 { defaultRuntime, type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; import { formatDocsLink } from "../../terminal/links.js"; import { theme } from "../../terminal/theme.js"; +import { isCatalogChannelInstalled } from "../channel-setup/discovery.js"; +import { listTrustedChannelPluginCatalogEntries } from "../channel-setup/trusted-catalog.js"; import { formatChannelAccountLabel, requireValidConfig } from "./shared.js"; export type ChannelsListOptions = { json?: boolean; - usage?: boolean; + all?: boolean; }; const colorValue = (value: string) => { @@ -34,6 +35,10 @@ function formatConfigured(value: boolean): string { return value ? theme.success("configured") : theme.warn("not configured"); } +function formatInstalled(value: boolean): string { + return value ? theme.success("installed") : theme.warn("not installed"); +} + function formatTokenSource(source?: string): string { const value = source || "none"; return `token=${colorValue(value)}`; @@ -55,8 +60,9 @@ function shouldShowConfigured(channel: ChannelPlugin): boolean { function formatAccountLine(params: { channel: ChannelPlugin; snapshot: ChannelAccountSnapshot; + installed: boolean; }): string { - const { channel, snapshot } = params; + const { channel, snapshot, installed } = params; const label = formatChannelAccountLabel({ channel: channel.id, accountId: snapshot.accountId, @@ -66,12 +72,16 @@ function formatAccountLine(params: { accountStyle: theme.heading, }); const bits: string[] = []; - if (snapshot.linked !== undefined) { - bits.push(formatLinked(snapshot.linked)); - } + bits.push(formatInstalled(installed)); if (shouldShowConfigured(channel) && typeof snapshot.configured === "boolean") { bits.push(formatConfigured(snapshot.configured)); } + if (typeof snapshot.enabled === "boolean") { + bits.push(formatEnabled(snapshot.enabled)); + } + if (snapshot.linked !== undefined) { + bits.push(formatLinked(snapshot.linked)); + } if (snapshot.tokenSource) { bits.push(formatTokenSource(snapshot.tokenSource)); } @@ -84,26 +94,21 @@ function formatAccountLine(params: { if (snapshot.baseUrl) { bits.push(`base=${theme.muted(snapshot.baseUrl)}`); } - if (typeof snapshot.enabled === "boolean") { - bits.push(formatEnabled(snapshot.enabled)); - } 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)); - } - return null; - } + +function formatCatalogOnlyLine(params: { + entry: ChannelPluginCatalogEntry; + installed: boolean; +}): string { + const { entry, installed } = params; + const channelText = theme.accent(entry.meta.label ?? entry.id); + const bits: string[] = [ + formatInstalled(installed), + formatConfigured(false), + formatEnabled(false), + ]; + return `- ${channelText}: ${bits.join(", ")}`; } export async function channelsListCommand( @@ -114,78 +119,169 @@ export async function channelsListCommand( if (!cfg) { return; } - const includeUsage = opts.usage !== false; + const showAll = opts.all === true; const plugins = listReadOnlyChannelPluginsForConfig(cfg, { includeSetupFallbackPlugins: true, }); + const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)); + const catalogEntries = listTrustedChannelPluginCatalogEntries({ + cfg, + ...(workspaceDir ? { workspaceDir } : {}), + }); + const installedByChannelId = new Map(); + for (const entry of catalogEntries) { + installedByChannelId.set( + entry.id, + isCatalogChannelInstalled({ + cfg, + entry, + ...(workspaceDir ? { workspaceDir } : {}), + }), + ); + } + // A plugin loaded into the runtime registry is, by definition, installed. + // Catalog-tracked channels may still be flagged as not installed when the + // plugin object only came in via setup fallback metadata; in that case the + // explicit catalog check above wins. + const isInstalled = (channelId: string): boolean => installedByChannelId.get(channelId) ?? true; - 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); + type AccountLineSource = { + plugin: ChannelPlugin; + snapshot: ChannelAccountSnapshot; + installed: boolean; + }; + const accountLines: AccountLineSource[] = []; + const renderedChannelIds = new Set(); + + for (const plugin of plugins) { + const accountIds = plugin.config.listAccountIds(cfg); + if (accountIds && accountIds.length > 0) { + renderedChannelIds.add(plugin.id); + for (const accountId of accountIds) { + const snapshot = await buildChannelAccountSnapshot({ plugin, cfg, accountId }); + accountLines.push({ + plugin, + snapshot, + installed: isInstalled(plugin.id), + }); + } + continue; } - const payload = { chat, auth: authProfiles, ...(usage ? { usage } : {}) }; - writeRuntimeJson(runtime, payload); + if (!showAll) { + continue; + } + if (!shouldShowConfigured(plugin)) { + continue; + } + // --all: surface installed-but-unconfigured plugins (bundled, or + // catalog plugins that already landed on disk) so users can see the + // full set of channels they could enable without first running + // `channels add`. Use the channel's default account so the snapshot + // can reflect "not configured / not enabled" state. + const snapshot = await buildChannelAccountSnapshot({ + plugin, + cfg, + accountId: "default", + }); + renderedChannelIds.add(plugin.id); + accountLines.push({ + plugin, + snapshot, + installed: isInstalled(plugin.id), + }); + } + + // --all also surfaces catalog entries that are not already represented + // by a plugin row above. Two shapes land here: + // 1. Catalog plugin package is not yet installed on disk — rendered as + // `not installed, not configured, disabled` so the channel still + // appears in the listing as installable. + // 2. Catalog plugin package IS installed but the user has no config + // entry for the channel, AND the read-only loader did not surface + // a plugin object for it (because it only activates based on + // configured channels). These would otherwise silently disappear + // from the listing — render them as `installed, not configured, + // disabled` so operators can tell the plugin is ready to configure. + const catalogOnlyLines: ChannelPluginCatalogEntry[] = showAll + ? catalogEntries.filter((entry) => !renderedChannelIds.has(entry.id)) + : []; + + if (opts.json) { + type JsonChannelEntry = { + accounts: string[]; + installed: boolean; + origin: "configured" | "available" | "installable"; + }; + const chat: Record = {}; + for (const plugin of plugins) { + const accountIds = plugin.config.listAccountIds(cfg); + const installed = isInstalled(plugin.id); + if (accountIds && accountIds.length > 0) { + chat[plugin.id] = { + accounts: accountIds, + installed, + origin: "configured", + }; + } else if (showAll && shouldShowConfigured(plugin)) { + chat[plugin.id] = { + accounts: [], + installed, + origin: "available", + }; + } + } + if (showAll) { + for (const entry of catalogOnlyLines) { + const installed = isInstalled(entry.id); + chat[entry.id] = { + accounts: [], + installed, + origin: installed ? "available" : "installable", + }; + } + } + writeRuntimeJson(runtime, { chat }); 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) { - continue; - } - for (const accountId of accounts) { - const snapshot = await buildChannelAccountSnapshot({ - plugin, - cfg, - accountId, - }); + if (accountLines.length === 0 && catalogOnlyLines.length === 0) { + lines.push( + theme.muted( + showAll + ? "- no chat channels found" + : "- no configured chat channels (run `openclaw channels list --all` to see installable channels)", + ), + ); + } else { + for (const line of accountLines) { lines.push( formatAccountLine({ - channel: plugin, - snapshot, + channel: line.plugin, + snapshot: line.snapshot, + installed: line.installed, + }), + ); + } + for (const entry of catalogOnlyLines) { + lines.push( + formatCatalogOnlyLine({ + entry, + installed: isInstalled(entry.id), }), ); } } - 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( + theme.muted( + "Model provider usage moved out of `channels list` — see `openclaw status` or `openclaw models list`.", + ), + ); runtime.log(`Docs: ${formatDocsLink("/gateway/configuration", "gateway/configuration")}`); }