refactor!: rename chat providers to channels

This commit is contained in:
Peter Steinberger
2026-01-13 06:16:43 +00:00
parent 0cd632ba84
commit 90342a4f3a
393 changed files with 8004 additions and 6737 deletions

View File

@@ -0,0 +1,67 @@
import { getChannelPlugin } from "../../channels/plugins/index.js";
import type {
ChannelId,
ChannelSetupInput,
} from "../../channels/plugins/types.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { normalizeAccountId } from "../../routing/session-key.js";
type ChatChannel = ChannelId;
export function applyAccountName(params: {
cfg: ClawdbotConfig;
channel: ChatChannel;
accountId: string;
name?: string;
}): ClawdbotConfig {
const accountId = normalizeAccountId(params.accountId);
const plugin = getChannelPlugin(params.channel);
const apply = plugin?.setup?.applyAccountName;
return apply
? apply({ cfg: params.cfg, accountId, name: params.name })
: params.cfg;
}
export function applyChannelAccountConfig(params: {
cfg: ClawdbotConfig;
channel: ChatChannel;
accountId: string;
name?: string;
token?: string;
tokenFile?: string;
botToken?: string;
appToken?: string;
signalNumber?: string;
cliPath?: string;
dbPath?: string;
service?: "imessage" | "sms" | "auto";
region?: string;
authDir?: string;
httpUrl?: string;
httpHost?: string;
httpPort?: string;
useEnv?: boolean;
}): ClawdbotConfig {
const accountId = normalizeAccountId(params.accountId);
const plugin = getChannelPlugin(params.channel);
const apply = plugin?.setup?.applyAccountConfig;
if (!apply) return params.cfg;
const input: ChannelSetupInput = {
name: params.name,
token: params.token,
tokenFile: params.tokenFile,
botToken: params.botToken,
appToken: params.appToken,
signalNumber: params.signalNumber,
cliPath: params.cliPath,
dbPath: params.dbPath,
service: params.service,
region: params.region,
authDir: params.authDir,
httpUrl: params.httpUrl,
httpHost: params.httpHost,
httpPort: params.httpPort,
useEnv: params.useEnv,
};
return apply({ cfg: params.cfg, accountId, input });
}

View File

