From 9170243f9246814aeb90f8b4bd157c47f8e7e5cc Mon Sep 17 00:00:00 2001 From: Sliverp <38134380+sliverp@users.noreply.github.com> Date: Thu, 7 May 2026 17:28:03 +0800 Subject: [PATCH] Feat/channels list show all and drop auth (#78456) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(channels list): drop auth providers, add --all, surface installed/configured/enabled `openclaw channels list` used to conflate two very different surfaces: chat channels and OAuth/API-key auth providers for model routing. The auth section was the first and most visible block in the output even for operators who only cared about chat channels, and its JSON `auth` key leaked model-provider identities into a command whose top-level help describes it as channel management. Worse, the command silently hid every channel that had no configured account, so users could not tell from `channels list` which bundled or catalog channels were even available to configure. Split the surface cleanly around channels only: 1. Remove the `Auth providers (OAuth + API keys)` text section and the `auth` field from the JSON payload. Model-provider auth profiles remain reachable via `openclaw models auth list`, which is where they conceptually belong. 2. Add a `--all` flag to surface every channel an operator could configure: bundled channel plugins that have no account yet and catalog-listed external channels whose plugin package is not even installed on disk. Without `--all` the output still shows only channels with at least one configured account, matching the previous default behavior so existing scripts keep working. The "empty" default path now prints a hint pointing at `--all`. 3. Render three explicit status tags per row — `installed` / `not installed`, `configured` / `not configured`, `enabled` / `disabled` — so bundled-but-unconfigured plugins and installable catalog channels both render with accurate state instead of being invisible. Installed state comes from the same `isCatalogChannelInstalled` probe the setup flow uses, so it stays consistent with `openclaw onboard` and `channels add`. 4. JSON payload now carries an `origin` per channel (`configured`, `available`, `installable`) alongside `installed: boolean`, which lets tooling distinguish "user has set this up" from "user could set this up" without second-guessing. Register `--all` on both the Commander CLI and the fast-path route-arg parser so the flag works in both code paths, update the one routes test that asserted the parsed args shape, and rewrite the old auth profiles surface test as a broader `channels list` behavior spec covering default output, `--all` output, JSON shape (no `auth`), and the bundled-unconfigured + catalog-not-installed cases. Docs: call out that `channels list` is chat-channel only now, mention `--all`, and point at `openclaw models auth list` for what used to be the auth providers block. * fix(channels list): surface catalog channels that are installed on disk but not yet configured The previous `--all` path filtered catalog entries with `!installedByChannelId.get(entry.id)` before rendering them as catalog-only rows. That assumed "catalog entry not already rendered as a plugin row" implied "not installed", which is wrong: an external channel plugin package can be installed on disk (`isCatalogChannelInstalled` returns true) while the read-only channel loader still declines to surface a plugin object for it — the loader only activates channels that appear in user config, so a plugin that is installed but never configured ended up in neither bucket and silently dropped out of `channels list --all`. Operator-facing symptom: `pnpm openclaw channels list --all` omitted WeCom (and any other catalog channel in the same state) even though its npm package was present on disk and its catalog entry existed, while rendering every other uninstalled catalog channel as expected. Fix: drop the `installed` filter from `catalogOnlyLines` so every catalog entry that is not already represented by a plugin row is rendered, and let the row itself carry the real installed/not-installed tag. Two renderings now land in the catalog-only bucket: - Not installed — rendered as `not installed, not configured, disabled` (installable row). - Installed but unconfigured — rendered as `installed, not configured, disabled` (ready-to-configure row). The JSON `origin` for this case becomes `available`, matching the existing origin for bundled plugins that are installed but unconfigured, so downstream tooling sees a consistent "you could configure this now" signal regardless of whether the plugin came from bundled sources or from the catalog. Regression test added under the WeCom scenario. * refactor(channels list): drop model-provider usage surface, make the command channel-only `openclaw channels list` used to append a model-provider usage/quota snapshot (Anthropic, OpenRouter, OpenAI Codex, Gemini, Zai, Minimax, etc.) under every invocation. That was a leftover from the days when `channels list` was the only "operator overview" command; the same data is now owned by `openclaw status` (overview) and `openclaw models list` (per-provider), which handle timeouts, probe errors, and output shape consistently for that class of data. Keeping the snapshot wired into `channels list` meant: - Every default invocation made one blocking `loadProviderUsageSummary` call that fanned out to every configured provider billing/auth endpoint, adding seconds of latency to a command that otherwise just reads local config. - `channels list --no-usage` was the escape hatch, but the flag was itself a self-sustaining bug: it only existed because the command did work that did not belong to it. - JSON consumers had an optional `usage` key whose shape was owned by the provider-usage module, not by the channels module, so any change upstream silently reshaped `channels list --json` output. - Failed provider fetches printed provider-side errors on a command that never advertised itself as a provider-health surface. Scope this PR tightens, in one move: 1. Remove `loadProviderUsageSummary` / `formatUsageReportLines` usage from `src/commands/channels/list.ts`. The command now only reads config, the read-only channel plugin registry, and the trusted catalog — matching its name. 2. Drop `--no-usage` from the Commander CLI registration, from the fast-path route-arg parser (`parseChannelsListRouteArgs`), and from `ChannelsListOptions`. The flag is gone, not silently ignored, so anyone depending on it will get a clear "unknown option" from Commander and from the fast-path router. 3. Drop the `usage` key from `channels list --json` payloads. Shape of the `chat` record and the new `origin` / `installed` tags introduced earlier in this branch are unchanged. 4. Print a single-line migration pointer at the bottom of the text output so operators who expected usage know where it went (`openclaw status` / `openclaw models list`). This replaces what used to be a block of fetched provider data with one static line, so it cannot fail or add latency. 5. Update `docs/cli/channels.md` troubleshooting to remove the `--no-usage` mention and point at the two new entry points. 6. Update tests: drop the `loadProviderUsageSummary` mock and the `"keeps JSON output valid when usage loading fails"` case, replace it with a positive assertion that `payload.usage` is undefined (locking in the narrower contract), and remove `usage` from every `channelsListCommand(...)` call to match the narrowed `ChannelsListOptions` type. The route-args test is updated to expect `{ json, all }` without `usage`. No other command changes. `openclaw status` and `openclaw models list` already render usage; they are the documented replacements. Breaking-ish surface: - CLI: `channels list --no-usage` now fails with "unknown option". Tooling should drop the flag — there is nothing left to opt out of. - JSON: `channels list --json` no longer carries a top-level `usage` key. Tooling that read it must migrate to `openclaw status --json` or `openclaw models list --json`. * fix(channels.list.test): widen isCatalogChannelInstalled mock signature to accept entry param CI typecheck failed because the mock was declared with a zero-arg signature while one test called mockImplementation(({ entry }) => …). Tighten the generic so vitest's mock accepts the same params the real helper does. * changelog: record channels list channel-only rework (#78456) --- CHANGELOG.md | 1 + docs/cli/channels.md | 5 +- src/cli/channels-cli.ts | 4 +- src/cli/program/route-args.ts | 2 +- src/cli/program/routes.test.ts | 8 +- .../channels.list.auth-profiles.test.ts | 214 ---------- src/commands/channels.list.test.ts | 364 ++++++++++++++++++ src/commands/channels/list.ts | 252 ++++++++---- 8 files changed, 550 insertions(+), 300 deletions(-) delete mode 100644 src/commands/channels.list.auth-profiles.test.ts create mode 100644 src/commands/channels.list.test.ts 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")}`); }