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

@@ -1,3 +1,4 @@
import { DEFAULT_CHAT_CHANNEL } from "../channels/registry.js";
import type { CliDeps } from "../cli/deps.js";
import { withProgress } from "../cli/progress.js";
import { loadConfig } from "../config/config.js";
@@ -7,14 +8,13 @@ import {
resolveStorePath,
} from "../config/sessions.js";
import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
import { DEFAULT_CHAT_PROVIDER } from "../providers/registry.js";
import { normalizeMainKey } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
normalizeMessageProvider,
} from "../utils/message-provider.js";
normalizeMessageChannel,
} from "../utils/message-channel.js";
import { agentCommand } from "./agent.js";
type AgentGatewayResult = {
@@ -42,7 +42,7 @@ export type AgentCliOpts = {
json?: boolean;
timeout?: string;
deliver?: boolean;
provider?: string;
channel?: string;
bestEffortDeliver?: boolean;
lane?: string;
runId?: string;
@@ -129,8 +129,7 @@ export async function agentViaGatewayCommand(
sessionId: opts.sessionId,
});
const provider =
normalizeMessageProvider(opts.provider) ?? DEFAULT_CHAT_PROVIDER;
const channel = normalizeMessageChannel(opts.channel) ?? DEFAULT_CHAT_CHANNEL;
const idempotencyKey = opts.runId?.trim() || randomIdempotencyKey();
const response = await withProgress(
@@ -149,7 +148,7 @@ export async function agentViaGatewayCommand(
sessionKey,
thinking: opts.thinking,
deliver: Boolean(opts.deliver),
provider,
channel,
timeout: timeoutSeconds,
lane: opts.lane,
extraSystemPrompt: opts.extraSystemPrompt,

View File

@@ -287,7 +287,7 @@ describe("agentCommand", () => {
message: "hi",
to: "123",
deliver: true,
provider: "telegram",
channel: "telegram",
},
runtime,
deps,

View File

@@ -38,6 +38,12 @@ import {
type ThinkLevel,
type VerboseLevel,
} from "../auto-reply/thinking.js";
import {
getChannelPlugin,
normalizeChannelId,
} from "../channels/plugins/index.js";
import type { ChannelOutboundTargetMode } from "../channels/plugins/types.js";
import { DEFAULT_CHAT_CHANNEL } from "../channels/registry.js";
import {
type CliDeps,
createDefaultDeps,
@@ -67,21 +73,15 @@ import {
normalizeOutboundPayloadsForJson,
} from "../infra/outbound/payloads.js";
import { resolveOutboundTarget } from "../infra/outbound/targets.js";
import {
getProviderPlugin,
normalizeProviderId,
} from "../providers/plugins/index.js";
import type { ProviderOutboundTargetMode } from "../providers/plugins/types.js";
import { DEFAULT_CHAT_PROVIDER } from "../providers/registry.js";
import { normalizeMainKey } from "../routing/session-key.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { applyVerboseOverride } from "../sessions/level-overrides.js";
import { resolveSendPolicy } from "../sessions/send-policy.js";
import {
isInternalMessageProvider,
resolveGatewayMessageProvider,
resolveMessageProvider,
} from "../utils/message-provider.js";
isInternalMessageChannel,
resolveGatewayMessageChannel,
resolveMessageChannel,
} from "../utils/message-channel.js";
/** Image content block for Claude API multimodal messages. */
type ImageContent = {
@@ -103,10 +103,10 @@ type AgentCommandOpts = {
json?: boolean;
timeout?: string;
deliver?: boolean;
/** Message provider context (webchat|voicewake|whatsapp|...). */
messageProvider?: string;
provider?: string; // delivery provider (whatsapp|telegram|...)
deliveryTargetMode?: ProviderOutboundTargetMode;
/** Message channel context (webchat|voicewake|whatsapp|...). */
messageChannel?: string;
channel?: string; // delivery channel (whatsapp|telegram|...)
deliveryTargetMode?: ChannelOutboundTargetMode;
bestEffortDeliver?: boolean;
abortSignal?: AbortSignal;
lane?: string;
@@ -287,7 +287,7 @@ export async function agentCommand(
cfg,
entry: sessionEntry,
sessionKey,
provider: sessionEntry?.provider,
channel: sessionEntry?.channel,
chatType: sessionEntry?.chatType,
});
if (sendPolicy === "deny") {
@@ -490,9 +490,9 @@ export async function agentCommand(
let fallbackProvider = provider;
let fallbackModel = model;
try {
const messageProvider = resolveMessageProvider(
opts.messageProvider,
opts.provider,
const messageChannel = resolveMessageChannel(
opts.messageChannel,
opts.channel,
);
const fallbackResult = await runWithModelFallback({
cfg,
@@ -525,7 +525,7 @@ export async function agentCommand(
return runEmbeddedPiAgent({
sessionId,
sessionKey,
messageProvider,
messageChannel,
sessionFile,
workspaceDir,
config: cfg,
@@ -636,30 +636,28 @@ export async function agentCommand(
const payloads = result.payloads ?? [];
const deliver = opts.deliver === true;
const bestEffortDeliver = opts.bestEffortDeliver === true;
const deliveryProvider =
resolveGatewayMessageProvider(opts.provider) ?? DEFAULT_CHAT_PROVIDER;
// Provider docking: delivery providers are resolved via plugin registry.
const deliveryPlugin = !isInternalMessageProvider(deliveryProvider)
? getProviderPlugin(
normalizeProviderId(deliveryProvider) ?? deliveryProvider,
)
const deliveryChannel =
resolveGatewayMessageChannel(opts.channel) ?? DEFAULT_CHAT_CHANNEL;
// Channel docking: delivery channels are resolved via plugin registry.
const deliveryPlugin = !isInternalMessageChannel(deliveryChannel)
? getChannelPlugin(normalizeChannelId(deliveryChannel) ?? deliveryChannel)
: undefined;
const logDeliveryError = (err: unknown) => {
const message = `Delivery failed (${deliveryProvider}${deliveryTarget ? ` to ${deliveryTarget}` : ""}): ${String(err)}`;
const message = `Delivery failed (${deliveryChannel}${deliveryTarget ? ` to ${deliveryTarget}` : ""}): ${String(err)}`;
runtime.error?.(message);
if (!runtime.error) runtime.log(message);
};
const isDeliveryProviderKnown =
isInternalMessageProvider(deliveryProvider) || Boolean(deliveryPlugin);
const isDeliveryChannelKnown =
isInternalMessageChannel(deliveryChannel) || Boolean(deliveryPlugin);
const targetMode: ProviderOutboundTargetMode =
const targetMode: ChannelOutboundTargetMode =
opts.deliveryTargetMode ?? (opts.to ? "explicit" : "implicit");
const resolvedTarget =
deliver && isDeliveryProviderKnown && deliveryProvider
deliver && isDeliveryChannelKnown && deliveryChannel
? resolveOutboundTarget({
provider: deliveryProvider,
channel: deliveryChannel,
to: opts.to,
cfg,
accountId:
@@ -670,8 +668,8 @@ export async function agentCommand(
const deliveryTarget = resolvedTarget?.ok ? resolvedTarget.to : undefined;
if (deliver) {
if (!isDeliveryProviderKnown) {
const err = new Error(`Unknown provider: ${deliveryProvider}`);
if (!isDeliveryChannelKnown) {
const err = new Error(`Unknown channel: ${deliveryChannel}`);
if (!bestEffortDeliver) throw err;
logDeliveryError(err);
} else if (resolvedTarget && !resolvedTarget.ok) {
@@ -715,13 +713,13 @@ export async function agentCommand(
}
if (
deliver &&
deliveryProvider &&
!isInternalMessageProvider(deliveryProvider)
deliveryChannel &&
!isInternalMessageChannel(deliveryChannel)
) {
if (deliveryTarget) {
await deliverOutboundPayloads({
cfg,
provider: deliveryProvider,
channel: deliveryChannel,
to: deliveryTarget,
payloads: deliveryPayloads,
bestEffort: bestEffortDeliver,

View File

@@ -34,9 +34,9 @@ describe("agents helpers", () => {
bindings: [
{
agentId: "work",
match: { provider: "whatsapp", accountId: "biz" },
match: { channel: "whatsapp", accountId: "biz" },
},
{ agentId: "main", match: { provider: "telegram" } },
{ agentId: "main", match: { channel: "telegram" } },
],
};
@@ -86,7 +86,7 @@ describe("agents helpers", () => {
bindings: [
{
agentId: "main",
match: { provider: "whatsapp", accountId: "default" },
match: { channel: "whatsapp", accountId: "default" },
},
],
};
@@ -94,15 +94,15 @@ describe("agents helpers", () => {
const result = applyAgentBindings(cfg, [
{
agentId: "main",
match: { provider: "whatsapp", accountId: "default" },
match: { channel: "whatsapp", accountId: "default" },
},
{
agentId: "work",
match: { provider: "whatsapp", accountId: "default" },
match: { channel: "whatsapp", accountId: "default" },
},
{
agentId: "work",
match: { provider: "telegram" },
match: { channel: "telegram" },
},
]);
@@ -121,8 +121,8 @@ describe("agents helpers", () => {
],
},
bindings: [
{ agentId: "work", match: { provider: "whatsapp" } },
{ agentId: "home", match: { provider: "telegram" } },
{ agentId: "work", match: { channel: "whatsapp" } },
{ agentId: "home", match: { channel: "telegram" } },
],
tools: {
agentToAgent: { enabled: true, allow: ["work", "home"] },

View File

@@ -7,6 +7,16 @@ import {
} from "../agents/agent-scope.js";
import { ensureAuthProfileStore } from "../agents/auth-profiles.js";
import { DEFAULT_IDENTITY_FILENAME } from "../agents/workspace.js";
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import {
getChannelPlugin,
listChannelPlugins,
} from "../channels/plugins/index.js";
import {
type ChatChannelId,
getChatChannelMeta,
normalizeChatChannelId,
} from "../channels/registry.js";
import type { ClawdbotConfig } from "../config/config.js";
import {
CONFIG_PATH_CLAWDBOT,
@@ -14,16 +24,7 @@ import {
writeConfigFile,
} from "../config/config.js";
import { resolveSessionTranscriptsDirForAgent } from "../config/sessions.js";
import { resolveProviderDefaultAccountId } from "../providers/plugins/helpers.js";
import {
getProviderPlugin,
listProviderPlugins,
} from "../providers/plugins/index.js";
import {
type ChatProviderId,
getChatProviderMeta,
normalizeChatProviderId,
} from "../providers/registry.js";
import type { AgentBinding } from "../config/types.js";
import {
DEFAULT_ACCOUNT_ID,
DEFAULT_AGENT_ID,
@@ -36,9 +37,9 @@ import { createClackPrompter } from "../wizard/clack-prompter.js";
import { WizardCancelledError } from "../wizard/prompts.js";
import { applyAuthChoice, warnIfModelConfigLooksOff } from "./auth-choice.js";
import { promptAuthChoiceGrouped } from "./auth-choice-prompt.js";
import { setupChannels } from "./onboard-channels.js";
import { ensureWorkspaceAndSessions, moveToTrash } from "./onboard-helpers.js";
import { setupProviders } from "./onboard-providers.js";
import type { ProviderChoice } from "./onboard-types.js";
import type { ChannelChoice } from "./onboard-types.js";
type AgentsListOptions = {
json?: boolean;
@@ -77,17 +78,6 @@ export type AgentSummary = {
isDefault: boolean;
};
type AgentBinding = {
agentId: string;
match: {
provider: string;
accountId?: string;
peer?: { kind: "dm" | "group" | "channel"; id: string };
guildId?: string;
teamId?: string;
};
};
type AgentEntry = NonNullable<
NonNullable<ClawdbotConfig["agents"]>["list"]
>[number];
@@ -100,7 +90,7 @@ type AgentIdentity = {
};
type ProviderAccountStatus = {
provider: ChatProviderId;
provider: ChatChannelId;
accountId: string;
name?: string;
state:
@@ -276,7 +266,7 @@ export function applyAgentConfig(
function bindingMatchKey(match: AgentBinding["match"]) {
const accountId = match.accountId?.trim() || DEFAULT_ACCOUNT_ID;
return [
match.provider,
match.channel,
accountId,
match.peer?.kind ?? "",
match.peer?.id ?? "",
@@ -438,16 +428,16 @@ function formatSummary(summary: AgentSummary) {
return lines.join("\n");
}
function providerAccountKey(provider: ChatProviderId, accountId?: string) {
function providerAccountKey(provider: ChatChannelId, accountId?: string) {
return `${provider}:${accountId ?? DEFAULT_ACCOUNT_ID}`;
}
function formatProviderAccountLabel(params: {
provider: ChatProviderId;
function formatChannelAccountLabel(params: {
provider: ChatChannelId;
accountId: string;
name?: string;
}): string {
const label = getChatProviderMeta(params.provider).label;
const label = getChatChannelMeta(params.provider).label;
const account = params.name?.trim()
? `${params.accountId} (${params.name.trim()})`
: params.accountId;
@@ -467,7 +457,7 @@ async function buildProviderStatusIndex(
): Promise<Map<string, ProviderAccountStatus>> {
const map = new Map<string, ProviderAccountStatus>();
for (const plugin of listProviderPlugins()) {
for (const plugin of listChannelPlugins()) {
const accountIds = plugin.config.listAccountIds(cfg);
for (const accountId of accountIds) {
const account = plugin.config.resolveAccount(cfg, accountId);
@@ -514,18 +504,18 @@ async function buildProviderStatusIndex(
function resolveDefaultAccountId(
cfg: ClawdbotConfig,
provider: ChatProviderId,
provider: ChatChannelId,
): string {
const plugin = getProviderPlugin(provider);
const plugin = getChannelPlugin(provider);
if (!plugin) return DEFAULT_ACCOUNT_ID;
return resolveProviderDefaultAccountId({ plugin, cfg });
return resolveChannelDefaultAccountId({ plugin, cfg });
}
function shouldShowProviderEntry(
entry: ProviderAccountStatus,
cfg: ClawdbotConfig,
): boolean {
const plugin = getProviderPlugin(entry.provider);
const plugin = getChannelPlugin(entry.provider);
if (!plugin) return Boolean(entry.configured);
if (plugin.meta.showConfigured === false) {
const providerConfig = (cfg as Record<string, unknown>)[plugin.id];
@@ -535,7 +525,7 @@ function shouldShowProviderEntry(
}
function formatProviderEntry(entry: ProviderAccountStatus): string {
const label = formatProviderAccountLabel({
const label = formatChannelAccountLabel({
provider: entry.provider,
accountId: entry.accountId,
name: entry.name,
@@ -550,14 +540,14 @@ function summarizeBindings(
if (bindings.length === 0) return [];
const seen = new Map<string, string>();
for (const binding of bindings) {
const provider = normalizeChatProviderId(binding.match.provider);
if (!provider) continue;
const channel = normalizeChatChannelId(binding.match.channel);
if (!channel) continue;
const accountId =
binding.match.accountId ?? resolveDefaultAccountId(cfg, provider);
const key = providerAccountKey(provider, accountId);
binding.match.accountId ?? resolveDefaultAccountId(cfg, channel);
const key = providerAccountKey(channel, accountId);
if (!seen.has(key)) {
const label = formatProviderAccountLabel({
provider,
const label = formatChannelAccountLabel({
provider: channel,
accountId,
});
seen.set(key, label);
@@ -628,11 +618,11 @@ export async function agentsListCommand(
if (bindings.length > 0) {
const seen = new Set<string>();
for (const binding of bindings) {
const provider = normalizeChatProviderId(binding.match.provider);
if (!provider) continue;
const channel = normalizeChatChannelId(binding.match.channel);
if (!channel) continue;
const accountId =
binding.match.accountId ?? resolveDefaultAccountId(cfg, provider);
const key = providerAccountKey(provider, accountId);
binding.match.accountId ?? resolveDefaultAccountId(cfg, channel);
const key = providerAccountKey(channel, accountId);
if (seen.has(key)) continue;
seen.add(key);
const status = providerStatus.get(key);
@@ -640,7 +630,7 @@ export async function agentsListCommand(
providerLines.push(formatProviderEntry(status));
} else {
providerLines.push(
`${formatProviderAccountLabel({ provider, accountId })}: unknown`,
`${formatChannelAccountLabel({ provider: channel, accountId })}: unknown`,
);
}
}
@@ -663,17 +653,17 @@ export async function agentsListCommand(
const lines = ["Agents:", ...summaries.map(formatSummary)];
lines.push(
"Routing rules map provider/account/peer to an agent. Use --bindings for full rules.",
"Routing rules map channel/account/peer to an agent. Use --bindings for full rules.",
);
lines.push(
"Provider status reflects local config/creds. For live health: clawdbot providers status --probe.",
"Channel status reflects local config/creds. For live health: clawdbot channels status --probe.",
);
runtime.log(lines.join("\n"));
}
function describeBinding(binding: AgentBinding) {
const match = binding.match;
const parts = [match.provider];
const parts = [match.channel];
if (match.accountId) parts.push(`accountId=${match.accountId}`);
if (match.peer) parts.push(`peer=${match.peer.kind}:${match.peer.id}`);
if (match.guildId) parts.push(`guild=${match.guildId}`);
@@ -681,23 +671,23 @@ function describeBinding(binding: AgentBinding) {
return parts.join(" ");
}
function buildProviderBindings(params: {
function buildChannelBindings(params: {
agentId: string;
selection: ProviderChoice[];
selection: ChannelChoice[];
config: ClawdbotConfig;
accountIds?: Partial<Record<ProviderChoice, string>>;
accountIds?: Partial<Record<ChannelChoice, string>>;
}): AgentBinding[] {
const bindings: AgentBinding[] = [];
const agentId = normalizeAgentId(params.agentId);
for (const provider of params.selection) {
const match: AgentBinding["match"] = { provider };
const accountId = params.accountIds?.[provider]?.trim();
for (const channel of params.selection) {
const match: AgentBinding["match"] = { channel };
const accountId = params.accountIds?.[channel]?.trim();
if (accountId) {
match.accountId = accountId;
} else {
const plugin = getProviderPlugin(provider);
const plugin = getChannelPlugin(channel);
if (plugin?.meta.forceAccountBinding) {
match.accountId = resolveDefaultAccountId(params.config, provider);
match.accountId = resolveDefaultAccountId(params.config, channel);
}
}
bindings.push({ agentId, match });
@@ -717,10 +707,10 @@ function parseBindingSpecs(params: {
for (const raw of specs) {
const trimmed = raw?.trim();
if (!trimmed) continue;
const [providerRaw, accountRaw] = trimmed.split(":", 2);
const provider = normalizeChatProviderId(providerRaw);
if (!provider) {
errors.push(`Unknown provider "${providerRaw}".`);
const [channelRaw, accountRaw] = trimmed.split(":", 2);
const channel = normalizeChatChannelId(channelRaw);
if (!channel) {
errors.push(`Unknown channel "${channelRaw}".`);
continue;
}
let accountId = accountRaw?.trim();
@@ -729,12 +719,12 @@ function parseBindingSpecs(params: {
continue;
}
if (!accountId) {
const plugin = getProviderPlugin(provider);
const plugin = getChannelPlugin(channel);
if (plugin?.meta.forceAccountBinding) {
accountId = resolveDefaultAccountId(params.config, provider);
accountId = resolveDefaultAccountId(params.config, channel);
}
}
const match: AgentBinding["match"] = { provider };
const match: AgentBinding["match"] = { channel };
if (accountId) match.accountId = accountId;
bindings.push({ agentId, match });
}
@@ -958,30 +948,30 @@ export async function agentsAddCommand(
agentDir,
});
let selection: ProviderChoice[] = [];
const providerAccountIds: Partial<Record<ProviderChoice, string>> = {};
nextConfig = await setupProviders(nextConfig, runtime, prompter, {
let selection: ChannelChoice[] = [];
const channelAccountIds: Partial<Record<ChannelChoice, string>> = {};
nextConfig = await setupChannels(nextConfig, runtime, prompter, {
allowSignalInstall: true,
onSelection: (value) => {
selection = value;
},
promptAccountIds: true,
onAccountId: (provider, accountId) => {
providerAccountIds[provider] = accountId;
onAccountId: (channel, accountId) => {
channelAccountIds[channel] = accountId;
},
});
if (selection.length > 0) {
const wantsBindings = await prompter.confirm({
message: "Route selected providers to this agent now? (bindings)",
message: "Route selected channels to this agent now? (bindings)",
initialValue: false,
});
if (wantsBindings) {
const desiredBindings = buildProviderBindings({
const desiredBindings = buildChannelBindings({
agentId,
selection,
config: nextConfig,
accountIds: providerAccountIds,
accountIds: channelAccountIds,
});
const result = applyAgentBindings(nextConfig, desiredBindings);
nextConfig = result.config;

View File

@@ -30,11 +30,11 @@ vi.mock("../agents/auth-profiles.js", async (importOriginal) => {
});
import {
formatGatewayProvidersStatusLines,
providersAddCommand,
providersListCommand,
providersRemoveCommand,
} from "./providers.js";
channelsAddCommand,
channelsListCommand,
channelsRemoveCommand,
formatGatewayChannelsStatusLines,
} from "./channels.js";
const runtime: RuntimeEnv = {
log: vi.fn(),
@@ -53,7 +53,7 @@ const baseSnapshot = {
legacyIssues: [],
};
describe("providers command", () => {
describe("channels command", () => {
beforeEach(() => {
configMocks.readConfigFileSnapshot.mockReset();
configMocks.writeConfigFile.mockClear();
@@ -69,28 +69,30 @@ describe("providers command", () => {
it("adds a non-default telegram account", async () => {
configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseSnapshot });
await providersAddCommand(
{ provider: "telegram", account: "alerts", token: "123:abc" },
await channelsAddCommand(
{ channel: "telegram", account: "alerts", token: "123:abc" },
runtime,
{ hasFlags: true },
);
expect(configMocks.writeConfigFile).toHaveBeenCalledTimes(1);
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
telegram?: {
enabled?: boolean;
accounts?: Record<string, { botToken?: string }>;
channels?: {
telegram?: {
enabled?: boolean;
accounts?: Record<string, { botToken?: string }>;
};
};
};
expect(next.telegram?.enabled).toBe(true);
expect(next.telegram?.accounts?.alerts?.botToken).toBe("123:abc");
expect(next.channels?.telegram?.enabled).toBe(true);
expect(next.channels?.telegram?.accounts?.alerts?.botToken).toBe("123:abc");
});
it("adds a default slack account with tokens", async () => {
configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseSnapshot });
await providersAddCommand(
await channelsAddCommand(
{
provider: "slack",
channel: "slack",
account: "default",
botToken: "xoxb-1",
appToken: "xapp-1",
@@ -101,69 +103,81 @@ describe("providers command", () => {
expect(configMocks.writeConfigFile).toHaveBeenCalledTimes(1);
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
slack?: { enabled?: boolean; botToken?: string; appToken?: string };
channels?: {
slack?: { enabled?: boolean; botToken?: string; appToken?: string };
};
};
expect(next.slack?.enabled).toBe(true);
expect(next.slack?.botToken).toBe("xoxb-1");
expect(next.slack?.appToken).toBe("xapp-1");
expect(next.channels?.slack?.enabled).toBe(true);
expect(next.channels?.slack?.botToken).toBe("xoxb-1");
expect(next.channels?.slack?.appToken).toBe("xapp-1");
});
it("deletes a non-default discord account", async () => {
configMocks.readConfigFileSnapshot.mockResolvedValue({
...baseSnapshot,
config: {
discord: {
accounts: {
default: { token: "d0" },
work: { token: "d1" },
channels: {
discord: {
accounts: {
default: { token: "d0" },
work: { token: "d1" },
},
},
},
},
});
await providersRemoveCommand(
{ provider: "discord", account: "work", delete: true },
await channelsRemoveCommand(
{ channel: "discord", account: "work", delete: true },
runtime,
{ hasFlags: true },
);
expect(configMocks.writeConfigFile).toHaveBeenCalledTimes(1);
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
discord?: { accounts?: Record<string, { token?: string }> };
channels?: {
discord?: { accounts?: Record<string, { token?: string }> };
};
};
expect(next.discord?.accounts?.work).toBeUndefined();
expect(next.discord?.accounts?.default?.token).toBe("d0");
expect(next.channels?.discord?.accounts?.work).toBeUndefined();
expect(next.channels?.discord?.accounts?.default?.token).toBe("d0");
});
it("adds a named WhatsApp account", async () => {
configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseSnapshot });
await providersAddCommand(
{ provider: "whatsapp", account: "family", name: "Family Phone" },
await channelsAddCommand(
{ channel: "whatsapp", account: "family", name: "Family Phone" },
runtime,
{ hasFlags: true },
);
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
whatsapp?: { accounts?: Record<string, { name?: string }> };
channels?: {
whatsapp?: { accounts?: Record<string, { name?: string }> };
};
};
expect(next.whatsapp?.accounts?.family?.name).toBe("Family Phone");
expect(next.channels?.whatsapp?.accounts?.family?.name).toBe(
"Family Phone",
);
});
it("adds a second signal account with a distinct name", async () => {
configMocks.readConfigFileSnapshot.mockResolvedValue({
...baseSnapshot,
config: {
signal: {
accounts: {
default: { account: "+15555550111", name: "Primary" },
channels: {
signal: {
accounts: {
default: { account: "+15555550111", name: "Primary" },
},
},
},
},
});
await providersAddCommand(
await channelsAddCommand(
{
provider: "signal",
channel: "signal",
account: "lab",
name: "Lab",
signalNumber: "+15555550123",
@@ -173,20 +187,22 @@ describe("providers command", () => {
);
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
signal?: {
accounts?: Record<string, { account?: string; name?: string }>;
channels?: {
signal?: {
accounts?: Record<string, { account?: string; name?: string }>;
};
};
};
expect(next.signal?.accounts?.lab?.account).toBe("+15555550123");
expect(next.signal?.accounts?.lab?.name).toBe("Lab");
expect(next.signal?.accounts?.default?.name).toBe("Primary");
expect(next.channels?.signal?.accounts?.lab?.account).toBe("+15555550123");
expect(next.channels?.signal?.accounts?.lab?.name).toBe("Lab");
expect(next.channels?.signal?.accounts?.default?.name).toBe("Primary");
});
it("disables a default provider account when remove has no delete flag", async () => {
configMocks.readConfigFileSnapshot.mockResolvedValue({
...baseSnapshot,
config: {
discord: { token: "d0", enabled: true },
channels: { discord: { token: "d0", enabled: true } },
},
});
@@ -196,16 +212,16 @@ describe("providers command", () => {
.spyOn(prompterModule, "createClackPrompter")
.mockReturnValue(prompt as never);
await providersRemoveCommand(
{ provider: "discord", account: "default" },
await channelsRemoveCommand(
{ channel: "discord", account: "default" },
runtime,
{ hasFlags: true },
);
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
discord?: { enabled?: boolean };
channels?: { discord?: { enabled?: boolean } };
};
expect(next.discord?.enabled).toBe(false);
expect(next.channels?.discord?.enabled).toBe(false);
promptSpy.mockRestore();
});
@@ -236,7 +252,7 @@ describe("providers command", () => {
},
});
await providersListCommand({ json: true, usage: false }, runtime);
await channelsListCommand({ json: true, usage: false }, runtime);
const payload = JSON.parse(
String(runtime.log.mock.calls[0]?.[0] ?? "{}"),
) as { auth?: Array<{ id: string }> };
@@ -249,18 +265,20 @@ describe("providers command", () => {
configMocks.readConfigFileSnapshot.mockResolvedValue({
...baseSnapshot,
config: {
telegram: {
name: "Legacy Name",
accounts: {
work: { botToken: "t0" },
channels: {
telegram: {
name: "Legacy Name",
accounts: {
work: { botToken: "t0" },
},
},
},
},
});
await providersAddCommand(
await channelsAddCommand(
{
provider: "telegram",
channel: "telegram",
account: "default",
token: "123:abc",
name: "Primary Bot",
@@ -270,46 +288,54 @@ describe("providers command", () => {
);
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
telegram?: {
name?: string;
accounts?: Record<string, { botToken?: string; name?: string }>;
channels?: {
telegram?: {
name?: string;
accounts?: Record<string, { botToken?: string; name?: string }>;
};
};
};
expect(next.telegram?.name).toBeUndefined();
expect(next.telegram?.accounts?.default?.name).toBe("Primary Bot");
expect(next.channels?.telegram?.name).toBeUndefined();
expect(next.channels?.telegram?.accounts?.default?.name).toBe(
"Primary Bot",
);
});
it("migrates base names when adding non-default accounts", async () => {
configMocks.readConfigFileSnapshot.mockResolvedValue({
...baseSnapshot,
config: {
discord: {
name: "Primary Bot",
token: "d0",
channels: {
discord: {
name: "Primary Bot",
token: "d0",
},
},
},
});
await providersAddCommand(
{ provider: "discord", account: "work", token: "d1" },
await channelsAddCommand(
{ channel: "discord", account: "work", token: "d1" },
runtime,
{ hasFlags: true },
);
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
discord?: {
name?: string;
accounts?: Record<string, { name?: string; token?: string }>;
channels?: {
discord?: {
name?: string;
accounts?: Record<string, { name?: string; token?: string }>;
};
};
};
expect(next.discord?.name).toBeUndefined();
expect(next.discord?.accounts?.default?.name).toBe("Primary Bot");
expect(next.discord?.accounts?.work?.token).toBe("d1");
expect(next.channels?.discord?.name).toBeUndefined();
expect(next.channels?.discord?.accounts?.default?.name).toBe("Primary Bot");
expect(next.channels?.discord?.accounts?.work?.token).toBe("d1");
});
it("formats gateway provider status lines in registry order", () => {
const lines = formatGatewayProvidersStatusLines({
providerAccounts: {
it("formats gateway channel status lines in registry order", () => {
const lines = formatGatewayChannelsStatusLines({
channelAccounts: {
telegram: [{ accountId: "default", configured: true }],
whatsapp: [{ accountId: "default", linked: true }],
},
@@ -326,9 +352,9 @@ describe("providers command", () => {
expect(telegramIndex).toBeLessThan(whatsappIndex);
});
it("surfaces Discord privileged intent issues in providers status output", () => {
const lines = formatGatewayProvidersStatusLines({
providerAccounts: {
it("surfaces Discord privileged intent issues in channels status output", () => {
const lines = formatGatewayChannelsStatusLines({
channelAccounts: {
discord: [
{
accountId: "default",
@@ -344,9 +370,9 @@ describe("providers command", () => {
expect(lines.join("\n")).toMatch(/Run: clawdbot doctor/);
});
it("surfaces Discord permission audit issues in providers status output", () => {
const lines = formatGatewayProvidersStatusLines({
providerAccounts: {
it("surfaces Discord permission audit issues in channels status output", () => {
const lines = formatGatewayChannelsStatusLines({
channelAccounts: {
discord: [
{
accountId: "default",
@@ -372,8 +398,8 @@ describe("providers command", () => {
});
it("surfaces Telegram privacy-mode hints when allowUnmentionedGroups is enabled", () => {
const lines = formatGatewayProvidersStatusLines({
providerAccounts: {
const lines = formatGatewayChannelsStatusLines({
channelAccounts: {
telegram: [
{
accountId: "default",
@@ -388,9 +414,9 @@ describe("providers command", () => {
expect(lines.join("\n")).toMatch(/Telegram Bot API privacy mode/i);
});
it("surfaces Telegram group membership audit issues in providers status output", () => {
const lines = formatGatewayProvidersStatusLines({
providerAccounts: {
it("surfaces Telegram group membership audit issues in channels status output", () => {
const lines = formatGatewayChannelsStatusLines({
channelAccounts: {
telegram: [
{
accountId: "default",
@@ -418,16 +444,16 @@ describe("providers command", () => {
});
it("surfaces WhatsApp auth/runtime hints when unlinked or disconnected", () => {
const unlinked = formatGatewayProvidersStatusLines({
providerAccounts: {
const unlinked = formatGatewayChannelsStatusLines({
channelAccounts: {
whatsapp: [{ accountId: "default", enabled: true, linked: false }],
},
});
expect(unlinked.join("\n")).toMatch(/WhatsApp/i);
expect(unlinked.join("\n")).toMatch(/Not linked/i);
const disconnected = formatGatewayProvidersStatusLines({
providerAccounts: {
const disconnected = formatGatewayChannelsStatusLines({
channelAccounts: {
whatsapp: [
{
accountId: "default",
@@ -444,9 +470,9 @@ describe("providers command", () => {
expect(disconnected.join("\n")).toMatch(/disconnected/i);
});
it("surfaces Signal runtime errors in providers status output", () => {
const lines = formatGatewayProvidersStatusLines({
providerAccounts: {
it("surfaces Signal runtime errors in channels status output", () => {
const lines = formatGatewayChannelsStatusLines({
channelAccounts: {
signal: [
{
accountId: "default",
@@ -460,12 +486,12 @@ describe("providers command", () => {
});
expect(lines.join("\n")).toMatch(/Warnings:/);
expect(lines.join("\n")).toMatch(/signal/i);
expect(lines.join("\n")).toMatch(/Provider error/i);
expect(lines.join("\n")).toMatch(/Channel error/i);
});
it("surfaces iMessage runtime errors in providers status output", () => {
const lines = formatGatewayProvidersStatusLines({
providerAccounts: {
it("surfaces iMessage runtime errors in channels status output", () => {
const lines = formatGatewayChannelsStatusLines({
channelAccounts: {
imessage: [
{
accountId: "default",
@@ -479,6 +505,6 @@ describe("providers command", () => {
});
expect(lines.join("\n")).toMatch(/Warnings:/);
expect(lines.join("\n")).toMatch(/imessage/i);
expect(lines.join("\n")).toMatch(/Provider error/i);
expect(lines.join("\n")).toMatch(/Channel error/i);
});
});

13
src/commands/channels.ts Normal file
View File

@@ -0,0 +1,13 @@
export type { ChannelsAddOptions } from "./channels/add.js";
export { channelsAddCommand } from "./channels/add.js";
export type { ChannelsListOptions } from "./channels/list.js";
export { channelsListCommand } from "./channels/list.js";
export type { ChannelsLogsOptions } from "./channels/logs.js";
export { channelsLogsCommand } from "./channels/logs.js";
export type { ChannelsRemoveOptions } from "./channels/remove.js";
export { channelsRemoveCommand } from "./channels/remove.js";
export type { ChannelsStatusOptions } from "./channels/status.js";
export {
channelsStatusCommand,
formatGatewayChannelsStatusLines,
} from "./channels/status.js";

View File

@@ -1,30 +1,30 @@
import type { ClawdbotConfig } from "../../config/config.js";
import { getProviderPlugin } from "../../providers/plugins/index.js";
import { getChannelPlugin } from "../../channels/plugins/index.js";
import type {
ProviderId,
ProviderSetupInput,
} from "../../providers/plugins/types.js";
ChannelId,
ChannelSetupInput,
} from "../../channels/plugins/types.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { normalizeAccountId } from "../../routing/session-key.js";
type ChatProvider = ProviderId;
type ChatChannel = ChannelId;
export function applyAccountName(params: {
cfg: ClawdbotConfig;
provider: ChatProvider;
channel: ChatChannel;
accountId: string;
name?: string;
}): ClawdbotConfig {
const accountId = normalizeAccountId(params.accountId);
const plugin = getProviderPlugin(params.provider);
const plugin = getChannelPlugin(params.channel);
const apply = plugin?.setup?.applyAccountName;
return apply
? apply({ cfg: params.cfg, accountId, name: params.name })
: params.cfg;
}
export function applyProviderAccountConfig(params: {
export function applyChannelAccountConfig(params: {
cfg: ClawdbotConfig;
provider: ChatProvider;
channel: ChatChannel;
accountId: string;
name?: string;
token?: string;
@@ -43,10 +43,10 @@ export function applyProviderAccountConfig(params: {
useEnv?: boolean;
}): ClawdbotConfig {
const accountId = normalizeAccountId(params.accountId);
const plugin = getProviderPlugin(params.provider);
const plugin = getChannelPlugin(params.channel);
const apply = plugin?.setup?.applyAccountConfig;
if (!apply) return params.cfg;
const input: ProviderSetupInput = {
const input: ChannelSetupInput = {
name: params.name,
token: params.token,
tokenFile: params.tokenFile,

View File

@@ -1,29 +1,22 @@
import { writeConfigFile } from "../../config/config.js";
import {
getProviderPlugin,
normalizeProviderId,
} from "../../providers/plugins/index.js";
import type { ProviderId } from "../../providers/plugins/types.js";
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 { setupProviders } from "../onboard-providers.js";
import type { ProviderChoice } from "../onboard-types.js";
import {
applyAccountName,
applyProviderAccountConfig,
} from "./add-mutators.js";
import {
providerLabel,
requireValidConfig,
shouldUseWizard,
} from "./shared.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 ProvidersAddOptions = {
provider?: string;
export type ChannelsAddOptions = {
channel?: string;
account?: string;
name?: string;
token?: string;
@@ -42,8 +35,8 @@ export type ProvidersAddOptions = {
useEnv?: boolean;
};
export async function providersAddCommand(
opts: ProvidersAddOptions,
export async function channelsAddCommand(
opts: ChannelsAddOptions,
runtime: RuntimeEnv = defaultRuntime,
params?: { hasFlags?: boolean },
) {
@@ -53,22 +46,22 @@ export async function providersAddCommand(
const useWizard = shouldUseWizard(params);
if (useWizard) {
const prompter = createClackPrompter();
let selection: ProviderChoice[] = [];
const accountIds: Partial<Record<ProviderChoice, string>> = {};
await prompter.intro("Provider setup");
let nextConfig = await setupProviders(cfg, runtime, prompter, {
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: (provider, accountId) => {
accountIds[provider] = accountId;
onAccountId: (channel, accountId) => {
accountIds[channel] = accountId;
},
});
if (selection.length === 0) {
await prompter.outro("No providers selected.");
await prompter.outro("No channels selected.");
return;
}
@@ -77,22 +70,22 @@ export async function providersAddCommand(
initialValue: false,
});
if (wantsNames) {
for (const provider of selection) {
const accountId = accountIds[provider] ?? DEFAULT_ACCOUNT_ID;
const plugin = getProviderPlugin(provider as ProviderId);
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: `${provider} account name (${accountId})`,
message: `${channel} account name (${accountId})`,
initialValue: existingName,
});
if (name?.trim()) {
nextConfig = applyAccountName({
cfg: nextConfig,
provider,
channel,
accountId,
name,
});
@@ -101,20 +94,20 @@ export async function providersAddCommand(
}
await writeConfigFile(nextConfig);
await prompter.outro("Providers updated.");
await prompter.outro("Channels updated.");
return;
}
const provider = normalizeProviderId(opts.provider);
if (!provider) {
runtime.error(`Unknown provider: ${String(opts.provider ?? "")}`);
const channel = normalizeChannelId(opts.channel);
if (!channel) {
runtime.error(`Unknown channel: ${String(opts.channel ?? "")}`);
runtime.exit(1);
return;
}
const plugin = getProviderPlugin(provider);
const plugin = getChannelPlugin(channel);
if (!plugin?.setup?.applyAccountConfig) {
runtime.error(`Provider ${provider} does not support add.`);
runtime.error(`Channel ${channel} does not support add.`);
runtime.exit(1);
return;
}
@@ -149,9 +142,9 @@ export async function providersAddCommand(
return;
}
const nextConfig = applyProviderAccountConfig({
const nextConfig = applyChannelAccountConfig({
cfg,
provider,
channel,
accountId,
name: opts.name,
token: opts.token,
@@ -171,5 +164,5 @@ export async function providersAddCommand(
});
await writeConfigFile(nextConfig);
runtime.log(`Added ${providerLabel(provider)} account "${accountId}".`);
runtime.log(`Added ${channelLabel(channel)} account "${accountId}".`);
}

View File

@@ -3,23 +3,23 @@ import {
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 { listProviderPlugins } from "../../providers/plugins/index.js";
import { buildProviderAccountSnapshot } from "../../providers/plugins/status.js";
import type {
ProviderAccountSnapshot,
ProviderPlugin,
} from "../../providers/plugins/types.js";
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
import { formatDocsLink } from "../../terminal/links.js";
import { theme } from "../../terminal/theme.js";
import { formatProviderAccountLabel, requireValidConfig } from "./shared.js";
import { formatChannelAccountLabel, requireValidConfig } from "./shared.js";
export type ProvidersListOptions = {
export type ChannelsListOptions = {
json?: boolean;
usage?: boolean;
};
@@ -52,20 +52,20 @@ function formatLinked(value: boolean): string {
return value ? theme.success("linked") : theme.warn("not linked");
}
function shouldShowConfigured(provider: ProviderPlugin): boolean {
return provider.meta.showConfigured !== false;
function shouldShowConfigured(channel: ChannelPlugin): boolean {
return channel.meta.showConfigured !== false;
}
function formatAccountLine(params: {
provider: ProviderPlugin;
snapshot: ProviderAccountSnapshot;
channel: ChannelPlugin;
snapshot: ChannelAccountSnapshot;
}): string {
const { provider, snapshot } = params;
const label = formatProviderAccountLabel({
provider: provider.id,
const { channel, snapshot } = params;
const label = formatChannelAccountLabel({
channel: channel.id,
accountId: snapshot.accountId,
name: snapshot.name,
providerStyle: theme.accent,
channelStyle: theme.accent,
accountStyle: theme.heading,
});
const bits: string[] = [];
@@ -73,7 +73,7 @@ function formatAccountLine(params: {
bits.push(formatLinked(snapshot.linked));
}
if (
shouldShowConfigured(provider) &&
shouldShowConfigured(channel) &&
typeof snapshot.configured === "boolean"
) {
bits.push(formatConfigured(snapshot.configured));
@@ -109,15 +109,15 @@ async function loadUsageWithProgress(
}
}
export async function providersListCommand(
opts: ProvidersListOptions,
export async function channelsListCommand(
opts: ChannelsListOptions,
runtime: RuntimeEnv = defaultRuntime,
) {
const cfg = await requireValidConfig(runtime);
if (!cfg) return;
const includeUsage = opts.usage !== false;
const plugins = listProviderPlugins();
const plugins = listChannelPlugins();
const authStore = loadAuthProfileStore();
const authProfiles = Object.entries(authStore.profiles).map(
@@ -142,20 +142,20 @@ export async function providersListCommand(
}
const lines: string[] = [];
lines.push(theme.heading("Chat providers:"));
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 buildProviderAccountSnapshot({
const snapshot = await buildChannelAccountSnapshot({
plugin,
cfg,
accountId,
});
lines.push(
formatAccountLine({
provider: plugin,
channel: plugin,
snapshot,
}),
);

View File

@@ -1,13 +1,12 @@
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 { listProviderPlugins } from "../../providers/plugins/index.js";
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
import { theme } from "../../terminal/theme.js";
export type ProvidersLogsOptions = {
provider?: string;
export type ChannelsLogsOptions = {
channel?: string;
lines?: string | number;
json?: boolean;
};
@@ -16,22 +15,22 @@ type LogLine = ReturnType<typeof parseLogLine>;
const DEFAULT_LIMIT = 200;
const MAX_BYTES = 1_000_000;
const PROVIDERS = new Set<string>([
...listProviderPlugins().map((plugin) => plugin.id),
const CHANNELS = new Set<string>([
...listChannelPlugins().map((plugin) => plugin.id),
"all",
]);
function parseProviderFilter(raw?: string) {
function parseChannelFilter(raw?: string) {
const trimmed = raw?.trim().toLowerCase();
if (!trimmed) return "all";
return PROVIDERS.has(trimmed) ? trimmed : "all";
return CHANNELS.has(trimmed) ? trimmed : "all";
}
function matchesProvider(line: NonNullable<LogLine>, provider: string) {
if (provider === "all") return true;
const needle = `gateway/providers/${provider}`;
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(provider)) return true;
if (line.module?.includes(channel)) return true;
return false;
}
@@ -61,11 +60,11 @@ async function readTailLines(file: string, limit: number): Promise<string[]> {
}
}
export async function providersLogsCommand(
opts: ProvidersLogsOptions,
export async function channelsLogsCommand(
opts: ChannelsLogsOptions,
runtime: RuntimeEnv = defaultRuntime,
) {
const provider = parseProviderFilter(opts.provider);
const channel = parseChannelFilter(opts.channel);
const limitRaw =
typeof opts.lines === "string" ? Number(opts.lines) : opts.lines;
const limit =
@@ -78,17 +77,17 @@ export async function providersLogsCommand(
const parsed = rawLines
.map(parseLogLine)
.filter((line): line is NonNullable<LogLine> => Boolean(line));
const filtered = parsed.filter((line) => matchesProvider(line, provider));
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, provider, lines }, null, 2));
runtime.log(JSON.stringify({ file, channel, lines }, null, 2));
return;
}
runtime.log(theme.info(`Log file: ${file}`));
if (provider !== "all") {
runtime.log(theme.info(`Provider: ${provider}`));
if (channel !== "all") {
runtime.log(theme.info(`Channel: ${channel}`));
}
if (lines.length === 0) {
runtime.log(theme.muted("No matching log lines."));

View File

@@ -1,10 +1,10 @@
import { type ClawdbotConfig, writeConfigFile } from "../../config/config.js";
import { resolveProviderDefaultAccountId } from "../../providers/plugins/helpers.js";
import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js";
import {
getProviderPlugin,
listProviderPlugins,
normalizeProviderId,
} from "../../providers/plugins/index.js";
getChannelPlugin,
listChannelPlugins,
normalizeChannelId,
} from "../../channels/plugins/index.js";
import { type ClawdbotConfig, writeConfigFile } from "../../config/config.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
@@ -12,26 +12,26 @@ import {
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
import { createClackPrompter } from "../../wizard/clack-prompter.js";
import {
type ChatProvider,
providerLabel,
type ChatChannel,
channelLabel,
requireValidConfig,
shouldUseWizard,
} from "./shared.js";
export type ProvidersRemoveOptions = {
provider?: string;
export type ChannelsRemoveOptions = {
channel?: string;
account?: string;
delete?: boolean;
};
function listAccountIds(cfg: ClawdbotConfig, provider: ChatProvider): string[] {
const plugin = getProviderPlugin(provider);
function listAccountIds(cfg: ClawdbotConfig, channel: ChatChannel): string[] {
const plugin = getChannelPlugin(channel);
if (!plugin) return [];
return plugin.config.listAccountIds(cfg);
}
export async function providersRemoveCommand(
opts: ProvidersRemoveOptions,
export async function channelsRemoveCommand(
opts: ChannelsRemoveOptions,
runtime: RuntimeEnv = defaultRuntime,
params?: { hasFlags?: boolean },
) {
@@ -40,22 +40,23 @@ export async function providersRemoveCommand(
const useWizard = shouldUseWizard(params);
const prompter = useWizard ? createClackPrompter() : null;
let provider = normalizeProviderId(opts.provider);
let channel: ChatChannel | null = normalizeChannelId(opts.channel);
let accountId = normalizeAccountId(opts.account);
const deleteConfig = Boolean(opts.delete);
if (useWizard && prompter) {
await prompter.intro("Remove provider account");
provider = (await prompter.select({
message: "Provider",
options: listProviderPlugins().map((plugin) => ({
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 ChatProvider;
})) as ChatChannel;
channel = selectedChannel;
accountId = await (async () => {
const ids = listAccountIds(cfg, provider);
const ids = listAccountIds(cfg, selectedChannel);
const choice = (await prompter.select({
message: "Account",
options: ids.map((id) => ({
@@ -68,7 +69,7 @@ export async function providersRemoveCommand(
})();
const wantsDisable = await prompter.confirm({
message: `Disable ${providerLabel(provider)} account "${accountId}"? (keeps config)`,
message: `Disable ${channelLabel(selectedChannel)} account "${accountId}"? (keeps config)`,
initialValue: true,
});
if (!wantsDisable) {
@@ -76,15 +77,15 @@ export async function providersRemoveCommand(
return;
}
} else {
if (!provider) {
runtime.error("Provider is required. Use --provider <name>.");
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 ${providerLabel(provider)} account "${accountId}"? (keeps config)`,
message: `Disable ${channelLabel(channel)} account "${accountId}"? (keeps config)`,
initialValue: true,
});
if (!ok) {
@@ -93,22 +94,22 @@ export async function providersRemoveCommand(
}
}
const plugin = getProviderPlugin(provider);
const plugin = getChannelPlugin(channel);
if (!plugin) {
runtime.error(`Unknown provider: ${provider}`);
runtime.error(`Unknown channel: ${channel}`);
runtime.exit(1);
return;
}
const resolvedAccountId =
normalizeAccountId(accountId) ??
resolveProviderDefaultAccountId({ plugin, cfg });
resolveChannelDefaultAccountId({ plugin, cfg });
const accountKey = resolvedAccountId || DEFAULT_ACCOUNT_ID;
let next = { ...cfg };
if (deleteConfig) {
if (!plugin.config.deleteAccount) {
runtime.error(`Provider ${provider} does not support delete.`);
runtime.error(`Channel ${channel} does not support delete.`);
runtime.exit(1);
return;
}
@@ -118,7 +119,7 @@ export async function providersRemoveCommand(
});
} else {
if (!plugin.config.setAccountEnabled) {
runtime.error(`Provider ${provider} does not support disable.`);
runtime.error(`Channel ${channel} does not support disable.`);
runtime.exit(1);
return;
}
@@ -133,14 +134,14 @@ export async function providersRemoveCommand(
if (useWizard && prompter) {
await prompter.outro(
deleteConfig
? `Deleted ${providerLabel(provider)} account "${accountKey}".`
: `Disabled ${providerLabel(provider)} account "${accountKey}".`,
? `Deleted ${channelLabel(channel)} account "${accountKey}".`
: `Disabled ${channelLabel(channel)} account "${accountKey}".`,
);
} else {
runtime.log(
deleteConfig
? `Deleted ${providerLabel(provider)} account "${accountKey}".`
: `Disabled ${providerLabel(provider)} account "${accountKey}".`,
? `Deleted ${channelLabel(channel)} account "${accountKey}".`
: `Disabled ${channelLabel(channel)} account "${accountKey}".`,
);
}
}

View File

@@ -1,15 +1,15 @@
import {
type ChannelId,
getChannelPlugin,
} from "../../channels/plugins/index.js";
import {
type ClawdbotConfig,
readConfigFileSnapshot,
} from "../../config/config.js";
import {
getProviderPlugin,
type ProviderId,
} from "../../providers/plugins/index.js";
import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js";
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
export type ChatProvider = ProviderId;
export type ChatChannel = ChannelId;
export async function requireValidConfig(
runtime: RuntimeEnv = defaultRuntime,
@@ -39,30 +39,30 @@ export function formatAccountLabel(params: {
return base;
}
export const providerLabel = (provider: ChatProvider) => {
const plugin = getProviderPlugin(provider);
return plugin?.meta.label ?? provider;
export const channelLabel = (channel: ChatChannel) => {
const plugin = getChannelPlugin(channel);
return plugin?.meta.label ?? channel;
};
export function formatProviderAccountLabel(params: {
provider: ChatProvider;
export function formatChannelAccountLabel(params: {
channel: ChatChannel;
accountId: string;
name?: string;
providerStyle?: (value: string) => string;
channelStyle?: (value: string) => string;
accountStyle?: (value: string) => string;
}): string {
const providerText = providerLabel(params.provider);
const channelText = channelLabel(params.channel);
const accountText = formatAccountLabel({
accountId: params.accountId,
name: params.name,
});
const styledProvider = params.providerStyle
? params.providerStyle(providerText)
: providerText;
const styledChannel = params.channelStyle
? params.channelStyle(channelText)
: channelText;
const styledAccount = params.accountStyle
? params.accountStyle(accountText)
: accountText;
return `${styledProvider} ${styledAccount}`;
return `${styledChannel} ${styledAccount}`;
}
export function shouldUseWizard(params?: { hasFlags?: boolean }) {

View File

@@ -1,36 +1,36 @@
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/provider-summary.js";
import { collectProvidersStatusIssues } from "../../infra/providers-status-issues.js";
import { listProviderPlugins } from "../../providers/plugins/index.js";
import { buildProviderAccountSnapshot } from "../../providers/plugins/status.js";
import type { ProviderAccountSnapshot } from "../../providers/plugins/types.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 ChatProvider,
formatProviderAccountLabel,
type ChatChannel,
formatChannelAccountLabel,
requireValidConfig,
} from "./shared.js";
export type ProvidersStatusOptions = {
export type ChannelsStatusOptions = {
json?: boolean;
probe?: boolean;
timeout?: string;
};
export function formatGatewayProvidersStatusLines(
export function formatGatewayChannelsStatusLines(
payload: Record<string, unknown>,
): string[] {
const lines: string[] = [];
lines.push(theme.success("Gateway reachable."));
const accountLines = (
provider: ChatProvider,
provider: ChatChannel,
accounts: Array<Record<string, unknown>>,
) =>
accounts.map((account) => {
@@ -117,23 +117,23 @@ export function formatGatewayProvidersStatusLines(
const accountId =
typeof account.accountId === "string" ? account.accountId : "default";
const name = typeof account.name === "string" ? account.name.trim() : "";
const labelText = formatProviderAccountLabel({
provider,
const labelText = formatChannelAccountLabel({
channel: provider,
accountId,
name: name || undefined,
});
return `- ${labelText}: ${bits.join(", ")}`;
});
const plugins = listProviderPlugins();
const accountsByProvider = payload.providerAccounts as
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 = accountsByProvider?.[plugin.id];
const raw = accountsByChannel?.[plugin.id];
if (Array.isArray(raw)) {
accountPayloads[plugin.id] = raw as Array<Record<string, unknown>>;
}
@@ -142,17 +142,17 @@ export function formatGatewayProvidersStatusLines(
for (const plugin of plugins) {
const accounts = accountPayloads[plugin.id];
if (accounts && accounts.length > 0) {
lines.push(...accountLines(plugin.id as ChatProvider, accounts));
lines.push(...accountLines(plugin.id as ChatChannel, accounts));
}
}
lines.push("");
const issues = collectProvidersStatusIssues(payload);
const issues = collectChannelStatusIssues(payload);
if (issues.length > 0) {
lines.push(theme.warn("Warnings:"));
for (const issue of issues) {
lines.push(
`- ${issue.provider} ${issue.accountId}: ${issue.message}${issue.fix ? ` (${issue.fix})` : ""}`,
`- ${issue.channel} ${issue.accountId}: ${issue.message}${issue.fix ? ` (${issue.fix})` : ""}`,
);
}
lines.push(`- Run: clawdbot doctor`);
@@ -164,7 +164,7 @@ export function formatGatewayProvidersStatusLines(
return lines;
}
async function formatConfigProvidersStatusLines(
async function formatConfigChannelsStatusLines(
cfg: ClawdbotConfig,
meta: { path?: string; mode?: "local" | "remote" },
): Promise<string[]> {
@@ -179,7 +179,7 @@ async function formatConfigProvidersStatusLines(
if (meta.path || meta.mode) lines.push("");
const accountLines = (
provider: ChatProvider,
provider: ChatChannel,
accounts: Array<Record<string, unknown>>,
) =>
accounts.map((account) => {
@@ -217,21 +217,21 @@ async function formatConfigProvidersStatusLines(
const accountId =
typeof account.accountId === "string" ? account.accountId : "default";
const name = typeof account.name === "string" ? account.name.trim() : "";
const labelText = formatProviderAccountLabel({
provider,
const labelText = formatChannelAccountLabel({
channel: provider,
accountId,
name: name || undefined,
});
return `- ${labelText}: ${bits.join(", ")}`;
});
const plugins = listProviderPlugins();
const plugins = listChannelPlugins();
for (const plugin of plugins) {
const accountIds = plugin.config.listAccountIds(cfg);
if (!accountIds.length) continue;
const snapshots: ProviderAccountSnapshot[] = [];
const snapshots: ChannelAccountSnapshot[] = [];
for (const accountId of accountIds) {
const snapshot = await buildProviderAccountSnapshot({
const snapshot = await buildChannelAccountSnapshot({
plugin,
cfg,
accountId,
@@ -239,7 +239,7 @@ async function formatConfigProvidersStatusLines(
snapshots.push(snapshot);
}
if (snapshots.length > 0) {
lines.push(...accountLines(plugin.id as ChatProvider, snapshots));
lines.push(...accountLines(plugin.id as ChatChannel, snapshots));
}
}
@@ -250,14 +250,14 @@ async function formatConfigProvidersStatusLines(
return lines;
}
export async function providersStatusCommand(
opts: ProvidersStatusOptions,
export async function channelsStatusCommand(
opts: ChannelsStatusOptions,
runtime: RuntimeEnv = defaultRuntime,
) {
const timeoutMs = Number(opts.timeout ?? 10_000);
const statusLabel = opts.probe
? "Checking provider status (probe)…"
: "Checking provider status…";
? "Checking channel status (probe)…"
: "Checking channel status…";
const shouldLogStatus = opts.json !== true && !process.stderr.isTTY;
if (shouldLogStatus) runtime.log(statusLabel);
try {
@@ -269,7 +269,7 @@ export async function providersStatusCommand(
},
async () =>
await callGateway({
method: "providers.status",
method: "channels.status",
params: { probe: Boolean(opts.probe), timeoutMs },
timeoutMs,
}),
@@ -279,9 +279,9 @@ export async function providersStatusCommand(
return;
}
runtime.log(
formatGatewayProvidersStatusLines(
payload as Record<string, unknown>,
).join("\n"),
formatGatewayChannelsStatusLines(payload as Record<string, unknown>).join(
"\n",
),
);
} catch (err) {
runtime.error(`Gateway not reachable: ${String(err)}`);
@@ -291,7 +291,7 @@ export async function providersStatusCommand(
const mode = cfg.gateway?.mode === "remote" ? "remote" : "local";
runtime.log(
(
await formatConfigProvidersStatusLines(cfg, {
await formatConfigChannelsStatusLines(cfg, {
path: snapshot.path,
mode,
})

View File

@@ -8,6 +8,7 @@ import {
text as clackText,
} from "@clack/prompts";
import { ensureAuthProfileStore } from "../agents/auth-profiles.js";
import { listChatChannels } from "../channels/registry.js";
import type { ClawdbotConfig, GatewayAuthConfig } from "../config/config.js";
import {
CONFIG_PATH_CLAWDBOT,
@@ -26,7 +27,6 @@ import { resolveGatewayService } from "../daemon/service.js";
import { buildServiceEnvironment } from "../daemon/service-env.js";
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
import { findTailscaleBinary } from "../infra/tailscale.js";
import { listChatProviders } from "../providers/registry.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { note } from "../terminal/note.js";
@@ -54,6 +54,7 @@ import {
import { healthCommand } from "./health.js";
import { formatHealthCheckFailure } from "./health-format.js";
import { applyPrimaryModel, promptDefaultModel } from "./model-picker.js";
import { setupChannels } from "./onboard-channels.js";
import {
applyWizardMetadata,
DEFAULT_WORKSPACE,
@@ -65,7 +66,6 @@ import {
resolveControlUiLinks,
summarizeExistingConfig,
} from "./onboard-helpers.js";
import { setupProviders } from "./onboard-providers.js";
import { promptRemoteGatewayConfig } from "./onboard-remote.js";
import { setupSkills } from "./onboard-skills.js";
import { ensureSystemdUserLingerInteractive } from "./systemd-linger.js";
@@ -75,14 +75,14 @@ export const CONFIGURE_WIZARD_SECTIONS = [
"model",
"gateway",
"daemon",
"providers",
"channels",
"skills",
"health",
] as const;
export type WizardSection = (typeof CONFIGURE_WIZARD_SECTIONS)[number];
type ProvidersWizardMode = "configure" | "remove";
type ChannelsWizardMode = "configure" | "remove";
type ConfigureWizardParams = {
command: "configure" | "update";
@@ -140,8 +140,8 @@ const CONFIGURE_SECTION_OPTIONS: {
hint: "Install/manage the background service",
},
{
value: "providers",
label: "Providers",
value: "channels",
label: "Channels",
hint: "Link WhatsApp/Telegram/etc and defaults",
},
{
@@ -152,7 +152,7 @@ const CONFIGURE_SECTION_OPTIONS: {
{
value: "health",
label: "Health check",
hint: "Run gateway + provider checks",
hint: "Run gateway + channel checks",
},
];
@@ -570,34 +570,31 @@ async function maybeInstallDaemon(params: {
}
}
async function removeProviderConfigWizard(
async function removeChannelConfigWizard(
cfg: ClawdbotConfig,
runtime: RuntimeEnv,
): Promise<ClawdbotConfig> {
let next = { ...cfg };
const listConfiguredProviders = () =>
listChatProviders().filter((meta) => {
const value = (next as Record<string, unknown>)[meta.id];
return value !== undefined;
});
const listConfiguredChannels = () =>
listChatChannels().filter((meta) => next.channels?.[meta.id] !== undefined);
while (true) {
const configured = listConfiguredProviders();
const configured = listConfiguredChannels();
if (configured.length === 0) {
note(
[
"No provider config found in clawdbot.json.",
"Tip: `clawdbot providers status` shows what is configured and enabled.",
"No channel config found in clawdbot.json.",
"Tip: `clawdbot channels status` shows what is configured and enabled.",
].join("\n"),
"Remove provider",
"Remove channel",
);
return next;
}
const provider = guardCancel(
const channel = guardCancel(
await select({
message: "Remove which provider config?",
message: "Remove which channel config?",
options: [
...configured.map((meta) => ({
value: meta.id,
@@ -610,11 +607,10 @@ async function removeProviderConfigWizard(
runtime,
) as string;
if (provider === "done") return next;
if (channel === "done") return next;
const label =
listChatProviders().find((meta) => meta.id === provider)?.label ??
provider;
listChatChannels().find((meta) => meta.id === channel)?.label ?? channel;
const confirmed = guardCancel(
await confirm({
message: `Delete ${label} configuration from ${CONFIG_PATH_CLAWDBOT}?`,
@@ -624,16 +620,21 @@ async function removeProviderConfigWizard(
);
if (!confirmed) continue;
const clone = { ...next } as Record<string, unknown>;
delete clone[provider];
next = clone as ClawdbotConfig;
const nextChannels: Record<string, unknown> = { ...(next.channels ?? {}) };
delete nextChannels[channel];
next = {
...next,
channels: Object.keys(nextChannels).length
? (nextChannels as ClawdbotConfig["channels"])
: undefined,
};
note(
[
`${label} removed from config.`,
"Note: credentials/sessions on disk are unchanged.",
].join("\n"),
"Provider removed",
"Channel removed",
);
}
}
@@ -794,34 +795,34 @@ export async function runConfigureWizard(
gatewayToken = gateway.token;
}
if (selected.includes("providers")) {
const providerMode = guardCancel(
if (selected.includes("channels")) {
const channelMode = guardCancel(
await select({
message: "Providers",
message: "Channels",
options: [
{
value: "configure",
label: "Configure/link",
hint: "Add/update providers; disable unselected accounts",
hint: "Add/update channels; disable unselected accounts",
},
{
value: "remove",
label: "Remove provider config",
hint: "Delete provider tokens/settings from clawdbot.json",
label: "Remove channel config",
hint: "Delete channel tokens/settings from clawdbot.json",
},
],
initialValue: "configure",
}),
runtime,
) as ProvidersWizardMode;
) as ChannelsWizardMode;
if (providerMode === "configure") {
nextConfig = await setupProviders(nextConfig, runtime, prompter, {
if (channelMode === "configure") {
nextConfig = await setupChannels(nextConfig, runtime, prompter, {
allowDisable: true,
allowSignalInstall: true,
});
} else {
nextConfig = await removeProviderConfigWizard(nextConfig, runtime);
nextConfig = await removeChannelConfigWizard(nextConfig, runtime);
}
}
@@ -917,34 +918,34 @@ export async function runConfigureWizard(
await persistConfig();
}
if (choice === "providers") {
const providerMode = guardCancel(
if (choice === "channels") {
const channelMode = guardCancel(
await select({
message: "Providers",
message: "Channels",
options: [
{
value: "configure",
label: "Configure/link",
hint: "Add/update providers; disable unselected accounts",
hint: "Add/update channels; disable unselected accounts",
},
{
value: "remove",
label: "Remove provider config",
hint: "Delete provider tokens/settings from clawdbot.json",
label: "Remove channel config",
hint: "Delete channel tokens/settings from clawdbot.json",
},
],
initialValue: "configure",
}),
runtime,
) as ProvidersWizardMode;
) as ChannelsWizardMode;
if (providerMode === "configure") {
nextConfig = await setupProviders(nextConfig, runtime, prompter, {
if (channelMode === "configure") {
nextConfig = await setupChannels(nextConfig, runtime, prompter, {
allowDisable: true,
allowSignalInstall: true,
});
} else {
nextConfig = await removeProviderConfigWizard(nextConfig, runtime);
nextConfig = await removeChannelConfigWizard(nextConfig, runtime);
}
await persistConfig();
}

View File

@@ -248,7 +248,7 @@ export function normalizeLegacyConfigValues(cfg: ClawdbotConfig): {
const legacyAckReaction = cfg.messages?.ackReaction?.trim();
if (legacyAckReaction) {
const hasWhatsAppAck = cfg.whatsapp?.ackReaction !== undefined;
const hasWhatsAppAck = cfg.channels?.whatsapp?.ackReaction !== undefined;
if (!hasWhatsAppAck) {
const legacyScope = cfg.messages?.ackReactionScope ?? "group-mentions";
let direct = true;
@@ -268,13 +268,16 @@ export function normalizeLegacyConfigValues(cfg: ClawdbotConfig): {
}
next = {
...next,
whatsapp: {
...next.whatsapp,
ackReaction: { emoji: legacyAckReaction, direct, group },
channels: {
...next.channels,
whatsapp: {
...next.channels?.whatsapp,
ackReaction: { emoji: legacyAckReaction, direct, group },
},
},
};
changes.push(
`Copied messages.ackReaction → whatsapp.ackReaction (scope: ${legacyScope}).`,
`Copied messages.ackReaction → channels.whatsapp.ackReaction (scope: ${legacyScope}).`,
);
}
}

View File

@@ -1,8 +1,8 @@
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import { listChannelPlugins } from "../channels/plugins/index.js";
import type { ChannelId } from "../channels/plugins/types.js";
import type { ClawdbotConfig } from "../config/config.js";
import { readProviderAllowFromStore } from "../pairing/pairing-store.js";
import { resolveProviderDefaultAccountId } from "../providers/plugins/helpers.js";
import { listProviderPlugins } from "../providers/plugins/index.js";
import type { ProviderId } from "../providers/plugins/types.js";
import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
import { note } from "../terminal/note.js";
export async function noteSecurityWarnings(cfg: ClawdbotConfig) {
@@ -10,7 +10,7 @@ export async function noteSecurityWarnings(cfg: ClawdbotConfig) {
const warnDmPolicy = async (params: {
label: string;
provider: ProviderId;
provider: ChannelId;
dmPolicy: string;
allowFrom?: Array<string | number> | null;
policyPath?: string;
@@ -24,7 +24,7 @@ export async function noteSecurityWarnings(cfg: ClawdbotConfig) {
String(v).trim(),
);
const hasWildcard = configAllowFrom.includes("*");
const storeAllowFrom = await readProviderAllowFromStore(
const storeAllowFrom = await readChannelAllowFromStore(
params.provider,
).catch(() => []);
const normalizedCfg = configAllowFrom
@@ -68,10 +68,10 @@ export async function noteSecurityWarnings(cfg: ClawdbotConfig) {
}
};
for (const plugin of listProviderPlugins()) {
for (const plugin of listChannelPlugins()) {
if (!plugin.security) continue;
const accountIds = plugin.config.listAccountIds(cfg);
const defaultAccountId = resolveProviderDefaultAccountId({
const defaultAccountId = resolveChannelDefaultAccountId({
plugin,
cfg,
accountIds,

View File

@@ -59,7 +59,7 @@ beforeEach(() => {
.mockReturnValue({ version: 1, profiles: {} });
migrateLegacyConfig.mockReset().mockImplementation((raw: unknown) => ({
config: raw as Record<string, unknown>,
changes: ["Moved routing.allowFrom → whatsapp.allowFrom."],
changes: ["Moved routing.allowFrom → channels.whatsapp.allowFrom."],
}));
findLegacyGatewayServices.mockReset().mockResolvedValue([]);
uninstallLegacyGatewayServices.mockReset().mockResolvedValue([]);
@@ -122,7 +122,7 @@ const runGatewayUpdate = vi.fn().mockResolvedValue({
});
const migrateLegacyConfig = vi.fn((raw: unknown) => ({
config: raw as Record<string, unknown>,
changes: ["Moved routing.allowFrom → whatsapp.allowFrom."],
changes: ["Moved routing.allowFrom → channels.whatsapp.allowFrom."],
}));
const runExec = vi.fn().mockResolvedValue({ stdout: "", stderr: "" });
@@ -253,7 +253,7 @@ vi.mock("../telegram/pairing-store.js", () => ({
}));
vi.mock("../pairing/pairing-store.js", () => ({
readProviderAllowFromStore: vi.fn().mockResolvedValue([]),
readChannelAllowFromStore: vi.fn().mockResolvedValue([]),
}));
vi.mock("../telegram/token.js", () => ({
@@ -324,7 +324,7 @@ vi.mock("./doctor-state-migrations.js", () => ({
describe("doctor", () => {
it(
"migrates routing.allowFrom to whatsapp.allowFrom",
"migrates routing.allowFrom to channels.whatsapp.allowFrom",
{ timeout: 30_000 },
async () => {
readConfigFileSnapshot.mockResolvedValue({
@@ -356,8 +356,8 @@ describe("doctor", () => {
};
migrateLegacyConfig.mockReturnValue({
config: { whatsapp: { allowFrom: ["+15555550123"] } },
changes: ["Moved routing.allowFrom → whatsapp.allowFrom."],
config: { channels: { whatsapp: { allowFrom: ["+15555550123"] } } },
changes: ["Moved routing.allowFrom → channels.whatsapp.allowFrom."],
});
await doctorCommand(runtime, { nonInteractive: true });
@@ -367,9 +367,9 @@ describe("doctor", () => {
string,
unknown
>;
expect((written.whatsapp as Record<string, unknown>)?.allowFrom).toEqual([
"+15555550123",
]);
expect((written.channels as Record<string, unknown>)?.whatsapp).toEqual({
allowFrom: ["+15555550123"],
});
expect(written.routing).toBeUndefined();
},
);

View File

@@ -33,9 +33,9 @@ import {
import { resolveGatewayService } from "../daemon/service.js";
import { buildServiceEnvironment } from "../daemon/service-env.js";
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
import { collectChannelStatusIssues } from "../infra/channels-status-issues.js";
import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js";
import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js";
import { collectProvidersStatusIssues } from "../infra/providers-status-issues.js";
import { runGatewayUpdate } from "../infra/update-runner.js";
import { loadClawdbotPlugins } from "../plugins/loader.js";
import { runCommandWithTimeout } from "../process/exec.js";
@@ -281,7 +281,7 @@ export async function doctorCommand(
initialValue: true,
});
if (migrate) {
// Legacy migration (2026-01-02, commit: 16420e5b) — normalize per-provider allowlists; move WhatsApp gating into whatsapp.allowFrom.
// Legacy migration (2026-01-02, commit: 16420e5b) — normalize per-provider allowlists; move WhatsApp gating into channels.whatsapp.allowFrom.
const { config: migrated, changes } = migrateLegacyConfig(
snapshot.parsed,
);
@@ -555,20 +555,20 @@ export async function doctorCommand(
if (healthOk) {
try {
const status = await callGateway<Record<string, unknown>>({
method: "providers.status",
method: "channels.status",
params: { probe: true, timeoutMs: 5000 },
timeoutMs: 6000,
});
const issues = collectProvidersStatusIssues(status);
const issues = collectChannelStatusIssues(status);
if (issues.length > 0) {
note(
issues
.map(
(issue) =>
`- ${issue.provider} ${issue.accountId}: ${issue.message}${issue.fix ? ` (${issue.fix})` : ""}`,
`- ${issue.channel} ${issue.accountId}: ${issue.message}${issue.fix ? ` (${issue.fix})` : ""}`,
)
.join("\n"),
"Provider warnings",
"Channel warnings",
);
}
} catch {

View File

@@ -32,7 +32,7 @@ describe("healthCommand (coverage)", () => {
ok: true,
ts: Date.now(),
durationMs: 5,
providers: {
channels: {
whatsapp: {
linked: true,
authAgeMs: 5 * 60_000,
@@ -50,8 +50,8 @@ describe("healthCommand (coverage)", () => {
configured: false,
},
},
providerOrder: ["whatsapp", "telegram", "discord"],
providerLabels: {
channelOrder: ["whatsapp", "telegram", "discord"],
channelLabels: {
whatsapp: "WhatsApp",
telegram: "Telegram",
discord: "Discord",

View File

@@ -54,7 +54,7 @@ describe("getHealthSnapshot", () => {
timeoutMs: 10,
})) satisfies HealthSummary;
expect(snap.ok).toBe(true);
const telegram = snap.providers.telegram as {
const telegram = snap.channels.telegram as {
configured?: boolean;
probe?: unknown;
};
@@ -65,7 +65,7 @@ describe("getHealthSnapshot", () => {
});
it("probes telegram getMe + webhook info when configured", async () => {
testConfig = { telegram: { botToken: "t-1" } };
testConfig = { channels: { telegram: { botToken: "t-1" } } };
testStore = {};
vi.stubEnv("DISCORD_BOT_TOKEN", "");
@@ -106,7 +106,7 @@ describe("getHealthSnapshot", () => {
);
const snap = await getHealthSnapshot({ timeoutMs: 25 });
const telegram = snap.providers.telegram as {
const telegram = snap.channels.telegram as {
configured?: boolean;
probe?: {
ok?: boolean;
@@ -126,7 +126,7 @@ describe("getHealthSnapshot", () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-health-"));
const tokenFile = path.join(tmpDir, "telegram-token");
fs.writeFileSync(tokenFile, "t-file\n", "utf-8");
testConfig = { telegram: { tokenFile } };
testConfig = { channels: { telegram: { tokenFile } } };
testStore = {};
vi.stubEnv("TELEGRAM_BOT_TOKEN", "");
@@ -167,7 +167,7 @@ describe("getHealthSnapshot", () => {
);
const snap = await getHealthSnapshot({ timeoutMs: 25 });
const telegram = snap.providers.telegram as {
const telegram = snap.channels.telegram as {
configured?: boolean;
probe?: { ok?: boolean };
};
@@ -179,7 +179,7 @@ describe("getHealthSnapshot", () => {
});
it("returns a structured telegram probe error when getMe fails", async () => {
testConfig = { telegram: { botToken: "bad-token" } };
testConfig = { channels: { telegram: { botToken: "bad-token" } } };
testStore = {};
vi.stubEnv("DISCORD_BOT_TOKEN", "");
@@ -198,7 +198,7 @@ describe("getHealthSnapshot", () => {
);
const snap = await getHealthSnapshot({ timeoutMs: 25 });
const telegram = snap.providers.telegram as {
const telegram = snap.channels.telegram as {
configured?: boolean;
probe?: { ok?: boolean; status?: number; error?: string };
};
@@ -209,7 +209,7 @@ describe("getHealthSnapshot", () => {
});
it("captures unexpected probe exceptions as errors", async () => {
testConfig = { telegram: { botToken: "t-err" } };
testConfig = { channels: { telegram: { botToken: "t-err" } } };
testStore = {};
vi.stubEnv("DISCORD_BOT_TOKEN", "");
@@ -221,7 +221,7 @@ describe("getHealthSnapshot", () => {
);
const snap = await getHealthSnapshot({ timeoutMs: 25 });
const telegram = snap.providers.telegram as {
const telegram = snap.channels.telegram as {
configured?: boolean;
probe?: { ok?: boolean; error?: string };
};

View File

@@ -24,13 +24,13 @@ describe("healthCommand", () => {
ok: true,
ts: Date.now(),
durationMs: 5,
providers: {
channels: {
whatsapp: { linked: true, authAgeMs: 5000 },
telegram: { configured: true, probe: { ok: true, elapsedMs: 1 } },
discord: { configured: false },
},
providerOrder: ["whatsapp", "telegram", "discord"],
providerLabels: {
channelOrder: ["whatsapp", "telegram", "discord"],
channelLabels: {
whatsapp: "WhatsApp",
telegram: "Telegram",
discord: "Discord",
@@ -49,8 +49,8 @@ describe("healthCommand", () => {
expect(runtime.exit).not.toHaveBeenCalled();
const logged = runtime.log.mock.calls[0]?.[0] as string;
const parsed = JSON.parse(logged) as HealthSummary;
expect(parsed.providers.whatsapp?.linked).toBe(true);
expect(parsed.providers.telegram?.configured).toBe(true);
expect(parsed.channels.whatsapp?.linked).toBe(true);
expect(parsed.channels.telegram?.configured).toBe(true);
expect(parsed.sessions.count).toBe(1);
});
@@ -59,13 +59,13 @@ describe("healthCommand", () => {
ok: true,
ts: Date.now(),
durationMs: 5,
providers: {
channels: {
whatsapp: { linked: false, authAgeMs: null },
telegram: { configured: false },
discord: { configured: false },
},
providerOrder: ["whatsapp", "telegram", "discord"],
providerLabels: {
channelOrder: ["whatsapp", "telegram", "discord"],
channelLabels: {
whatsapp: "WhatsApp",
telegram: "Telegram",
discord: "Discord",

View File

@@ -1,19 +1,19 @@
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import {
getChannelPlugin,
listChannelPlugins,
} from "../channels/plugins/index.js";
import type { ChannelAccountSnapshot } from "../channels/plugins/types.js";
import { withProgress } from "../cli/progress.js";
import { loadConfig } from "../config/config.js";
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
import { info } from "../globals.js";
import { formatErrorMessage } from "../infra/errors.js";
import { resolveProviderDefaultAccountId } from "../providers/plugins/helpers.js";
import {
getProviderPlugin,
listProviderPlugins,
} from "../providers/plugins/index.js";
import type { ProviderAccountSnapshot } from "../providers/plugins/types.js";
import type { RuntimeEnv } from "../runtime.js";
import { resolveHeartbeatSeconds } from "../web/reconnect.js";
export type ProviderHealthSummary = {
export type ChannelHealthSummary = {
configured?: boolean;
linked?: boolean;
authAgeMs?: number | null;
@@ -31,9 +31,9 @@ export type HealthSummary = {
ok: true;
ts: number;
durationMs: number;
providers: Record<string, ProviderHealthSummary>;
providerOrder: string[];
providerLabels: Record<string, string>;
channels: Record<string, ChannelHealthSummary>;
channelOrder: string[];
channelLabels: Record<string, string>;
heartbeatSeconds: number;
sessions: {
path: string;
@@ -87,29 +87,27 @@ const formatProbeLine = (probe: unknown): string | null => {
return label;
};
export const formatHealthProviderLines = (summary: HealthSummary): string[] => {
const providers = summary.providers ?? {};
const providerOrder =
summary.providerOrder?.length > 0
? summary.providerOrder
: Object.keys(providers);
export const formatHealthChannelLines = (summary: HealthSummary): string[] => {
const channels = summary.channels ?? {};
const channelOrder =
summary.channelOrder?.length > 0
? summary.channelOrder
: Object.keys(channels);
const lines: string[] = [];
for (const providerId of providerOrder) {
const providerSummary = providers[providerId];
if (!providerSummary) continue;
const plugin = getProviderPlugin(providerId as never);
for (const channelId of channelOrder) {
const channelSummary = channels[channelId];
if (!channelSummary) continue;
const plugin = getChannelPlugin(channelId as never);
const label =
summary.providerLabels?.[providerId] ?? plugin?.meta.label ?? providerId;
summary.channelLabels?.[channelId] ?? plugin?.meta.label ?? channelId;
const linked =
typeof providerSummary.linked === "boolean"
? providerSummary.linked
: null;
typeof channelSummary.linked === "boolean" ? channelSummary.linked : null;
if (linked !== null) {
if (linked) {
const authAgeMs =
typeof providerSummary.authAgeMs === "number"
? providerSummary.authAgeMs
typeof channelSummary.authAgeMs === "number"
? channelSummary.authAgeMs
: null;
const authLabel =
authAgeMs != null
@@ -123,15 +121,15 @@ export const formatHealthProviderLines = (summary: HealthSummary): string[] => {
}
const configured =
typeof providerSummary.configured === "boolean"
? providerSummary.configured
typeof channelSummary.configured === "boolean"
? channelSummary.configured
: null;
if (configured === false) {
lines.push(`${label}: not configured`);
continue;
}
const probeLine = formatProbeLine(providerSummary.probe);
const probeLine = formatProbeLine(channelSummary.probe);
if (probeLine) {
lines.push(`${label}: ${probeLine}`);
continue;
@@ -168,14 +166,14 @@ export async function getHealthSnapshot(params?: {
const start = Date.now();
const cappedTimeout = Math.max(1000, timeoutMs ?? DEFAULT_TIMEOUT_MS);
const doProbe = params?.probe !== false;
const providers: Record<string, ProviderHealthSummary> = {};
const providerOrder = listProviderPlugins().map((plugin) => plugin.id);
const providerLabels: Record<string, string> = {};
const channels: Record<string, ChannelHealthSummary> = {};
const channelOrder = listChannelPlugins().map((plugin) => plugin.id);
const channelLabels: Record<string, string> = {};
for (const plugin of listProviderPlugins()) {
providerLabels[plugin.id] = plugin.meta.label ?? plugin.id;
for (const plugin of listChannelPlugins()) {
channelLabels[plugin.id] = plugin.meta.label ?? plugin.id;
const accountIds = plugin.config.listAccountIds(cfg);
const defaultAccountId = resolveProviderDefaultAccountId({
const defaultAccountId = resolveChannelDefaultAccountId({
plugin,
cfg,
accountIds,
@@ -204,7 +202,7 @@ export async function getHealthSnapshot(params?: {
}
}
const snapshot: ProviderAccountSnapshot = {
const snapshot: ChannelAccountSnapshot = {
accountId: defaultAccountId,
enabled,
configured,
@@ -212,8 +210,8 @@ export async function getHealthSnapshot(params?: {
if (probe !== undefined) snapshot.probe = probe;
if (lastProbeAt) snapshot.lastProbeAt = lastProbeAt;
const summary = plugin.status?.buildProviderSummary
? await plugin.status.buildProviderSummary({
const summary = plugin.status?.buildChannelSummary
? await plugin.status.buildChannelSummary({
account,
cfg,
defaultAccountId,
@@ -222,26 +220,26 @@ export async function getHealthSnapshot(params?: {
: undefined;
const record =
summary && typeof summary === "object"
? (summary as ProviderHealthSummary)
? (summary as ChannelHealthSummary)
: ({
configured,
probe,
lastProbeAt,
} satisfies ProviderHealthSummary);
} satisfies ChannelHealthSummary);
if (record.configured === undefined) record.configured = configured;
if (record.lastProbeAt === undefined && lastProbeAt) {
record.lastProbeAt = lastProbeAt;
}
providers[plugin.id] = record;
channels[plugin.id] = record;
}
const summary: HealthSummary = {
ok: true,
ts: Date.now(),
durationMs: Date.now() - start,
providers,
providerOrder,
providerLabels,
channels,
channelOrder,
channelLabels,
heartbeatSeconds,
sessions: {
path: storePath,
@@ -270,7 +268,7 @@ export async function healthCommand(
timeoutMs: opts.timeoutMs,
}),
);
// Gateway reachability defines success; provider issues are reported but not fatal here.
// Gateway reachability defines success; channel issues are reported but not fatal here.
const fatal = false;
if (opts.json) {
@@ -283,16 +281,16 @@ export async function healthCommand(
runtime.log(` ${line}`);
}
}
for (const line of formatHealthProviderLines(summary)) {
for (const line of formatHealthChannelLines(summary)) {
runtime.log(line);
}
const cfg = loadConfig();
for (const plugin of listProviderPlugins()) {
const providerSummary = summary.providers?.[plugin.id];
if (!providerSummary || providerSummary.linked !== true) continue;
for (const plugin of listChannelPlugins()) {
const channelSummary = summary.channels?.[plugin.id];
if (!channelSummary || channelSummary.linked !== true) continue;
if (!plugin.status?.logSelfId) continue;
const accountIds = plugin.config.listAccountIds(cfg);
const defaultAccountId = resolveProviderDefaultAccountId({
const defaultAccountId = resolveChannelDefaultAccountId({
plugin,
cfg,
accountIds,
@@ -302,7 +300,7 @@ export async function healthCommand(
account,
cfg,
runtime,
includeProviderPrefix: true,
includeChannelPrefix: true,
});
}

View File

@@ -1,14 +1,14 @@
import { getChannelPlugin } from "../channels/plugins/index.js";
import type {
ChannelId,
ChannelMessageActionName,
} from "../channels/plugins/types.js";
import type { OutboundDeliveryResult } from "../infra/outbound/deliver.js";
import {
formatGatewaySummary,
formatOutboundDeliverySummary,
} from "../infra/outbound/format.js";
import type { MessageActionRunResult } from "../infra/outbound/message-action-runner.js";
import { getProviderPlugin } from "../providers/plugins/index.js";
import type {
ProviderId,
ProviderMessageActionName,
} from "../providers/plugins/types.js";
import { renderTable } from "../terminal/table.js";
import { isRich, theme } from "../terminal/theme.js";
@@ -18,8 +18,8 @@ const shortenText = (value: string, maxLen: number) => {
return `${chars.slice(0, Math.max(0, maxLen - 1)).join("")}`;
};
const resolveProviderLabel = (provider: ProviderId) =>
getProviderPlugin(provider)?.meta.label ?? provider;
const resolveChannelLabel = (channel: ChannelId) =>
getChannelPlugin(channel)?.meta.label ?? channel;
function extractMessageId(payload: unknown): string | null {
if (!payload || typeof payload !== "object") return null;
@@ -34,8 +34,8 @@ function extractMessageId(payload: unknown): string | null {
}
export type MessageCliJsonEnvelope = {
action: ProviderMessageActionName;
provider: ProviderId;
action: ChannelMessageActionName;
channel: ChannelId;
dryRun: boolean;
handledBy: "plugin" | "core" | "dry-run";
payload: unknown;
@@ -46,7 +46,7 @@ export function buildMessageCliJson(
): MessageCliJsonEnvelope {
return {
action: result.action,
provider: result.provider,
channel: result.channel,
dryRun: result.dryRun,
handledBy: result.handledBy,
payload: result.payload,
@@ -256,7 +256,7 @@ export function formatMessageCliText(result: MessageActionRunResult): string[] {
if (result.handledBy === "dry-run") {
return [
muted(`[dry-run] would run ${result.action} via ${result.provider}`),
muted(`[dry-run] would run ${result.action} via ${result.channel}`),
];
}
@@ -265,20 +265,20 @@ export function formatMessageCliText(result: MessageActionRunResult): string[] {
const send = result.sendResult;
if (send.via === "direct") {
const directResult = send.result as OutboundDeliveryResult | undefined;
return [ok(formatOutboundDeliverySummary(send.provider, directResult))];
return [ok(formatOutboundDeliverySummary(send.channel, directResult))];
}
const gatewayResult = send.result as { messageId?: string } | undefined;
return [
ok(
formatGatewaySummary({
provider: send.provider,
channel: send.channel,
messageId: gatewayResult?.messageId ?? null,
}),
),
];
}
const label = resolveProviderLabel(result.provider);
const label = resolveChannelLabel(result.channel);
const msgId = extractMessageId(result.payload);
return [ok(`✅ Sent via ${label}.${msgId ? ` Message ID: ${msgId}` : ""}`)];
}
@@ -292,7 +292,7 @@ export function formatMessageCliText(result: MessageActionRunResult): string[] {
ok(
formatGatewaySummary({
action: "Poll sent",
provider: poll.provider,
channel: poll.channel,
messageId: msgId,
}),
),
@@ -301,14 +301,14 @@ export function formatMessageCliText(result: MessageActionRunResult): string[] {
return lines;
}
const label = resolveProviderLabel(result.provider);
const label = resolveChannelLabel(result.channel);
const msgId = extractMessageId(result.payload);
return [
ok(`✅ Poll sent via ${label}.${msgId ? ` Message ID: ${msgId}` : ""}`),
];
}
// provider actions (non-send/poll)
// channel actions (non-send/poll)
const payload = result.payload;
const lines: string[] = [];
@@ -372,7 +372,7 @@ export function formatMessageCliText(result: MessageActionRunResult): string[] {
// Generic success + compact details table.
lines.push(
ok(`${result.action} via ${resolveProviderLabel(result.provider)}.`),
ok(`${result.action} via ${resolveChannelLabel(result.channel)}.`),
);
const summary = renderObjectSummary(payload, opts);
if (summary.length) {

View File

@@ -83,7 +83,7 @@ const makeDeps = (overrides: Partial<CliDeps> = {}): CliDeps => ({
});
describe("messageCommand", () => {
it("defaults provider when only one configured", async () => {
it("defaults channel when only one configured", async () => {
process.env.TELEGRAM_BOT_TOKEN = "token-abc";
const deps = makeDeps();
await messageCommand(
@@ -97,7 +97,7 @@ describe("messageCommand", () => {
expect(handleTelegramAction).toHaveBeenCalled();
});
it("requires provider when multiple configured", async () => {
it("requires channel when multiple configured", async () => {
process.env.TELEGRAM_BOT_TOKEN = "token-abc";
process.env.DISCORD_BOT_TOKEN = "token-discord";
const deps = makeDeps();
@@ -110,7 +110,7 @@ describe("messageCommand", () => {
deps,
runtime,
),
).rejects.toThrow(/Provider is required/);
).rejects.toThrow(/Channel is required/);
});
it("sends via gateway for WhatsApp", async () => {
@@ -119,7 +119,7 @@ describe("messageCommand", () => {
await messageCommand(
{
action: "send",
provider: "whatsapp",
channel: "whatsapp",
to: "+1",
message: "hi",
},
@@ -134,7 +134,7 @@ describe("messageCommand", () => {
await messageCommand(
{
action: "poll",
provider: "discord",
channel: "discord",
to: "channel:123",
pollQuestion: "Snack?",
pollOption: ["Pizza", "Sushi"],

View File

@@ -1,17 +1,17 @@
import {
CHANNEL_MESSAGE_ACTION_NAMES,
type ChannelMessageActionName,
} from "../channels/plugins/types.js";
import type { CliDeps } from "../cli/deps.js";
import { withProgress } from "../cli/progress.js";
import { loadConfig } from "../config/config.js";
import type { OutboundSendDeps } from "../infra/outbound/deliver.js";
import { runMessageAction } from "../infra/outbound/message-action-runner.js";
import {
PROVIDER_MESSAGE_ACTION_NAMES,
type ProviderMessageActionName,
} from "../providers/plugins/types.js";
import type { RuntimeEnv } from "../runtime.js";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
} from "../utils/message-provider.js";
} from "../utils/message-channel.js";
import { buildMessageCliJson, formatMessageCliText } from "./message-format.js";
export async function messageCommand(
@@ -22,8 +22,8 @@ export async function messageCommand(
const cfg = loadConfig();
const rawAction =
typeof opts.action === "string" ? opts.action.trim().toLowerCase() : "";
const action = (rawAction || "send") as ProviderMessageActionName;
if (!(PROVIDER_MESSAGE_ACTION_NAMES as readonly string[]).includes(action)) {
const action = (rawAction || "send") as ChannelMessageActionName;
if (!(CHANNEL_MESSAGE_ACTION_NAMES as readonly string[]).includes(action)) {
throw new Error(`Unknown message action: ${action}`);
}

View File

@@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { setupProviders } from "./onboard-providers.js";
import { setupChannels } from "./onboard-channels.js";
vi.mock("node:fs/promises", () => ({
default: {
@@ -13,7 +13,7 @@ vi.mock("node:fs/promises", () => ({
},
}));
vi.mock("../provider-web.js", () => ({
vi.mock("../channel-web.js", () => ({
loginWeb: vi.fn(async () => {}),
}));
@@ -21,7 +21,7 @@ vi.mock("./onboard-helpers.js", () => ({
detectBinary: vi.fn(async () => false),
}));
describe("setupProviders", () => {
describe("setupChannels", () => {
it("QuickStart uses single-select (no multiselect) and doesn't prompt for Telegram token when WhatsApp is chosen", async () => {
const select = vi.fn(async () => "whatsapp");
const multiselect = vi.fn(async () => {
@@ -56,14 +56,14 @@ describe("setupProviders", () => {
}),
};
await setupProviders({} as ClawdbotConfig, runtime, prompter, {
await setupChannels({} as ClawdbotConfig, runtime, prompter, {
skipConfirm: true,
quickstartDefaults: true,
forceAllowFromProviders: ["whatsapp"],
forceAllowFromChannels: ["whatsapp"],
});
expect(select).toHaveBeenCalledWith(
expect.objectContaining({ message: "Select provider (QuickStart)" }),
expect.objectContaining({ message: "Select channel (QuickStart)" }),
);
expect(multiselect).not.toHaveBeenCalled();
});

View File

@@ -1,63 +1,63 @@
import {
formatChannelPrimerLine,
formatChannelSelectionLine,
getChatChannelMeta,
listChatChannels,
} from "../channels/registry.js";
import type { ClawdbotConfig } from "../config/config.js";
import type { DmPolicy } from "../config/types.js";
import {
formatProviderPrimerLine,
formatProviderSelectionLine,
getChatProviderMeta,
listChatProviders,
} from "../providers/registry.js";
import type { RuntimeEnv } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import type { ProviderChoice } from "./onboard-types.js";
import type { ChannelChoice } from "./onboard-types.js";
import {
getProviderOnboardingAdapter,
listProviderOnboardingAdapters,
getChannelOnboardingAdapter,
listChannelOnboardingAdapters,
} from "./onboarding/registry.js";
import type {
ProviderOnboardingDmPolicy,
SetupProvidersOptions,
ChannelOnboardingDmPolicy,
SetupChannelsOptions,
} from "./onboarding/types.js";
async function noteProviderPrimer(prompter: WizardPrompter): Promise<void> {
const providerLines = listChatProviders().map((meta) =>
formatProviderPrimerLine(meta),
async function noteChannelPrimer(prompter: WizardPrompter): Promise<void> {
const channelLines = listChatChannels().map((meta) =>
formatChannelPrimerLine(meta),
);
await prompter.note(
[
"DM security: default is pairing; unknown DMs get a pairing code.",
"Approve with: clawdbot pairing approve <provider> <code>",
"Approve with: clawdbot pairing approve <channel> <code>",
'Public DMs require dmPolicy="open" + allowFrom=["*"].',
`Docs: ${formatDocsLink("/start/pairing", "start/pairing")}`,
"",
...providerLines,
...channelLines,
].join("\n"),
"How providers work",
"How channels work",
);
}
function resolveQuickstartDefault(
statusByProvider: Map<ProviderChoice, { quickstartScore?: number }>,
): ProviderChoice | undefined {
let best: { provider: ProviderChoice; score: number } | null = null;
for (const [provider, status] of statusByProvider) {
statusByChannel: Map<ChannelChoice, { quickstartScore?: number }>,
): ChannelChoice | undefined {
let best: { channel: ChannelChoice; score: number } | null = null;
for (const [channel, status] of statusByChannel) {
if (status.quickstartScore == null) continue;
if (!best || status.quickstartScore > best.score) {
best = { provider, score: status.quickstartScore };
best = { channel, score: status.quickstartScore };
}
}
return best?.provider;
return best?.channel;
}
async function maybeConfigureDmPolicies(params: {
cfg: ClawdbotConfig;
selection: ProviderChoice[];
selection: ChannelChoice[];
prompter: WizardPrompter;
}): Promise<ClawdbotConfig> {
const { selection, prompter } = params;
const dmPolicies = selection
.map((provider) => getProviderOnboardingAdapter(provider)?.dmPolicy)
.filter(Boolean) as ProviderOnboardingDmPolicy[];
.map((channel) => getChannelOnboardingAdapter(channel)?.dmPolicy)
.filter(Boolean) as ChannelOnboardingDmPolicy[];
if (dmPolicies.length === 0) return params.cfg;
const wants = await prompter.confirm({
@@ -67,11 +67,11 @@ async function maybeConfigureDmPolicies(params: {
if (!wants) return params.cfg;
let cfg = params.cfg;
const selectPolicy = async (policy: ProviderOnboardingDmPolicy) => {
const selectPolicy = async (policy: ChannelOnboardingDmPolicy) => {
await prompter.note(
[
"Default: pairing (unknown DMs get a pairing code).",
`Approve: clawdbot pairing approve ${policy.provider} <code>`,
`Approve: clawdbot pairing approve ${policy.channel} <code>`,
`Public DMs: ${policy.policyKey}="open" + ${policy.allowFromKey} includes "*".`,
`Docs: ${formatDocsLink("/start/pairing", "start/pairing")}`,
].join("\n"),
@@ -98,18 +98,16 @@ async function maybeConfigureDmPolicies(params: {
return cfg;
}
// Provider-specific prompts moved into onboarding adapters.
// Channel-specific prompts moved into onboarding adapters.
export async function setupProviders(
export async function setupChannels(
cfg: ClawdbotConfig,
runtime: RuntimeEnv,
prompter: WizardPrompter,
options?: SetupProvidersOptions,
options?: SetupChannelsOptions,
): Promise<ClawdbotConfig> {
const forceAllowFromProviders = new Set(
options?.forceAllowFromProviders ?? [],
);
const accountOverrides: Partial<Record<ProviderChoice, string>> = {
const forceAllowFromChannels = new Set(options?.forceAllowFromChannels ?? []);
const accountOverrides: Partial<Record<ChannelChoice, string>> = {
...options?.accountIds,
};
if (options?.whatsappAccountId?.trim()) {
@@ -117,30 +115,30 @@ export async function setupProviders(
}
const statusEntries = await Promise.all(
listProviderOnboardingAdapters().map((adapter) =>
listChannelOnboardingAdapters().map((adapter) =>
adapter.getStatus({ cfg, options, accountOverrides }),
),
);
const statusByProvider = new Map(
statusEntries.map((entry) => [entry.provider, entry]),
const statusByChannel = new Map(
statusEntries.map((entry) => [entry.channel, entry]),
);
const statusLines = statusEntries.flatMap((entry) => entry.statusLines);
if (statusLines.length > 0) {
await prompter.note(statusLines.join("\n"), "Provider status");
await prompter.note(statusLines.join("\n"), "Channel status");
}
const shouldConfigure = options?.skipConfirm
? true
: await prompter.confirm({
message: "Configure chat providers now?",
message: "Configure chat channels now?",
initialValue: true,
});
if (!shouldConfigure) return cfg;
await noteProviderPrimer(prompter);
await noteChannelPrimer(prompter);
const selectionOptions = listChatProviders().map((meta) => {
const status = statusByProvider.get(meta.id as ProviderChoice);
const selectionOptions = listChatChannels().map((meta) => {
const status = statusByChannel.get(meta.id as ChannelChoice);
return {
value: meta.id,
label: meta.selectionLabel,
@@ -149,58 +147,57 @@ export async function setupProviders(
});
const quickstartDefault =
options?.initialSelection?.[0] ??
resolveQuickstartDefault(statusByProvider);
options?.initialSelection?.[0] ?? resolveQuickstartDefault(statusByChannel);
let selection: ProviderChoice[];
let selection: ChannelChoice[];
if (options?.quickstartDefaults) {
const choice = (await prompter.select({
message: "Select provider (QuickStart)",
message: "Select channel (QuickStart)",
options: [
...selectionOptions,
{
value: "__skip__",
label: "Skip for now",
hint: "You can add providers later via `clawdbot providers add`",
hint: "You can add channels later via `clawdbot channels add`",
},
],
initialValue: quickstartDefault,
})) as ProviderChoice | "__skip__";
})) as ChannelChoice | "__skip__";
selection = choice === "__skip__" ? [] : [choice];
} else {
const initialSelection = options?.initialSelection ?? [];
selection = (await prompter.multiselect({
message: "Select providers (Space to toggle, Enter to continue)",
message: "Select channels (Space to toggle, Enter to continue)",
options: selectionOptions,
initialValues: initialSelection.length ? initialSelection : undefined,
})) as ProviderChoice[];
})) as ChannelChoice[];
}
options?.onSelection?.(selection);
const selectionNotes = new Map(
listChatProviders().map((meta) => [
listChatChannels().map((meta) => [
meta.id,
formatProviderSelectionLine(meta, formatDocsLink),
formatChannelSelectionLine(meta, formatDocsLink),
]),
);
const selectedLines = selection
.map((provider) => selectionNotes.get(provider))
.map((channel) => selectionNotes.get(channel))
.filter((line): line is string => Boolean(line));
if (selectedLines.length > 0) {
await prompter.note(selectedLines.join("\n"), "Selected providers");
await prompter.note(selectedLines.join("\n"), "Selected channels");
}
const shouldPromptAccountIds = options?.promptAccountIds === true;
const recordAccount = (provider: ProviderChoice, accountId: string) => {
options?.onAccountId?.(provider, accountId);
const adapter = getProviderOnboardingAdapter(provider);
const recordAccount = (channel: ChannelChoice, accountId: string) => {
options?.onAccountId?.(channel, accountId);
const adapter = getChannelOnboardingAdapter(channel);
adapter?.onAccountRecorded?.(accountId, options);
};
let next = cfg;
for (const provider of selection) {
const adapter = getProviderOnboardingAdapter(provider);
for (const channel of selection) {
const adapter = getChannelOnboardingAdapter(channel);
if (!adapter) continue;
const result = await adapter.configure({
cfg: next,
@@ -209,11 +206,11 @@ export async function setupProviders(
options,
accountOverrides,
shouldPromptAccountIds,
forceAllowFrom: forceAllowFromProviders.has(provider),
forceAllowFrom: forceAllowFromChannels.has(channel),
});
next = result.cfg;
if (result.accountId) {
recordAccount(provider, result.accountId);
recordAccount(channel, result.accountId);
}
}
@@ -222,14 +219,14 @@ export async function setupProviders(
}
if (options?.allowDisable) {
for (const [providerId, status] of statusByProvider) {
if (selection.includes(providerId)) continue;
for (const [channelId, status] of statusByChannel) {
if (selection.includes(channelId)) continue;
if (!status.configured) continue;
const adapter = getProviderOnboardingAdapter(providerId);
const adapter = getChannelOnboardingAdapter(channelId);
if (!adapter?.disable) continue;
const meta = getChatProviderMeta(providerId);
const meta = getChatChannelMeta(channelId);
const disable = await prompter.confirm({
message: `Disable ${meta.label} provider?`,
message: `Disable ${meta.label} channel?`,
initialValue: false,
});
if (disable) {

View File

@@ -22,7 +22,7 @@ import { stylePromptTitle } from "../terminal/prompt-style.js";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
} from "../utils/message-provider.js";
} from "../utils/message-channel.js";
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
import { VERSION } from "../version.js";
import type {

View File

@@ -11,7 +11,7 @@ import { rawDataToString } from "../infra/ws.js";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
} from "../utils/message-provider.js";
} from "../utils/message-channel.js";
async function getFreePort(): Promise<number> {
return await new Promise((resolve, reject) => {

View File

@@ -12,7 +12,7 @@ import { rawDataToString } from "../infra/ws.js";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
} from "../utils/message-provider.js";
} from "../utils/message-channel.js";
async function isPortFree(port: number): Promise<boolean> {
if (!Number.isFinite(port) || port <= 0 || port > 65535) return false;

View File

@@ -1,4 +1,4 @@
import type { ChatProviderId } from "../providers/registry.js";
import type { ChatChannelId } from "../channels/registry.js";
import type { GatewayDaemonRuntime } from "./daemon-runtime.js";
export type OnboardMode = "local" | "remote";
@@ -31,7 +31,9 @@ export type ResetScope = "config" | "config+creds+sessions" | "full";
export type GatewayBind = "loopback" | "lan" | "auto" | "custom";
export type TailscaleMode = "off" | "serve" | "funnel";
export type NodeManagerChoice = "npm" | "pnpm" | "bun";
export type ProviderChoice = ChatProviderId;
export type ChannelChoice = ChatChannelId;
// Legacy alias (pre-rename).
export type ProviderChoice = ChannelChoice;
export type OnboardOptions = {
mode?: OnboardMode;
@@ -66,6 +68,8 @@ export type OnboardOptions = {
tailscaleResetOnExit?: boolean;
installDaemon?: boolean;
daemonRuntime?: GatewayDaemonRuntime;
skipChannels?: boolean;
/** @deprecated Legacy alias for `skipChannels`. */
skipProviders?: boolean;
skipSkills?: boolean;
skipHealth?: boolean;

View File

@@ -1,29 +1,31 @@
import { listProviderPlugins } from "../../providers/plugins/index.js";
import type { ProviderChoice } from "../onboard-types.js";
import type { ProviderOnboardingAdapter } from "./types.js";
import { listChannelPlugins } from "../../channels/plugins/index.js";
import type { ChannelChoice } from "../onboard-types.js";
import type { ChannelOnboardingAdapter } from "./types.js";
const PROVIDER_ONBOARDING_ADAPTERS = () =>
new Map<ProviderChoice, ProviderOnboardingAdapter>(
listProviderPlugins()
const CHANNEL_ONBOARDING_ADAPTERS = () =>
new Map<ChannelChoice, ChannelOnboardingAdapter>(
listChannelPlugins()
.map((plugin) =>
plugin.onboarding
? ([plugin.id as ProviderChoice, plugin.onboarding] as const)
? ([plugin.id as ChannelChoice, plugin.onboarding] as const)
: null,
)
.filter(
(
entry,
): entry is readonly [ProviderChoice, ProviderOnboardingAdapter] =>
(entry): entry is readonly [ChannelChoice, ChannelOnboardingAdapter] =>
Boolean(entry),
),
);
export function getProviderOnboardingAdapter(
provider: ProviderChoice,
): ProviderOnboardingAdapter | undefined {
return PROVIDER_ONBOARDING_ADAPTERS().get(provider);
export function getChannelOnboardingAdapter(
channel: ChannelChoice,
): ChannelOnboardingAdapter | undefined {
return CHANNEL_ONBOARDING_ADAPTERS().get(channel);
}
export function listProviderOnboardingAdapters(): ProviderOnboardingAdapter[] {
return Array.from(PROVIDER_ONBOARDING_ADAPTERS().values());
export function listChannelOnboardingAdapters(): ChannelOnboardingAdapter[] {
return Array.from(CHANNEL_ONBOARDING_ADAPTERS().values());
}
// Legacy aliases (pre-rename).
export const getProviderOnboardingAdapter = getChannelOnboardingAdapter;
export const listProviderOnboardingAdapters = listChannelOnboardingAdapters;

View File

@@ -1 +1 @@
export * from "../../providers/plugins/onboarding-types.js";
export * from "../../channels/plugins/onboarding-types.js";

View File

@@ -1,13 +0,0 @@
export type { ProvidersAddOptions } from "./providers/add.js";
export { providersAddCommand } from "./providers/add.js";
export type { ProvidersListOptions } from "./providers/list.js";
export { providersListCommand } from "./providers/list.js";
export type { ProvidersLogsOptions } from "./providers/logs.js";
export { providersLogsCommand } from "./providers/logs.js";
export type { ProvidersRemoveOptions } from "./providers/remove.js";
export { providersRemoveCommand } from "./providers/remove.js";
export type { ProvidersStatusOptions } from "./providers/status.js";
export {
formatGatewayProvidersStatusLines,
providersStatusCommand,
} from "./providers/status.js";

View File

@@ -3,6 +3,7 @@ import {
resolveSandboxConfigForAgent,
resolveSandboxToolPolicyForAgent,
} from "../agents/sandbox.js";
import { normalizeChannelId } from "../channels/registry.js";
import type { ClawdbotConfig } from "../config/config.js";
import { loadConfig } from "../config/config.js";
import {
@@ -11,7 +12,6 @@ import {
resolveMainSessionKey,
resolveStorePath,
} from "../config/sessions.js";
import { normalizeProviderId } from "../providers/registry.js";
import {
buildAgentMainSessionKey,
normalizeAgentId,
@@ -22,7 +22,7 @@ import {
import type { RuntimeEnv } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { colorize, isRich, theme } from "../terminal/theme.js";
import { INTERNAL_MESSAGE_PROVIDER } from "../utils/message-provider.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel.js";
type SandboxExplainOptions = {
session?: string;
@@ -66,11 +66,11 @@ function inferProviderFromSessionKey(params: {
if (parts[0] === configuredMainKey) return undefined;
const candidate = parts[0]?.trim().toLowerCase();
if (!candidate) return undefined;
if (candidate === INTERNAL_MESSAGE_PROVIDER) return INTERNAL_MESSAGE_PROVIDER;
return normalizeProviderId(candidate) ?? undefined;
if (candidate === INTERNAL_MESSAGE_CHANNEL) return INTERNAL_MESSAGE_CHANNEL;
return normalizeChannelId(candidate) ?? undefined;
}
function resolveActiveProvider(params: {
function resolveActiveChannel(params: {
cfg: ClawdbotConfig;
agentId: string;
sessionKey: string;
@@ -79,17 +79,26 @@ function resolveActiveProvider(params: {
agentId: params.agentId,
});
const store = loadSessionStore(storePath);
const entry = store[params.sessionKey];
const entry = store[params.sessionKey] as
| {
lastChannel?: string;
channel?: string;
// Legacy keys (pre-rename).
lastProvider?: string;
provider?: string;
}
| undefined;
const candidate = (
entry?.lastChannel ??
entry?.channel ??
entry?.lastProvider ??
entry?.providerOverride ??
entry?.provider ??
""
)
.trim()
.toLowerCase();
if (candidate === INTERNAL_MESSAGE_PROVIDER) return INTERNAL_MESSAGE_PROVIDER;
const normalized = normalizeProviderId(candidate);
if (candidate === INTERNAL_MESSAGE_CHANNEL) return INTERNAL_MESSAGE_CHANNEL;
const normalized = normalizeChannelId(candidate);
if (normalized) return normalized;
return inferProviderFromSessionKey({
cfg: params.cfg,
@@ -133,7 +142,7 @@ export async function sandboxExplainCommand(
? false
: sessionKey.trim() !== mainSessionKey.trim();
const provider = resolveActiveProvider({
const channel = resolveActiveChannel({
cfg,
agentId: resolvedAgentId,
sessionKey,
@@ -146,12 +155,10 @@ export async function sandboxExplainCommand(
const elevatedAgentEnabled = elevatedAgent?.enabled !== false;
const elevatedEnabled = elevatedGlobalEnabled && elevatedAgentEnabled;
const globalAllow = provider
? elevatedGlobal?.allowFrom?.[provider]
: undefined;
const agentAllow = provider
? elevatedAgent?.allowFrom?.[provider]
const globalAllow = channel
? elevatedGlobal?.allowFrom?.[channel]
: undefined;
const agentAllow = channel ? elevatedAgent?.allowFrom?.[channel] : undefined;
const allowTokens = (values?: Array<string | number>) =>
(values ?? []).map((v) => String(v).trim()).filter(Boolean);
@@ -160,7 +167,7 @@ export async function sandboxExplainCommand(
const elevatedAllowedByConfig =
elevatedEnabled &&
Boolean(provider) &&
Boolean(channel) &&
globalAllowTokens.length > 0 &&
(elevatedAgent?.allowFrom ? agentAllowTokens.length > 0 : true);
@@ -179,16 +186,16 @@ export async function sandboxExplainCommand(
key: "agents.list[].tools.elevated.enabled",
});
}
if (provider && globalAllowTokens.length === 0) {
if (channel && globalAllowTokens.length === 0) {
elevatedFailures.push({
gate: "allowFrom",
key: `tools.elevated.allowFrom.${provider}`,
key: `tools.elevated.allowFrom.${channel}`,
});
}
if (provider && elevatedAgent?.allowFrom && agentAllowTokens.length === 0) {
if (channel && elevatedAgent?.allowFrom && agentAllowTokens.length === 0) {
elevatedFailures.push({
gate: "allowFrom",
key: `agents.list[].tools.elevated.allowFrom.${provider}`,
key: `agents.list[].tools.elevated.allowFrom.${channel}`,
});
}
@@ -202,7 +209,7 @@ export async function sandboxExplainCommand(
fixIt.push("agents.list[].tools.sandbox.tools.allow");
fixIt.push("agents.list[].tools.sandbox.tools.deny");
fixIt.push("tools.elevated.enabled");
if (provider) fixIt.push(`tools.elevated.allowFrom.${provider}`);
if (channel) fixIt.push(`tools.elevated.allowFrom.${channel}`);
const payload = {
docsUrl: SANDBOX_DOCS_URL,
@@ -224,13 +231,13 @@ export async function sandboxExplainCommand(
},
elevated: {
enabled: elevatedEnabled,
provider,
channel,
allowedByConfig: elevatedAllowedByConfig,
alwaysAllowedByConfig: elevatedAlwaysAllowedByConfig,
allowFrom: {
global: provider ? globalAllowTokens : undefined,
global: channel ? globalAllowTokens : undefined,
agent:
elevatedAgent?.allowFrom && provider ? agentAllowTokens : undefined,
elevatedAgent?.allowFrom && channel ? agentAllowTokens : undefined,
},
failures: elevatedFailures,
},
@@ -287,7 +294,7 @@ export async function sandboxExplainCommand(
lines.push(heading("Elevated:"));
lines.push(` ${key("enabled:")} ${bool(payload.elevated.enabled)}`);
lines.push(
` ${key("provider:")} ${value(payload.elevated.provider ?? "(unknown)")}`,
` ${key("channel:")} ${value(payload.elevated.channel ?? "(unknown)")}`,
);
lines.push(
` ${key("allowedByConfig:")} ${bool(payload.elevated.allowedByConfig)}`,

View File

@@ -11,10 +11,10 @@ import { resolveGatewayService } from "../daemon/service.js";
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
import { normalizeControlUiBasePath } from "../gateway/control-ui.js";
import { probeGateway } from "../gateway/probe.js";
import { collectChannelStatusIssues } from "../infra/channels-status-issues.js";
import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js";
import { resolveOsSummary } from "../infra/os-summary.js";
import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js";
import { collectProvidersStatusIssues } from "../infra/providers-status-issues.js";
import {
readRestartSentinel,
summarizeRestartSentinel,
@@ -31,6 +31,7 @@ import { isRich, theme } from "../terminal/theme.js";
import { VERSION } from "../version.js";
import { resolveControlUiLinks } from "./onboard-helpers.js";
import { getAgentLocalStatuses } from "./status-all/agents.js";
import { buildChannelsTable } from "./status-all/channels.js";
import {
formatAge,
formatDuration,
@@ -42,7 +43,6 @@ import {
readFileTailLines,
summarizeLogTail,
} from "./status-all/gateway.js";
import { buildProvidersTable } from "./status-all/providers.js";
export async function statusAllCommand(
runtime: RuntimeEnv,
@@ -192,8 +192,8 @@ export async function statusAllCommand(
progress.setLabel("Scanning agents…");
const agentStatus = await getAgentLocalStatuses(cfg);
progress.tick();
progress.setLabel("Summarizing providers…");
const providers = await buildProvidersTable(cfg, { showSecrets: false });
progress.setLabel("Summarizing channels…");
const channels = await buildChannelsTable(cfg, { showSecrets: false });
progress.tick();
const connectionDetailsForReport = (() => {
@@ -229,16 +229,16 @@ export async function statusAllCommand(
}).catch((err) => ({ error: String(err) }))
: { error: gatewayProbe?.error ?? "gateway unreachable" };
const providersStatus = gatewayReachable
const channelsStatus = gatewayReachable
? await callGateway<Record<string, unknown>>({
method: "providers.status",
method: "channels.status",
params: { probe: false, timeoutMs: opts?.timeoutMs ?? 10_000 },
timeoutMs: Math.min(8000, opts?.timeoutMs ?? 10_000),
...callOverrides,
}).catch(() => null)
: null;
const providerIssues = providersStatus
? collectProvidersStatusIssues(providersStatus)
const channelIssues = channelsStatus
? collectChannelStatusIssues(channelsStatus)
: [];
progress.tick();
@@ -428,9 +428,9 @@ export async function statusAllCommand(
rows: overviewRows,
});
const providerRows = providers.rows.map((row) => ({
providerId: row.id,
Provider: row.provider,
const channelRows = channels.rows.map((row) => ({
channelId: row.id,
Channel: row.label,
Enabled: row.enabled ? ok("ON") : muted("OFF"),
State:
row.state === "ok"
@@ -442,18 +442,18 @@ export async function statusAllCommand(
: theme.accentDim("SETUP"),
Detail: row.detail,
}));
const providerIssuesByProvider = (() => {
const map = new Map<string, typeof providerIssues>();
for (const issue of providerIssues) {
const key = issue.provider;
const channelIssuesByChannel = (() => {
const map = new Map<string, typeof channelIssues>();
for (const issue of channelIssues) {
const key = issue.channel;
const list = map.get(key);
if (list) list.push(issue);
else map.set(key, [issue]);
}
return map;
})();
const providerRowsWithIssues = providerRows.map((row) => {
const issues = providerIssuesByProvider.get(row.providerId) ?? [];
const channelRowsWithIssues = channelRows.map((row) => {
const issues = channelIssuesByChannel.get(row.channelId) ?? [];
if (issues.length === 0) return row;
const issue = issues[0];
const suffix = ` · ${warn(`gateway: ${String(issue.message).slice(0, 90)}`)}`;
@@ -464,15 +464,15 @@ export async function statusAllCommand(
};
});
const providersTable = renderTable({
const channelsTable = renderTable({
width: tableWidth,
columns: [
{ key: "Provider", header: "Provider", minWidth: 10 },
{ key: "Channel", header: "Channel", minWidth: 10 },
{ key: "Enabled", header: "Enabled", minWidth: 7 },
{ key: "State", header: "State", minWidth: 8 },
{ key: "Detail", header: "Detail", flex: true, minWidth: 28 },
],
rows: providerRowsWithIssues,
rows: channelRowsWithIssues,
});
const agentRows = agentStatus.agents.map((a) => ({
@@ -507,9 +507,9 @@ export async function statusAllCommand(
lines.push(heading("Overview"));
lines.push(overview.trimEnd());
lines.push("");
lines.push(heading("Providers"));
lines.push(providersTable.trimEnd());
for (const detail of providers.details) {
lines.push(heading("Channels"));
lines.push(channelsTable.trimEnd());
for (const detail of channels.details) {
lines.push("");
lines.push(heading(detail.title));
lines.push(
@@ -690,23 +690,23 @@ export async function statusAllCommand(
}
progress.tick();
if (providersStatus) {
if (channelsStatus) {
emitCheck(
`Provider issues (${providerIssues.length || "none"})`,
providerIssues.length === 0 ? "ok" : "warn",
`Channel issues (${channelIssues.length || "none"})`,
channelIssues.length === 0 ? "ok" : "warn",
);
for (const issue of providerIssues.slice(0, 12)) {
for (const issue of channelIssues.slice(0, 12)) {
const fixText = issue.fix ? ` · fix: ${issue.fix}` : "";
lines.push(
` - ${issue.provider}[${issue.accountId}] ${issue.kind}: ${issue.message}${fixText}`,
` - ${issue.channel}[${issue.accountId}] ${issue.kind}: ${issue.message}${fixText}`,
);
}
if (providerIssues.length > 12) {
lines.push(` ${muted(`… +${providerIssues.length - 12} more`)}`);
if (channelIssues.length > 12) {
lines.push(` ${muted(`… +${channelIssues.length - 12} more`)}`);
}
} else {
emitCheck(
`Provider issues skipped (gateway ${gatewayReachable ? "query failed" : "unreachable"})`,
`Channel issues skipped (gateway ${gatewayReachable ? "query failed" : "unreachable"})`,
"warn",
);
}

View File

@@ -1,30 +1,29 @@
import crypto from "node:crypto";
import fs from "node:fs";
import type { ClawdbotConfig } from "../../config/config.js";
import { resolveProviderDefaultAccountId } from "../../providers/plugins/helpers.js";
import { listProviderPlugins } from "../../providers/plugins/index.js";
import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js";
import { listChannelPlugins } from "../../channels/plugins/index.js";
import type {
ProviderAccountSnapshot,
ProviderId,
ProviderPlugin,
} from "../../providers/plugins/types.js";
ChannelAccountSnapshot,
ChannelId,
ChannelPlugin,
} from "../../channels/plugins/types.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { formatAge } from "./format.js";
export type ProviderRow = {
id: ProviderId;
provider: string;
export type ChannelRow = {
id: ChannelId;
label: string;
enabled: boolean;
state: "ok" | "setup" | "warn" | "off";
detail: string;
};
type ProviderAccountRow = {
type ChannelAccountRow = {
accountId: string;
account: unknown;
enabled: boolean;
configured: boolean;
snapshot: ProviderAccountSnapshot;
snapshot: ChannelAccountSnapshot;
};
const asRecord = (value: unknown): Record<string, unknown> =>
@@ -81,7 +80,7 @@ const formatAccountLabel = (params: { accountId: string; name?: string }) => {
};
const resolveAccountEnabled = (
plugin: ProviderPlugin,
plugin: ChannelPlugin,
account: unknown,
cfg: ClawdbotConfig,
): boolean => {
@@ -91,7 +90,7 @@ const resolveAccountEnabled = (
};
const resolveAccountConfigured = async (
plugin: ProviderPlugin,
plugin: ChannelPlugin,
account: unknown,
cfg: ClawdbotConfig,
): Promise<boolean> => {
@@ -103,13 +102,13 @@ const resolveAccountConfigured = async (
};
const buildAccountSnapshot = (params: {
plugin: ProviderPlugin;
plugin: ChannelPlugin;
account: unknown;
cfg: ClawdbotConfig;
accountId: string;
enabled: boolean;
configured: boolean;
}): ProviderAccountSnapshot => {
}): ChannelAccountSnapshot => {
const described = params.plugin.config.describeAccount?.(
params.account,
params.cfg,
@@ -123,7 +122,7 @@ const buildAccountSnapshot = (params: {
};
const formatAllowFrom = (params: {
plugin: ProviderPlugin;
plugin: ChannelPlugin;
cfg: ClawdbotConfig;
accountId?: string | null;
allowFrom: Array<string | number>;
@@ -139,9 +138,9 @@ const formatAllowFrom = (params: {
};
const buildAccountNotes = (params: {
plugin: ProviderPlugin;
plugin: ChannelPlugin;
cfg: ClawdbotConfig;
entry: ProviderAccountRow;
entry: ChannelAccountRow;
}) => {
const { plugin, cfg, entry } = params;
const notes: string[] = [];
@@ -192,7 +191,7 @@ function resolveLinkFields(summary: unknown): {
return { linked, authAgeMs, selfE164 };
}
function collectMissingPaths(accounts: ProviderAccountRow[]): string[] {
function collectMissingPaths(accounts: ChannelAccountRow[]): string[] {
const missing: string[] = [];
for (const entry of accounts) {
const accountRec = asRecord(entry.account);
@@ -216,9 +215,9 @@ function collectMissingPaths(accounts: ProviderAccountRow[]): string[] {
}
function summarizeTokenConfig(params: {
plugin: ProviderPlugin;
plugin: ChannelPlugin;
cfg: ClawdbotConfig;
accounts: ProviderAccountRow[];
accounts: ChannelAccountRow[];
showSecrets: boolean;
}): { state: "ok" | "setup" | "warn" | null; detail: string | null } {
const enabled = params.accounts.filter((a) => a.enabled);
@@ -308,13 +307,13 @@ function summarizeTokenConfig(params: {
};
}
// `status --all` providers table.
// Keep this generic: provider-specific rules belong in the provider plugin.
export async function buildProvidersTable(
// `status --all` channels table.
// Keep this generic: channel-specific rules belong in the channel plugin.
export async function buildChannelsTable(
cfg: ClawdbotConfig,
opts?: { showSecrets?: boolean },
): Promise<{
rows: ProviderRow[];
rows: ChannelRow[];
details: Array<{
title: string;
columns: string[];
@@ -322,16 +321,16 @@ export async function buildProvidersTable(
}>;
}> {
const showSecrets = opts?.showSecrets === true;
const rows: ProviderRow[] = [];
const rows: ChannelRow[] = [];
const details: Array<{
title: string;
columns: string[];
rows: Array<Record<string, string>>;
}> = [];
for (const plugin of listProviderPlugins()) {
for (const plugin of listChannelPlugins()) {
const accountIds = plugin.config.listAccountIds(cfg);
const defaultAccountId = resolveProviderDefaultAccountId({
const defaultAccountId = resolveChannelDefaultAccountId({
plugin,
cfg,
accountIds,
@@ -339,7 +338,7 @@ export async function buildProvidersTable(
const resolvedAccountIds =
accountIds.length > 0 ? accountIds : [defaultAccountId];
const accounts: ProviderAccountRow[] = [];
const accounts: ChannelAccountRow[] = [];
for (const accountId of resolvedAccountIds) {
const account = plugin.config.resolveAccount(cfg, accountId);
const enabled = resolveAccountEnabled(plugin, account, cfg);
@@ -361,14 +360,14 @@ export async function buildProvidersTable(
const defaultEntry =
accounts.find((a) => a.accountId === defaultAccountId) ?? accounts[0];
const summary = plugin.status?.buildProviderSummary
? await plugin.status.buildProviderSummary({
const summary = plugin.status?.buildChannelSummary
? await plugin.status.buildChannelSummary({
account: defaultEntry?.account ?? {},
cfg,
defaultAccountId,
snapshot:
defaultEntry?.snapshot ??
({ accountId: defaultAccountId } as ProviderAccountSnapshot),
({ accountId: defaultAccountId } as ChannelAccountSnapshot),
})
: undefined;
@@ -440,7 +439,7 @@ export async function buildProvidersTable(
rows.push({
id: plugin.id,
provider: label,
label,
enabled: anyEnabled,
state,
detail,

View File

@@ -39,8 +39,8 @@ vi.mock("../config/sessions.js", () => ({
resolveMainSessionKey: mocks.resolveMainSessionKey,
resolveStorePath: mocks.resolveStorePath,
}));
vi.mock("../providers/plugins/index.js", () => ({
listProviderPlugins: () =>
vi.mock("../channels/plugins/index.js", () => ({
listChannelPlugins: () =>
[
{
id: "whatsapp",
@@ -56,7 +56,7 @@ vi.mock("../providers/plugins/index.js", () => ({
resolveAccount: () => ({}),
},
status: {
buildProviderSummary: async () => ({ linked: true, authAgeMs: 5000 }),
buildChannelSummary: async () => ({ linked: true, authAgeMs: 5000 }),
},
},
{
@@ -80,12 +80,12 @@ vi.mock("../providers/plugins/index.js", () => ({
typeof account.lastError === "string" && account.lastError,
)
.map((account) => ({
provider: "signal",
channel: "signal",
accountId:
typeof account.accountId === "string"
? account.accountId
: "default",
message: `Provider error: ${String(account.lastError)}`,
message: `Channel error: ${String(account.lastError)}`,
})),
},
},
@@ -110,12 +110,12 @@ vi.mock("../providers/plugins/index.js", () => ({
typeof account.lastError === "string" && account.lastError,
)
.map((account) => ({
provider: "imessage",
channel: "imessage",
accountId:
typeof account.accountId === "string"
? account.accountId
: "default",
message: `Provider error: ${String(account.lastError)}`,
message: `Channel error: ${String(account.lastError)}`,
})),
},
},
@@ -210,7 +210,7 @@ describe("statusCommand", () => {
it("prints JSON when requested", async () => {
await statusCommand({ json: true }, runtime as never);
const payload = JSON.parse((runtime.log as vi.Mock).mock.calls[0][0]);
expect(payload.linkProvider.linked).toBe(true);
expect(payload.linkChannel.linked).toBe(true);
expect(payload.sessions.count).toBe(1);
expect(payload.sessions.path).toBe("/tmp/sessions.json");
expect(payload.sessions.defaults.model).toBeTruthy();
@@ -228,7 +228,7 @@ describe("statusCommand", () => {
expect(logs.some((l) => l.includes("Overview"))).toBe(true);
expect(logs.some((l) => l.includes("Dashboard"))).toBe(true);
expect(logs.some((l) => l.includes("macos 14.0 (arm64)"))).toBe(true);
expect(logs.some((l) => l.includes("Providers"))).toBe(true);
expect(logs.some((l) => l.includes("Channels"))).toBe(true);
expect(logs.some((l) => l.includes("WhatsApp"))).toBe(true);
expect(logs.some((l) => l.includes("Sessions"))).toBe(true);
expect(logs.some((l) => l.includes("+1000"))).toBe(true);
@@ -265,7 +265,7 @@ describe("statusCommand", () => {
}
});
it("surfaces provider runtime errors from the gateway", async () => {
it("surfaces channel runtime errors from the gateway", async () => {
mocks.probeGateway.mockResolvedValueOnce({
ok: true,
url: "ws://127.0.0.1:18789",
@@ -278,7 +278,7 @@ describe("statusCommand", () => {
configSnapshot: null,
});
mocks.callGateway.mockResolvedValueOnce({
providerAccounts: {
channelAccounts: {
signal: [
{
accountId: "default",

View File

@@ -9,6 +9,13 @@ import {
DEFAULT_PROVIDER,
} from "../agents/defaults.js";
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import { listChannelPlugins } from "../channels/plugins/index.js";
import type {
ChannelAccountSnapshot,
ChannelId,
ChannelPlugin,
} from "../channels/plugins/types.js";
import { withProgress } from "../cli/progress.js";
import {
type ClawdbotConfig,
@@ -27,14 +34,14 @@ import { normalizeControlUiBasePath } from "../gateway/control-ui.js";
import { probeGateway } from "../gateway/probe.js";
import { listAgentsForGateway } from "../gateway/session-utils.js";
import { info } from "../globals.js";
import { buildChannelSummary } from "../infra/channel-summary.js";
import { collectChannelStatusIssues } from "../infra/channels-status-issues.js";
import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js";
import { resolveOsSummary } from "../infra/os-summary.js";
import { buildProviderSummary } from "../infra/provider-summary.js";
import {
formatUsageReportLines,
loadProviderUsageSummary,
} from "../infra/provider-usage.js";
import { collectProvidersStatusIssues } from "../infra/providers-status-issues.js";
import { peekSystemEvents } from "../infra/system-events.js";
import { getTailnetHostname } from "../infra/tailscale.js";
import {
@@ -43,22 +50,15 @@ import {
type UpdateCheckResult,
} from "../infra/update-check.js";
import { runExec } from "../process/exec.js";
import { resolveProviderDefaultAccountId } from "../providers/plugins/helpers.js";
import { listProviderPlugins } from "../providers/plugins/index.js";
import type {
ProviderAccountSnapshot,
ProviderId,
ProviderPlugin,
} from "../providers/plugins/types.js";
import type { RuntimeEnv } from "../runtime.js";
import { renderTable } from "../terminal/table.js";
import { theme } from "../terminal/theme.js";
import { VERSION } from "../version.js";
import { resolveHeartbeatSeconds } from "../web/reconnect.js";
import { formatHealthProviderLines, type HealthSummary } from "./health.js";
import { formatHealthChannelLines, type HealthSummary } from "./health.js";
import { resolveControlUiLinks } from "./onboard-helpers.js";
import { buildChannelsTable } from "./status-all/channels.js";
import { formatGatewayAuthUsed } from "./status-all/format.js";
import { buildProvidersTable } from "./status-all/providers.js";
import { statusAllCommand } from "./status-all.js";
export type SessionStatus = {
@@ -84,14 +84,14 @@ export type SessionStatus = {
};
export type StatusSummary = {
linkProvider?: {
id: ProviderId;
linkChannel?: {
id: ChannelId;
label: string;
linked: boolean;
authAgeMs: number | null;
};
heartbeatSeconds: number;
providerSummary: string[];
channelSummary: string[];
queuedSystemEvents: string[];
sessions: {
path: string;
@@ -101,20 +101,20 @@ export type StatusSummary = {
};
};
type LinkProviderContext = {
type LinkChannelContext = {
linked: boolean;
authAgeMs: number | null;
account?: unknown;
accountId?: string;
plugin: ProviderPlugin;
plugin: ChannelPlugin;
};
async function resolveLinkProviderContext(
async function resolveLinkChannelContext(
cfg: ClawdbotConfig,
): Promise<LinkProviderContext | null> {
for (const plugin of listProviderPlugins()) {
): Promise<LinkChannelContext | null> {
for (const plugin of listChannelPlugins()) {
const accountIds = plugin.config.listAccountIds(cfg);
const defaultAccountId = resolveProviderDefaultAccountId({
const defaultAccountId = resolveChannelDefaultAccountId({
plugin,
cfg,
accountIds,
@@ -132,9 +132,9 @@ async function resolveLinkProviderContext(
accountId: defaultAccountId,
enabled,
configured,
} as ProviderAccountSnapshot);
const summary = plugin.status?.buildProviderSummary
? await plugin.status.buildProviderSummary({
} as ChannelAccountSnapshot);
const summary = plugin.status?.buildChannelSummary
? await plugin.status.buildChannelSummary({
account,
cfg,
defaultAccountId,
@@ -158,9 +158,9 @@ async function resolveLinkProviderContext(
export async function getStatusSummary(): Promise<StatusSummary> {
const cfg = loadConfig();
const linkContext = await resolveLinkProviderContext(cfg);
const linkContext = await resolveLinkChannelContext(cfg);
const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined);
const providerSummary = await buildProviderSummary(cfg, {
const channelSummary = await buildChannelSummary(cfg, {
colorize: true,
includeAllowFrom: true,
});
@@ -228,16 +228,16 @@ export async function getStatusSummary(): Promise<StatusSummary> {
const recent = sessions.slice(0, 5);
return {
linkProvider: linkContext
linkChannel: linkContext
? {
id: linkContext.plugin.id,
label: linkContext.plugin.meta.label ?? "Provider",
label: linkContext.plugin.meta.label ?? "Channel",
linked: linkContext.linked,
authAgeMs: linkContext.authAgeMs,
}
: undefined,
heartbeatSeconds,
providerSummary,
channelSummary,
queuedSystemEvents,
sessions: {
path: storePath,
@@ -675,10 +675,10 @@ export async function statusCommand(
: null;
progress.tick();
progress.setLabel("Querying provider status…");
const providersStatus = gatewayReachable
progress.setLabel("Querying channel status…");
const channelsStatus = gatewayReachable
? await callGateway<Record<string, unknown>>({
method: "providers.status",
method: "channels.status",
params: {
probe: false,
timeoutMs: Math.min(8000, opts.timeoutMs ?? 10_000),
@@ -689,13 +689,13 @@ export async function statusCommand(
),
}).catch(() => null)
: null;
const providerIssues = providersStatus
? collectProvidersStatusIssues(providersStatus)
const channelIssues = channelsStatus
? collectChannelStatusIssues(channelsStatus)
: [];
progress.tick();
progress.setLabel("Summarizing providers…");
const providers = await buildProvidersTable(cfg, {
progress.setLabel("Summarizing channels…");
const channels = await buildChannelsTable(cfg, {
// Show token previews in regular status; keep `status --all` redacted.
// Set `CLAWDBOT_SHOW_SECRETS=0` to force redaction.
showSecrets: process.env.CLAWDBOT_SHOW_SECRETS?.trim() !== "0",
@@ -722,9 +722,9 @@ export async function statusCommand(
gatewayProbe,
gatewayReachable,
gatewaySelf,
providerIssues,
channelIssues,
agentStatus,
providers,
channels,
summary,
};
},
@@ -743,9 +743,9 @@ export async function statusCommand(
gatewayProbe,
gatewayReachable,
gatewaySelf,
providerIssues,
channelIssues,
agentStatus,
providers,
channels,
summary,
} = scan;
const usage = opts.usage
@@ -932,11 +932,11 @@ export async function statusCommand(
);
runtime.log("");
runtime.log(theme.heading("Providers"));
const providerIssuesByProvider = (() => {
const map = new Map<string, typeof providerIssues>();
for (const issue of providerIssues) {
const key = issue.provider;
runtime.log(theme.heading("Channels"));
const channelIssuesByChannel = (() => {
const map = new Map<string, typeof channelIssues>();
for (const issue of channelIssues) {
const key = issue.channel;
const list = map.get(key);
if (list) list.push(issue);
else map.set(key, [issue]);
@@ -947,13 +947,13 @@ export async function statusCommand(
renderTable({
width: tableWidth,
columns: [
{ key: "Provider", header: "Provider", minWidth: 10 },
{ key: "Channel", header: "Channel", minWidth: 10 },
{ key: "Enabled", header: "Enabled", minWidth: 7 },
{ key: "State", header: "State", minWidth: 8 },
{ key: "Detail", header: "Detail", flex: true, minWidth: 24 },
],
rows: providers.rows.map((row) => {
const issues = providerIssuesByProvider.get(row.id) ?? [];
rows: channels.rows.map((row) => {
const issues = channelIssuesByChannel.get(row.id) ?? [];
const effectiveState =
row.state === "off" ? "off" : issues.length > 0 ? "warn" : row.state;
const issueSuffix =
@@ -961,7 +961,7 @@ export async function statusCommand(
? ` · ${warn(`gateway: ${shortenText(issues[0]?.message ?? "issue", 84)}`)}`
: "";
return {
Provider: row.provider,
Channel: row.label,
Enabled: row.enabled ? ok("ON") : muted("OFF"),
State:
effectiveState === "ok"
@@ -1032,15 +1032,15 @@ export async function statusCommand(
runtime.log(theme.heading("Health"));
const rows: Array<Record<string, string>> = [];
rows.push({
Provider: "Gateway",
Item: "Gateway",
Status: ok("reachable"),
Detail: `${health.durationMs}ms`,
});
for (const line of formatHealthProviderLines(health)) {
for (const line of formatHealthChannelLines(health)) {
const colon = line.indexOf(":");
if (colon === -1) continue;
const provider = line.slice(0, colon).trim();
const item = line.slice(0, colon).trim();
const detail = line.slice(colon + 1).trim();
const normalized = detail.toLowerCase();
const status = (() => {
@@ -1052,14 +1052,14 @@ export async function statusCommand(
if (normalized.startsWith("not linked")) return warn("UNLINKED");
return warn("WARN");
})();
rows.push({ Provider: provider, Status: status, Detail: detail });
rows.push({ Item: item, Status: status, Detail: detail });
}
runtime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "Provider", header: "Provider", minWidth: 10 },
{ key: "Item", header: "Item", minWidth: 10 },
{ key: "Status", header: "Status", minWidth: 8 },
{ key: "Detail", header: "Detail", flex: true, minWidth: 28 },
],
@@ -1084,7 +1084,7 @@ export async function statusCommand(
runtime.log(" Need to share? clawdbot status --all");
runtime.log(" Need to debug live? clawdbot logs --follow");
if (gatewayReachable) {
runtime.log(" Need to test providers? clawdbot status --deep");
runtime.log(" Need to test channels? clawdbot status --deep");
} else {
runtime.log(" Fix reachability first: clawdbot gateway status");
}