@@ -0,0 +1,168 @@
import {
getChannelPlugin,
normalizeChannelId,
} from "../../channels/plugins/index.js";
import type { ChannelId } from "../../channels/plugins/types.js";
import { writeConfigFile } from "../../config/config.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
} from "../../routing/session-key.js";
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
import { createClackPrompter } from "../../wizard/clack-prompter.js";
import { setupChannels } from "../onboard-channels.js";
import type { ChannelChoice } from "../onboard-types.js";
import { applyAccountName, applyChannelAccountConfig } from "./add-mutators.js";
import { channelLabel, requireValidConfig, shouldUseWizard } from "./shared.js";
export type ChannelsAddOptions = {
channel?: string;
account?: string;
name?: string;
token?: string;
tokenFile?: string;
botToken?: string;
appToken?: string;
signalNumber?: string;
cliPath?: string;
dbPath?: string;
service?: "imessage" | "sms" | "auto";
region?: string;
authDir?: string;
httpUrl?: string;
httpHost?: string;
httpPort?: string;
useEnv?: boolean;
};
export async function channelsAddCommand(
opts: ChannelsAddOptions,
runtime: RuntimeEnv = defaultRuntime,
params?: { hasFlags?: boolean },
) {
const cfg = await requireValidConfig(runtime);
if (!cfg) return;
const useWizard = shouldUseWizard(params);
if (useWizard) {
const prompter = createClackPrompter();
let selection: ChannelChoice[] = [];
const accountIds: Partial<Record<ChannelChoice, string>> = {};
await prompter.intro("Channel setup");
let nextConfig = await setupChannels(cfg, runtime, prompter, {
allowDisable: false,
allowSignalInstall: true,
promptAccountIds: true,
onSelection: (value) => {
selection = value;
},
onAccountId: (channel, accountId) => {
accountIds[channel] = accountId;
},
});
if (selection.length === 0) {
await prompter.outro("No channels selected.");
return;
}
const wantsNames = await prompter.confirm({
message: "Add display names for these accounts? (optional)",
initialValue: false,
});
if (wantsNames) {
for (const channel of selection) {
const accountId = accountIds[channel] ?? DEFAULT_ACCOUNT_ID;
const plugin = getChannelPlugin(channel as ChannelId);
const account = plugin?.config.resolveAccount(nextConfig, accountId) as
| { name?: string }
| undefined;
const snapshot = plugin?.config.describeAccount?.(account, nextConfig);
const existingName = snapshot?.name ?? account?.name;
const name = await prompter.text({
message: `${channel} account name (${accountId})`,
initialValue: existingName,
});
if (name?.trim()) {
nextConfig = applyAccountName({
cfg: nextConfig,
channel,
accountId,
name,
});
}
}
}
await writeConfigFile(nextConfig);
await prompter.outro("Channels updated.");
return;
}
const channel = normalizeChannelId(opts.channel);
if (!channel) {
runtime.error(`Unknown channel: ${String(opts.channel ?? "")}`);
runtime.exit(1);
return;
}
const plugin = getChannelPlugin(channel);
if (!plugin?.setup?.applyAccountConfig) {
runtime.error(`Channel ${channel} does not support add.`);
runtime.exit(1);
return;
}
const accountId =
plugin.setup.resolveAccountId?.({ cfg, accountId: opts.account }) ??
normalizeAccountId(opts.account);
const useEnv = opts.useEnv === true;
const validationError = plugin.setup.validateInput?.({
cfg,
accountId,
input: {
name: opts.name,
token: opts.token,
tokenFile: opts.tokenFile,
botToken: opts.botToken,
appToken: opts.appToken,
signalNumber: opts.signalNumber,
cliPath: opts.cliPath,
dbPath: opts.dbPath,
service: opts.service,
region: opts.region,
authDir: opts.authDir,
httpUrl: opts.httpUrl,
httpHost: opts.httpHost,
httpPort: opts.httpPort,
useEnv,
},
});
if (validationError) {
runtime.error(validationError);
runtime.exit(1);
return;
}
const nextConfig = applyChannelAccountConfig({
cfg,
channel,
accountId,
name: opts.name,
token: opts.token,
tokenFile: opts.tokenFile,
botToken: opts.botToken,
appToken: opts.appToken,
signalNumber: opts.signalNumber,
cliPath: opts.cliPath,
dbPath: opts.dbPath,
service: opts.service,
region: opts.region,
authDir: opts.authDir,
httpUrl: opts.httpUrl,
httpHost: opts.httpHost,
httpPort: opts.httpPort,
useEnv,
});
await writeConfigFile(nextConfig);
runtime.log(`Added ${channelLabel(channel)} account "${accountId}".`);
}

View File

@@ -0,0 +1,196 @@
import {
CLAUDE_CLI_PROFILE_ID,
CODEX_CLI_PROFILE_ID,
loadAuthProfileStore,
} from "../../agents/auth-profiles.js";
import { listChannelPlugins } from "../../channels/plugins/index.js";
import { buildChannelAccountSnapshot } from "../../channels/plugins/status.js";
import type {
ChannelAccountSnapshot,
ChannelPlugin,
} from "../../channels/plugins/types.js";
import { withProgress } from "../../cli/progress.js";
import {
formatUsageReportLines,
loadProviderUsageSummary,
} from "../../infra/provider-usage.js";
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
import { formatDocsLink } from "../../terminal/links.js";
import { theme } from "../../terminal/theme.js";
import { formatChannelAccountLabel, requireValidConfig } from "./shared.js";
export type ChannelsListOptions = {
json?: boolean;
usage?: boolean;
};
const colorValue = (value: string) => {
if (value === "none") return theme.error(value);
if (value === "env") return theme.accent(value);
return theme.success(value);
};
function formatEnabled(value: boolean | undefined): string {
return value === false ? theme.error("disabled") : theme.success("enabled");
}
function formatConfigured(value: boolean): string {
return value ? theme.success("configured") : theme.warn("not configured");
}
function formatTokenSource(source?: string): string {
const value = source || "none";
return `token=${colorValue(value)}`;
}
function formatSource(label: string, source?: string): string {
const value = source || "none";
return `${label}=${colorValue(value)}`;
}
function formatLinked(value: boolean): string {
return value ? theme.success("linked") : theme.warn("not linked");
}
function shouldShowConfigured(channel: ChannelPlugin): boolean {
return channel.meta.showConfigured !== false;
}
function formatAccountLine(params: {
channel: ChannelPlugin;
snapshot: ChannelAccountSnapshot;
}): string {
const { channel, snapshot } = params;
const label = formatChannelAccountLabel({
channel: channel.id,
accountId: snapshot.accountId,
name: snapshot.name,
channelStyle: theme.accent,
accountStyle: theme.heading,
});
const bits: string[] = [];
if (snapshot.linked !== undefined) {
bits.push(formatLinked(snapshot.linked));
}
if (
shouldShowConfigured(channel) &&
typeof snapshot.configured === "boolean"
) {
bits.push(formatConfigured(snapshot.configured));
}
if (snapshot.tokenSource) {
bits.push(formatTokenSource(snapshot.tokenSource));
}
if (snapshot.botTokenSource) {
bits.push(formatSource("bot", snapshot.botTokenSource));
}
if (snapshot.appTokenSource) {
bits.push(formatSource("app", snapshot.appTokenSource));
}
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,
): Promise<Awaited<ReturnType<typeof loadProviderUsageSummary>> | null> {
try {
return await withProgress(
{ label: "Fetching usage snapshot…", indeterminate: true, enabled: true },
async () => await loadProviderUsageSummary(),
);
} catch (err) {
runtime.error(String(err));
return null;
}
}
export async function channelsListCommand(
opts: ChannelsListOptions,
runtime: RuntimeEnv = defaultRuntime,
) {
const cfg = await requireValidConfig(runtime);
if (!cfg) return;
const includeUsage = opts.usage !== false;
const plugins = listChannelPlugins();
const authStore = loadAuthProfileStore();
const authProfiles = Object.entries(authStore.profiles).map(
([profileId, profile]) => ({
id: profileId,
provider: profile.provider,
type: profile.type,
isExternal:
profileId === CLAUDE_CLI_PROFILE_ID ||
profileId === CODEX_CLI_PROFILE_ID,
}),
);
if (opts.json) {
const usage = includeUsage ? await loadProviderUsageSummary() : undefined;
const chat: Record<string, string[]> = {};
for (const plugin of plugins) {
chat[plugin.id] = plugin.config.listAccountIds(cfg);
}
const payload = { chat, auth: authProfiles, ...(usage ? { usage } : {}) };
runtime.log(JSON.stringify(payload, null, 2));
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,
});
lines.push(
formatAccountLine({
channel: plugin,
snapshot,
}),
);
}
}
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")}`,
);
}

View File

@@ -0,0 +1,101 @@
import fs from "node:fs/promises";
import { listChannelPlugins } from "../../channels/plugins/index.js";
import { parseLogLine } from "../../logging/parse-log-line.js";
import { getResolvedLoggerSettings } from "../../logging.js";
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
import { theme } from "../../terminal/theme.js";
export type ChannelsLogsOptions = {
channel?: string;
lines?: string | number;
json?: boolean;
};
type LogLine = ReturnType<typeof parseLogLine>;
const DEFAULT_LIMIT = 200;
const MAX_BYTES = 1_000_000;
const CHANNELS = new Set<string>([
...listChannelPlugins().map((plugin) => plugin.id),
"all",
]);
function parseChannelFilter(raw?: string) {
const trimmed = raw?.trim().toLowerCase();
if (!trimmed) return "all";
return CHANNELS.has(trimmed) ? trimmed : "all";
}
function matchesChannel(line: NonNullable<LogLine>, channel: string) {
if (channel === "all") return true;
const needle = `gateway/channels/${channel}`;
if (line.subsystem?.includes(needle)) return true;
if (line.module?.includes(channel)) return true;
return false;
}
async function readTailLines(file: string, limit: number): Promise<string[]> {
const stat = await fs.stat(file).catch(() => null);
if (!stat) return [];
const size = stat.size;
const start = Math.max(0, size - MAX_BYTES);
const handle = await fs.open(file, "r");
try {
const length = Math.max(0, size - start);
if (length === 0) return [];
const buffer = Buffer.alloc(length);
const readResult = await handle.read(buffer, 0, length, start);
const text = buffer.toString("utf8", 0, readResult.bytesRead);
let lines = text.split("\n");
if (start > 0) lines = lines.slice(1);
if (lines.length && lines[lines.length - 1] === "") {
lines = lines.slice(0, -1);
}
if (lines.length > limit) {
lines = lines.slice(lines.length - limit);
}
return lines;
} finally {
await handle.close();
}
}
export async function channelsLogsCommand(
opts: ChannelsLogsOptions,
runtime: RuntimeEnv = defaultRuntime,
) {
const channel = parseChannelFilter(opts.channel);
const limitRaw =
typeof opts.lines === "string" ? Number(opts.lines) : opts.lines;
const limit =
typeof limitRaw === "number" && Number.isFinite(limitRaw) && limitRaw > 0
? Math.floor(limitRaw)
: DEFAULT_LIMIT;
const file = getResolvedLoggerSettings().file;
const rawLines = await readTailLines(file, limit * 4);
const parsed = rawLines
.map(parseLogLine)
.filter((line): line is NonNullable<LogLine> => Boolean(line));
const filtered = parsed.filter((line) => matchesChannel(line, channel));
const lines = filtered.slice(Math.max(0, filtered.length - limit));
if (opts.json) {
runtime.log(JSON.stringify({ file, channel, lines }, null, 2));
return;
}
runtime.log(theme.info(`Log file: ${file}`));
if (channel !== "all") {
runtime.log(theme.info(`Channel: ${channel}`));
}
if (lines.length === 0) {
runtime.log(theme.muted("No matching log lines."));
return;
}
for (const line of lines) {
const ts = line.time ? `${line.time} ` : "";
const level = line.level ? `${line.level.toLowerCase()} ` : "";
runtime.log(`${ts}${level}${line.message}`.trim());
}
}

View File

@@ -0,0 +1,147 @@
import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js";
import {
getChannelPlugin,
listChannelPlugins,
normalizeChannelId,
} from "../../channels/plugins/index.js";
import { type ClawdbotConfig, writeConfigFile } from "../../config/config.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
} from "../../routing/session-key.js";
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
import { createClackPrompter } from "../../wizard/clack-prompter.js";
import {
type ChatChannel,
channelLabel,
requireValidConfig,
shouldUseWizard,
} from "./shared.js";
export type ChannelsRemoveOptions = {
channel?: string;
account?: string;
delete?: boolean;
};
function listAccountIds(cfg: ClawdbotConfig, channel: ChatChannel): string[] {
const plugin = getChannelPlugin(channel);
if (!plugin) return [];
return plugin.config.listAccountIds(cfg);
}
export async function channelsRemoveCommand(
opts: ChannelsRemoveOptions,
runtime: RuntimeEnv = defaultRuntime,
params?: { hasFlags?: boolean },
) {
const cfg = await requireValidConfig(runtime);
if (!cfg) return;
const useWizard = shouldUseWizard(params);
const prompter = useWizard ? createClackPrompter() : null;
let channel: ChatChannel | null = normalizeChannelId(opts.channel);
let accountId = normalizeAccountId(opts.account);
const deleteConfig = Boolean(opts.delete);
if (useWizard && prompter) {
await prompter.intro("Remove channel account");
const selectedChannel = (await prompter.select({
message: "Channel",
options: listChannelPlugins().map((plugin) => ({
value: plugin.id,
label: plugin.meta.label,
})),
})) as ChatChannel;
channel = selectedChannel;
accountId = await (async () => {
const ids = listAccountIds(cfg, selectedChannel);
const choice = (await prompter.select({
message: "Account",
options: ids.map((id) => ({
value: id,
label: id === DEFAULT_ACCOUNT_ID ? "default (primary)" : id,
})),
initialValue: ids[0] ?? DEFAULT_ACCOUNT_ID,
})) as string;
return normalizeAccountId(choice);
})();
const wantsDisable = await prompter.confirm({
message: `Disable ${channelLabel(selectedChannel)} account "${accountId}"? (keeps config)`,
initialValue: true,
});
if (!wantsDisable) {
await prompter.outro("Cancelled.");
return;
}
} else {
if (!channel) {
runtime.error("Channel is required. Use --channel <name>.");
runtime.exit(1);
return;
}
if (!deleteConfig) {
const confirm = createClackPrompter();
const ok = await confirm.confirm({
message: `Disable ${channelLabel(channel)} account "${accountId}"? (keeps config)`,
initialValue: true,
});
if (!ok) {
return;
}
}
}
const plugin = getChannelPlugin(channel);
if (!plugin) {
runtime.error(`Unknown channel: ${channel}`);
runtime.exit(1);
return;
}
const resolvedAccountId =
normalizeAccountId(accountId) ??
resolveChannelDefaultAccountId({ plugin, cfg });
const accountKey = resolvedAccountId || DEFAULT_ACCOUNT_ID;
let next = { ...cfg };
if (deleteConfig) {
if (!plugin.config.deleteAccount) {
runtime.error(`Channel ${channel} does not support delete.`);
runtime.exit(1);
return;
}
next = plugin.config.deleteAccount({
cfg: next,
accountId: resolvedAccountId,
});
} else {
if (!plugin.config.setAccountEnabled) {
runtime.error(`Channel ${channel} does not support disable.`);
runtime.exit(1);
return;
}
next = plugin.config.setAccountEnabled({
cfg: next,
accountId: resolvedAccountId,
enabled: false,
});
}
await writeConfigFile(next);
if (useWizard && prompter) {
await prompter.outro(
deleteConfig
? `Deleted ${channelLabel(channel)} account "${accountKey}".`
: `Disabled ${channelLabel(channel)} account "${accountKey}".`,
);
} else {
runtime.log(
deleteConfig
? `Deleted ${channelLabel(channel)} account "${accountKey}".`
: `Disabled ${channelLabel(channel)} account "${accountKey}".`,
);
}
}

View File

@@ -0,0 +1,70 @@
import {
type ChannelId,
getChannelPlugin,
} from "../../channels/plugins/index.js";
import {
type ClawdbotConfig,
readConfigFileSnapshot,
} from "../../config/config.js";
import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js";
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
export type ChatChannel = ChannelId;
export async function requireValidConfig(
runtime: RuntimeEnv = defaultRuntime,
): Promise<ClawdbotConfig | null> {
const snapshot = await readConfigFileSnapshot();
if (snapshot.exists && !snapshot.valid) {
const issues =
snapshot.issues.length > 0
? snapshot.issues
.map((issue) => `- ${issue.path}: ${issue.message}`)
.join("\n")
: "Unknown validation issue.";
runtime.error(`Config invalid:\n${issues}`);
runtime.error("Fix the config or run clawdbot doctor.");
runtime.exit(1);
return null;
}
return snapshot.config;
}
export function formatAccountLabel(params: {
accountId: string;
name?: string;
}) {
const base = params.accountId || DEFAULT_ACCOUNT_ID;
if (params.name?.trim()) return `${base} (${params.name.trim()})`;
return base;
}
export const channelLabel = (channel: ChatChannel) => {
const plugin = getChannelPlugin(channel);
return plugin?.meta.label ?? channel;
};
export function formatChannelAccountLabel(params: {
channel: ChatChannel;
accountId: string;
name?: string;
channelStyle?: (value: string) => string;
accountStyle?: (value: string) => string;
}): string {
const channelText = channelLabel(params.channel);
const accountText = formatAccountLabel({
accountId: params.accountId,
name: params.name,
});
const styledChannel = params.channelStyle
? params.channelStyle(channelText)
: channelText;
const styledAccount = params.accountStyle
? params.accountStyle(accountText)
: accountText;
return `${styledChannel} ${styledAccount}`;
}
export function shouldUseWizard(params?: { hasFlags?: boolean }) {
return params?.hasFlags === false;
}

View File

@@ -0,0 +1,301 @@
import { listChannelPlugins } from "../../channels/plugins/index.js";
import { buildChannelAccountSnapshot } from "../../channels/plugins/status.js";
import type { ChannelAccountSnapshot } from "../../channels/plugins/types.js";
import { withProgress } from "../../cli/progress.js";
import {
type ClawdbotConfig,
readConfigFileSnapshot,
} from "../../config/config.js";
import { callGateway } from "../../gateway/call.js";
import { formatAge } from "../../infra/channel-summary.js";
import { collectChannelStatusIssues } from "../../infra/channels-status-issues.js";
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
import { formatDocsLink } from "../../terminal/links.js";
import { theme } from "../../terminal/theme.js";
import {
type ChatChannel,
formatChannelAccountLabel,
requireValidConfig,
} from "./shared.js";
export type ChannelsStatusOptions = {
json?: boolean;
probe?: boolean;
timeout?: string;
};
export function formatGatewayChannelsStatusLines(
payload: Record<string, unknown>,
): string[] {
const lines: string[] = [];
lines.push(theme.success("Gateway reachable."));
const accountLines = (
provider: ChatChannel,
accounts: Array<Record<string, unknown>>,
) =>
accounts.map((account) => {
const bits: string[] = [];
if (typeof account.enabled === "boolean") {
bits.push(account.enabled ? "enabled" : "disabled");
}
if (typeof account.configured === "boolean") {
bits.push(account.configured ? "configured" : "not configured");
}
if (typeof account.linked === "boolean") {
bits.push(account.linked ? "linked" : "not linked");
}
if (typeof account.running === "boolean") {
bits.push(account.running ? "running" : "stopped");
}
if (typeof account.connected === "boolean") {
bits.push(account.connected ? "connected" : "disconnected");
}
const inboundAt =
typeof account.lastInboundAt === "number" &&
Number.isFinite(account.lastInboundAt)
? account.lastInboundAt
: null;
const outboundAt =
typeof account.lastOutboundAt === "number" &&
Number.isFinite(account.lastOutboundAt)
? account.lastOutboundAt
: null;
if (inboundAt) bits.push(`in:${formatAge(Date.now() - inboundAt)}`);
if (outboundAt) bits.push(`out:${formatAge(Date.now() - outboundAt)}`);
if (typeof account.mode === "string" && account.mode.length > 0) {
bits.push(`mode:${account.mode}`);
}
if (typeof account.dmPolicy === "string" && account.dmPolicy.length > 0) {
bits.push(`dm:${account.dmPolicy}`);
}
if (Array.isArray(account.allowFrom) && account.allowFrom.length > 0) {
bits.push(`allow:${account.allowFrom.slice(0, 2).join(",")}`);
}
if (typeof account.tokenSource === "string" && account.tokenSource) {
bits.push(`token:${account.tokenSource}`);
}
if (
typeof account.botTokenSource === "string" &&
account.botTokenSource
) {
bits.push(`bot:${account.botTokenSource}`);
}
if (
typeof account.appTokenSource === "string" &&
account.appTokenSource
) {
bits.push(`app:${account.appTokenSource}`);
}
const application = account.application as
| { intents?: { messageContent?: string } }
| undefined;
const messageContent = application?.intents?.messageContent;
if (
typeof messageContent === "string" &&
messageContent.length > 0 &&
messageContent !== "enabled"
) {
bits.push(`intents:content=${messageContent}`);
}
if (account.allowUnmentionedGroups === true) {
bits.push("groups:unmentioned");
}
if (typeof account.baseUrl === "string" && account.baseUrl) {
bits.push(`url:${account.baseUrl}`);
}
const probe = account.probe as { ok?: boolean } | undefined;
if (probe && typeof probe.ok === "boolean") {
bits.push(probe.ok ? "works" : "probe failed");
}
const audit = account.audit as { ok?: boolean } | undefined;
if (audit && typeof audit.ok === "boolean") {
bits.push(audit.ok ? "audit ok" : "audit failed");
}
if (typeof account.lastError === "string" && account.lastError) {
bits.push(`error:${account.lastError}`);
}
const accountId =
typeof account.accountId === "string" ? account.accountId : "default";
const name = typeof account.name === "string" ? account.name.trim() : "";
const labelText = formatChannelAccountLabel({
channel: provider,
accountId,
name: name || undefined,
});
return `- ${labelText}: ${bits.join(", ")}`;
});
const plugins = listChannelPlugins();
const accountsByChannel = payload.channelAccounts as
| Record<string, unknown>
| undefined;
const accountPayloads: Partial<
Record<string, Array<Record<string, unknown>>>
> = {};
for (const plugin of plugins) {
const raw = accountsByChannel?.[plugin.id];
if (Array.isArray(raw)) {
accountPayloads[plugin.id] = raw as Array<Record<string, unknown>>;
}
}
for (const plugin of plugins) {
const accounts = accountPayloads[plugin.id];
if (accounts && accounts.length > 0) {
lines.push(...accountLines(plugin.id as ChatChannel, accounts));
}
}
lines.push("");
const issues = collectChannelStatusIssues(payload);
if (issues.length > 0) {
lines.push(theme.warn("Warnings:"));
for (const issue of issues) {
lines.push(
`- ${issue.channel} ${issue.accountId}: ${issue.message}${issue.fix ? ` (${issue.fix})` : ""}`,
);
}
lines.push(`- Run: clawdbot doctor`);
lines.push("");
}
lines.push(
`Tip: ${formatDocsLink("/cli#status", "status --deep")} adds gateway health probes to status output (requires a reachable gateway).`,
);
return lines;
}
async function formatConfigChannelsStatusLines(
cfg: ClawdbotConfig,
meta: { path?: string; mode?: "local" | "remote" },
): Promise<string[]> {
const lines: string[] = [];
lines.push(theme.warn("Gateway not reachable; showing config-only status."));
if (meta.path) {
lines.push(`Config: ${meta.path}`);
}
if (meta.mode) {
lines.push(`Mode: ${meta.mode}`);
}
if (meta.path || meta.mode) lines.push("");
const accountLines = (
provider: ChatChannel,
accounts: Array<Record<string, unknown>>,
) =>
accounts.map((account) => {
const bits: string[] = [];
if (typeof account.enabled === "boolean") {
bits.push(account.enabled ? "enabled" : "disabled");
}
if (typeof account.configured === "boolean") {
bits.push(account.configured ? "configured" : "not configured");
}
if (typeof account.linked === "boolean") {
bits.push(account.linked ? "linked" : "not linked");
}
if (typeof account.mode === "string" && account.mode.length > 0) {
bits.push(`mode:${account.mode}`);
}
if (typeof account.tokenSource === "string" && account.tokenSource) {
bits.push(`token:${account.tokenSource}`);
}
if (
typeof account.botTokenSource === "string" &&
account.botTokenSource
) {
bits.push(`bot:${account.botTokenSource}`);
}
if (
typeof account.appTokenSource === "string" &&
account.appTokenSource
) {
bits.push(`app:${account.appTokenSource}`);
}
if (typeof account.baseUrl === "string" && account.baseUrl) {
bits.push(`url:${account.baseUrl}`);
}
const accountId =
typeof account.accountId === "string" ? account.accountId : "default";
const name = typeof account.name === "string" ? account.name.trim() : "";
const labelText = formatChannelAccountLabel({
channel: provider,
accountId,
name: name || undefined,
});
return `- ${labelText}: ${bits.join(", ")}`;
});
const plugins = listChannelPlugins();
for (const plugin of plugins) {
const accountIds = plugin.config.listAccountIds(cfg);
if (!accountIds.length) continue;
const snapshots: ChannelAccountSnapshot[] = [];
for (const accountId of accountIds) {
const snapshot = await buildChannelAccountSnapshot({
plugin,
cfg,
accountId,
});
snapshots.push(snapshot);
}
if (snapshots.length > 0) {
lines.push(...accountLines(plugin.id as ChatChannel, snapshots));
}
}
lines.push("");
lines.push(
`Tip: ${formatDocsLink("/cli#status", "status --deep")} adds gateway health probes to status output (requires a reachable gateway).`,
);
return lines;
}
export async function channelsStatusCommand(
opts: ChannelsStatusOptions,
runtime: RuntimeEnv = defaultRuntime,
) {
const timeoutMs = Number(opts.timeout ?? 10_000);
const statusLabel = opts.probe
? "Checking channel status (probe)…"
: "Checking channel status…";
const shouldLogStatus = opts.json !== true && !process.stderr.isTTY;
if (shouldLogStatus) runtime.log(statusLabel);
try {
const payload = await withProgress(
{
label: statusLabel,
indeterminate: true,
enabled: opts.json !== true,
},
async () =>
await callGateway({
method: "channels.status",
params: { probe: Boolean(opts.probe), timeoutMs },
timeoutMs,
}),
);
if (opts.json) {
runtime.log(JSON.stringify(payload, null, 2));
return;
}
runtime.log(
formatGatewayChannelsStatusLines(payload as Record<string, unknown>).join(
"\n",
),
);
} catch (err) {
runtime.error(`Gateway not reachable: ${String(err)}`);
const cfg = await requireValidConfig(runtime);
if (!cfg) return;
const snapshot = await readConfigFileSnapshot();
const mode = cfg.gateway?.mode === "remote" ? "remote" : "local";
runtime.log(
(
await formatConfigChannelsStatusLines(cfg, {
path: snapshot.path,
mode,
})
).join("\n"),
);
}
}