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

68
src/cli/channel-auth.ts Normal file
View File

@@ -0,0 +1,68 @@
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import {
getChannelPlugin,
normalizeChannelId,
} from "../channels/plugins/index.js";
import { DEFAULT_CHAT_CHANNEL } from "../channels/registry.js";
import { loadConfig } from "../config/config.js";
import { setVerbose } from "../globals.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
type ChannelAuthOptions = {
channel?: string;
account?: string;
verbose?: boolean;
};
export async function runChannelLogin(
opts: ChannelAuthOptions,
runtime: RuntimeEnv = defaultRuntime,
) {
const channelInput = opts.channel ?? DEFAULT_CHAT_CHANNEL;
const channelId = normalizeChannelId(channelInput);
if (!channelId) {
throw new Error(`Unsupported channel: ${channelInput}`);
}
const plugin = getChannelPlugin(channelId);
if (!plugin?.auth?.login) {
throw new Error(`Channel ${channelId} does not support login`);
}
// Auth-only flow: do not mutate channel config here.
setVerbose(Boolean(opts.verbose));
const cfg = loadConfig();
const accountId =
opts.account?.trim() || resolveChannelDefaultAccountId({ plugin, cfg });
await plugin.auth.login({
cfg,
accountId,
runtime,
verbose: Boolean(opts.verbose),
channelInput,
});
}
export async function runChannelLogout(
opts: ChannelAuthOptions,
runtime: RuntimeEnv = defaultRuntime,
) {
const channelInput = opts.channel ?? DEFAULT_CHAT_CHANNEL;
const channelId = normalizeChannelId(channelInput);
if (!channelId) {
throw new Error(`Unsupported channel: ${channelInput}`);
}
const plugin = getChannelPlugin(channelId);
if (!plugin?.gateway?.logoutAccount) {
throw new Error(`Channel ${channelId} does not support logout`);
}
// Auth-only flow: resolve account + clear session state only.
const cfg = loadConfig();
const accountId =
opts.account?.trim() || resolveChannelDefaultAccountId({ plugin, cfg });
const account = plugin.config.resolveAccount(cfg, accountId);
await plugin.gateway.logoutAccount({
cfg,
accountId,
account,
runtime,
});
}

View File

@@ -1,22 +1,21 @@
import type { Command } from "commander";
import { listChatChannels } from "../channels/registry.js";
import {
providersAddCommand,
providersListCommand,
providersLogsCommand,
providersRemoveCommand,
providersStatusCommand,
} from "../commands/providers.js";
channelsAddCommand,
channelsListCommand,
channelsLogsCommand,
channelsRemoveCommand,
channelsStatusCommand,
} from "../commands/channels.js";
import { danger } from "../globals.js";
import { listChatProviders } from "../providers/registry.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
import { runChannelLogin, runChannelLogout } from "./channel-auth.js";
import { hasExplicitOptions } from "./command-options.js";
import { runProviderLogin, runProviderLogout } from "./provider-auth.js";
const optionNamesAdd = [
"provider",
"channel",
"account",
"name",
"token",
@@ -35,17 +34,16 @@ const optionNamesAdd = [
"useEnv",
] as const;
const optionNamesRemove = ["provider", "account", "delete"] as const;
const optionNamesRemove = ["channel", "account", "delete"] as const;
const providerNames = listChatProviders()
const channelNames = listChatChannels()
.map((meta) => meta.id)
.join("|");
export function registerProvidersCli(program: Command) {
const providers = program
.command("providers")
.alias("provider")
.description("Manage chat provider accounts")
export function registerChannelsCli(program: Command) {
const channels = program
.command("channels")
.description("Manage chat channel accounts")
.addHelpText(
"after",
() =>
@@ -55,54 +53,54 @@ export function registerProvidersCli(program: Command) {
)}\n`,
);
providers
channels
.command("list")
.description("List configured providers + auth profiles")
.option("--no-usage", "Skip provider usage/quota snapshots")
.description("List configured channels + auth profiles")
.option("--no-usage", "Skip model provider usage/quota snapshots")
.option("--json", "Output JSON", false)
.action(async (opts) => {
try {
await providersListCommand(opts, defaultRuntime);
await channelsListCommand(opts, defaultRuntime);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
providers
channels
.command("status")
.description("Show gateway provider status (use status --deep for local)")
.option("--probe", "Probe provider credentials", false)
.description("Show gateway channel status (use status --deep for local)")
.option("--probe", "Probe channel credentials", false)
.option("--timeout <ms>", "Timeout in ms", "10000")
.option("--json", "Output JSON", false)
.action(async (opts) => {
try {
await providersStatusCommand(opts, defaultRuntime);
await channelsStatusCommand(opts, defaultRuntime);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
providers
channels
.command("logs")
.description("Show recent provider logs from the gateway log file")
.option("--provider <name>", `Provider (${providerNames}|all)`, "all")
.description("Show recent channel logs from the gateway log file")
.option("--channel <name>", `Channel (${channelNames}|all)`, "all")
.option("--lines <n>", "Number of lines (default: 200)", "200")
.option("--json", "Output JSON", false)
.action(async (opts) => {
try {
await providersLogsCommand(opts, defaultRuntime);
await channelsLogsCommand(opts, defaultRuntime);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
providers
channels
.command("add")
.description("Add or update a provider account")
.option("--provider <name>", `Provider (${providerNames})`)
.description("Add or update a channel account")
.option("--channel <name>", `Channel (${channelNames})`)
.option("--account <id>", "Account id (default when omitted)")
.option("--name <name>", "Display name for this account")
.option("--token <token>", "Bot token (Telegram/Discord)")
@@ -122,67 +120,67 @@ export function registerProvidersCli(program: Command) {
.action(async (opts, command) => {
try {
const hasFlags = hasExplicitOptions(command, optionNamesAdd);
await providersAddCommand(opts, defaultRuntime, { hasFlags });
await channelsAddCommand(opts, defaultRuntime, { hasFlags });
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
providers
channels
.command("remove")
.description("Disable or delete a provider account")
.option("--provider <name>", `Provider (${providerNames})`)
.description("Disable or delete a channel account")
.option("--channel <name>", `Channel (${channelNames})`)
.option("--account <id>", "Account id (default when omitted)")
.option("--delete", "Delete config entries (no prompt)", false)
.action(async (opts, command) => {
try {
const hasFlags = hasExplicitOptions(command, optionNamesRemove);
await providersRemoveCommand(opts, defaultRuntime, { hasFlags });
await channelsRemoveCommand(opts, defaultRuntime, { hasFlags });
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
providers
channels
.command("login")
.description("Link a provider account (WhatsApp Web only)")
.option("--provider <provider>", "Provider alias (default: whatsapp)")
.description("Link a channel account (WhatsApp Web only)")
.option("--channel <channel>", "Channel alias (default: whatsapp)")
.option("--account <id>", "WhatsApp account id (accountId)")
.option("--verbose", "Verbose connection logs", false)
.action(async (opts) => {
try {
await runProviderLogin(
await runChannelLogin(
{
provider: opts.provider as string | undefined,
channel: opts.channel as string | undefined,
account: opts.account as string | undefined,
verbose: Boolean(opts.verbose),
},
defaultRuntime,
);
} catch (err) {
defaultRuntime.error(danger(`Provider login failed: ${String(err)}`));
defaultRuntime.error(danger(`Channel login failed: ${String(err)}`));
defaultRuntime.exit(1);
}
});
providers
channels
.command("logout")
.description("Log out of a provider session (if supported)")
.option("--provider <provider>", "Provider alias (default: whatsapp)")
.description("Log out of a channel session (if supported)")
.option("--channel <channel>", "Channel alias (default: whatsapp)")
.option("--account <id>", "Account id (accountId)")
.action(async (opts) => {
try {
await runProviderLogout(
await runChannelLogout(
{
provider: opts.provider as string | undefined,
channel: opts.channel as string | undefined,
account: opts.account as string | undefined,
},
defaultRuntime,
);
} catch (err) {
defaultRuntime.error(danger(`Provider logout failed: ${String(err)}`));
defaultRuntime.error(danger(`Channel logout failed: ${String(err)}`));
defaultRuntime.exit(1);
}
});

View File

@@ -1,8 +1,8 @@
import type { Command } from "commander";
import { CHANNEL_IDS } from "../channels/registry.js";
import { parseAbsoluteTimeMs } from "../cron/parse.js";
import type { CronJob, CronSchedule } from "../cron/types.js";
import { danger } from "../globals.js";
import { PROVIDER_IDS } from "../providers/registry.js";
import { normalizeAgentId } from "../routing/session-key.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
@@ -10,7 +10,7 @@ import { colorize, isRich, theme } from "../terminal/theme.js";
import type { GatewayRpcOpts } from "./gateway-rpc.js";
import { addGatewayClientOptions, callGatewayFromCli } from "./gateway-rpc.js";
const CRON_PROVIDER_OPTIONS = ["last", ...PROVIDER_IDS].join("|");
const CRON_CHANNEL_OPTIONS = ["last", ...CHANNEL_IDS].join("|");
async function warnIfCronSchedulerDisabled(opts: GatewayRpcOpts) {
try {
@@ -322,8 +322,8 @@ export function registerCronCli(program: Command) {
.option("--timeout-seconds <n>", "Timeout seconds for agent jobs")
.option("--deliver", "Deliver agent output", false)
.option(
"--provider <provider>",
`Delivery provider (${CRON_PROVIDER_OPTIONS})`,
"--channel <channel>",
`Delivery channel (${CRON_CHANNEL_OPTIONS})`,
"last",
)
.option(
@@ -432,8 +432,7 @@ export function registerCronCli(program: Command) {
? timeoutSeconds
: undefined,
deliver: Boolean(opts.deliver),
provider:
typeof opts.provider === "string" ? opts.provider : "last",
channel: typeof opts.channel === "string" ? opts.channel : "last",
to:
typeof opts.to === "string" && opts.to.trim()
? opts.to.trim()
@@ -604,8 +603,8 @@ export function registerCronCli(program: Command) {
.option("--timeout-seconds <n>", "Timeout seconds for agent jobs")
.option("--deliver", "Deliver agent output", false)
.option(
"--provider <provider>",
`Delivery provider (${CRON_PROVIDER_OPTIONS})`,
"--channel <channel>",
`Delivery channel (${CRON_CHANNEL_OPTIONS})`,
)
.option(
"--to <dest>",
@@ -717,8 +716,8 @@ export function registerCronCli(program: Command) {
? timeoutSeconds
: undefined,
deliver: Boolean(opts.deliver),
provider:
typeof opts.provider === "string" ? opts.provider : undefined,
channel:
typeof opts.channel === "string" ? opts.channel : undefined,
to: typeof opts.to === "string" ? opts.to : undefined,
bestEffortDeliver: Boolean(opts.bestEffortDeliver),
};

View File

@@ -57,7 +57,7 @@ import { colorize, isRich, theme } from "../terminal/theme.js";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
} from "../utils/message-provider.js";
} from "../utils/message-channel.js";
import { createDefaultDeps } from "./deps.js";
import { withProgress } from "./progress.js";

View File

@@ -1,9 +1,9 @@
import { logWebSelfId, sendMessageWhatsApp } from "../channels/web/index.js";
import type { ClawdbotConfig } from "../config/config.js";
import { sendMessageDiscord } from "../discord/send.js";
import { sendMessageIMessage } from "../imessage/send.js";
import type { OutboundSendDeps } from "../infra/outbound/deliver.js";
import { sendMessageMSTeams } from "../msteams/send.js";
import { logWebSelfId, sendMessageWhatsApp } from "../providers/web/index.js";
import { sendMessageSignal } from "../signal/send.js";
import { sendMessageSlack } from "../slack/send.js";
import { sendMessageTelegram } from "../telegram/send.js";

View File

@@ -6,7 +6,7 @@ import type { Command } from "commander";
import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js";
import { gatewayStatusCommand } from "../commands/gateway-status.js";
import {
formatHealthProviderLines,
formatHealthChannelLines,
type HealthSummary,
} from "../commands/health.js";
import { handleReset } from "../commands/onboard-helpers.js";
@@ -47,7 +47,7 @@ import { colorize, isRich, theme } from "../terminal/theme.js";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
} from "../utils/message-provider.js";
} from "../utils/message-channel.js";
import { resolveUserPath } from "../utils.js";
import { forceFreePortAndWait } from "./ports.js";
import { withProgress } from "./progress.js";
@@ -958,10 +958,8 @@ export function registerGatewayCli(program: Command) {
durationMs != null ? ` (${durationMs}ms)` : ""
}`,
);
if (obj.providers && typeof obj.providers === "object") {
for (const line of formatHealthProviderLines(
obj as HealthSummary,
)) {
if (obj.channels && typeof obj.channels === "object") {
for (const line of formatHealthChannelLines(obj as HealthSummary)) {
defaultRuntime.log(line);
}
}

View File

@@ -3,7 +3,7 @@ import { callGateway } from "../gateway/call.js";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
} from "../utils/message-provider.js";
} from "../utils/message-channel.js";
import { withProgress } from "./progress.js";
export type GatewayRpcOpts = {

View File

@@ -7,7 +7,7 @@ import { theme } from "../terminal/theme.js";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
} from "../utils/message-provider.js";
} from "../utils/message-channel.js";
import {
type CameraFacing,
cameraTempPath,

View File

@@ -1,27 +1,27 @@
import { Command } from "commander";
import { describe, expect, it, vi } from "vitest";
const listProviderPairingRequests = vi.fn();
const approveProviderPairingCode = vi.fn();
const listChannelPairingRequests = vi.fn();
const approveChannelPairingCode = vi.fn();
const notifyPairingApproved = vi.fn();
const pairingIdLabels: Record<string, string> = {
telegram: "telegramUserId",
discord: "discordUserId",
};
const requirePairingAdapter = vi.fn((provider: string) => ({
idLabel: pairingIdLabels[provider] ?? "userId",
const requirePairingAdapter = vi.fn((channel: string) => ({
idLabel: pairingIdLabels[channel] ?? "userId",
}));
const listPairingProviders = vi.fn(() => ["telegram", "discord"]);
const resolvePairingProvider = vi.fn((raw: string) => raw);
const listPairingChannels = vi.fn(() => ["telegram", "discord"]);
const resolvePairingChannel = vi.fn((raw: string) => raw);
vi.mock("../pairing/pairing-store.js", () => ({
listProviderPairingRequests,
approveProviderPairingCode,
listChannelPairingRequests,
approveChannelPairingCode,
}));
vi.mock("../providers/plugins/pairing.js", () => ({
listPairingProviders,
resolvePairingProvider,
vi.mock("../channels/plugins/pairing.js", () => ({
listPairingChannels,
resolvePairingChannel,
notifyPairingApproved,
requirePairingAdapter,
}));
@@ -33,7 +33,7 @@ vi.mock("../config/config.js", () => ({
describe("pairing cli", () => {
it("labels Telegram ids as telegramUserId", async () => {
const { registerPairingCli } = await import("./pairing-cli.js");
listProviderPairingRequests.mockResolvedValueOnce([
listChannelPairingRequests.mockResolvedValueOnce([
{
id: "123",
code: "ABC123",
@@ -47,7 +47,7 @@ describe("pairing cli", () => {
const program = new Command();
program.name("test");
registerPairingCli(program);
await program.parseAsync(["pairing", "list", "--provider", "telegram"], {
await program.parseAsync(["pairing", "list", "--channel", "telegram"], {
from: "user",
});
expect(log).toHaveBeenCalledWith(
@@ -55,21 +55,21 @@ describe("pairing cli", () => {
);
});
it("accepts provider as positional for list", async () => {
it("accepts channel as positional for list", async () => {
const { registerPairingCli } = await import("./pairing-cli.js");
listProviderPairingRequests.mockResolvedValueOnce([]);
listChannelPairingRequests.mockResolvedValueOnce([]);
const program = new Command();
program.name("test");
registerPairingCli(program);
await program.parseAsync(["pairing", "list", "telegram"], { from: "user" });
expect(listProviderPairingRequests).toHaveBeenCalledWith("telegram");
expect(listChannelPairingRequests).toHaveBeenCalledWith("telegram");
});
it("labels Discord ids as discordUserId", async () => {
const { registerPairingCli } = await import("./pairing-cli.js");
listProviderPairingRequests.mockResolvedValueOnce([
listChannelPairingRequests.mockResolvedValueOnce([
{
id: "999",
code: "DEF456",
@@ -83,7 +83,7 @@ describe("pairing cli", () => {
const program = new Command();
program.name("test");
registerPairingCli(program);
await program.parseAsync(["pairing", "list", "--provider", "discord"], {
await program.parseAsync(["pairing", "list", "--channel", "discord"], {
from: "user",
});
expect(log).toHaveBeenCalledWith(
@@ -91,9 +91,9 @@ describe("pairing cli", () => {
);
});
it("accepts provider as positional for approve (npm-run compatible)", async () => {
it("accepts channel as positional for approve (npm-run compatible)", async () => {
const { registerPairingCli } = await import("./pairing-cli.js");
approveProviderPairingCode.mockResolvedValueOnce({
approveChannelPairingCode.mockResolvedValueOnce({
id: "123",
entry: {
id: "123",
@@ -111,8 +111,8 @@ describe("pairing cli", () => {
from: "user",
});
expect(approveProviderPairingCode).toHaveBeenCalledWith({
provider: "telegram",
expect(approveChannelPairingCode).toHaveBeenCalledWith({
channel: "telegram",
code: "ABCDEFGH",
});
expect(log).toHaveBeenCalledWith(expect.stringContaining("Approved"));

View File

@@ -1,27 +1,26 @@
import type { Command } from "commander";
import {
listPairingChannels,
notifyPairingApproved,
resolvePairingChannel,
} from "../channels/plugins/pairing.js";
import { loadConfig } from "../config/config.js";
import { resolvePairingIdLabel } from "../pairing/pairing-labels.js";
import {
approveProviderPairingCode,
listProviderPairingRequests,
type PairingProvider,
approveChannelPairingCode,
listChannelPairingRequests,
type PairingChannel,
} from "../pairing/pairing-store.js";
import {
listPairingProviders,
notifyPairingApproved,
resolvePairingProvider,
} from "../providers/plugins/pairing.js";
const PROVIDERS: PairingProvider[] = listPairingProviders();
const CHANNELS: PairingChannel[] = listPairingChannels();
function parseProvider(raw: unknown): PairingProvider {
return resolvePairingProvider(raw);
function parseChannel(raw: unknown): PairingChannel {
return resolvePairingChannel(raw);
}
async function notifyApproved(provider: PairingProvider, id: string) {
async function notifyApproved(channel: PairingChannel, id: string) {
const cfg = loadConfig();
await notifyPairingApproved({ providerId: provider, id, cfg });
await notifyPairingApproved({ channelId: channel, id, cfg });
}
export function registerPairingCli(program: Command) {
@@ -32,29 +31,29 @@ export function registerPairingCli(program: Command) {
pairing
.command("list")
.description("List pending pairing requests")
.option("--provider <provider>", `Provider (${PROVIDERS.join(", ")})`)
.argument("[provider]", `Provider (${PROVIDERS.join(", ")})`)
.option("--channel <channel>", `Channel (${CHANNELS.join(", ")})`)
.argument("[channel]", `Channel (${CHANNELS.join(", ")})`)
.option("--json", "Print JSON", false)
.action(async (providerArg, opts) => {
const providerRaw = opts.provider ?? providerArg;
if (!providerRaw) {
.action(async (channelArg, opts) => {
const channelRaw = opts.channel ?? channelArg;
if (!channelRaw) {
throw new Error(
`Provider required. Use --provider <provider> or pass it as the first argument (expected one of: ${PROVIDERS.join(", ")})`,
`Channel required. Use --channel <channel> or pass it as the first argument (expected one of: ${CHANNELS.join(", ")})`,
);
}
const provider = parseProvider(providerRaw);
const requests = await listProviderPairingRequests(provider);
const channel = parseChannel(channelRaw);
const requests = await listChannelPairingRequests(channel);
if (opts.json) {
console.log(JSON.stringify({ provider, requests }, null, 2));
console.log(JSON.stringify({ channel, requests }, null, 2));
return;
}
if (requests.length === 0) {
console.log(`No pending ${provider} pairing requests.`);
console.log(`No pending ${channel} pairing requests.`);
return;
}
for (const r of requests) {
const meta = r.meta ? JSON.stringify(r.meta) : "";
const idLabel = resolvePairingIdLabel(provider);
const idLabel = resolvePairingIdLabel(channel);
console.log(
`${r.code} ${idLabel}=${r.id}${meta ? ` meta=${meta}` : ""} ${r.createdAt}`,
);
@@ -64,29 +63,26 @@ export function registerPairingCli(program: Command) {
pairing
.command("approve")
.description("Approve a pairing code and allow that sender")
.option("--provider <provider>", `Provider (${PROVIDERS.join(", ")})`)
.argument(
"<codeOrProvider>",
"Pairing code (or provider when using 2 args)",
)
.argument("[code]", "Pairing code (when provider is passed as the 1st arg)")
.option("--notify", "Notify the requester on the same provider", false)
.action(async (codeOrProvider, code, opts) => {
const providerRaw = opts.provider ?? codeOrProvider;
const resolvedCode = opts.provider ? codeOrProvider : code;
if (!opts.provider && !code) {
.option("--channel <channel>", `Channel (${CHANNELS.join(", ")})`)
.argument("<codeOrChannel>", "Pairing code (or channel when using 2 args)")
.argument("[code]", "Pairing code (when channel is passed as the 1st arg)")
.option("--notify", "Notify the requester on the same channel", false)
.action(async (codeOrChannel, code, opts) => {
const channelRaw = opts.channel ?? codeOrChannel;
const resolvedCode = opts.channel ? codeOrChannel : code;
if (!opts.channel && !code) {
throw new Error(
`Usage: clawdbot pairing approve <provider> <code> (or: clawdbot pairing approve --provider <provider> <code>)`,
`Usage: clawdbot pairing approve <channel> <code> (or: clawdbot pairing approve --channel <channel> <code>)`,
);
}
if (opts.provider && code != null) {
if (opts.channel && code != null) {
throw new Error(
`Too many arguments. Use: clawdbot pairing approve --provider <provider> <code>`,
`Too many arguments. Use: clawdbot pairing approve --channel <channel> <code>`,
);
}
const provider = parseProvider(providerRaw);
const approved = await approveProviderPairingCode({
provider,
const channel = parseChannel(channelRaw);
const approved = await approveChannelPairingCode({
channel,
code: String(resolvedCode),
});
if (!approved) {
@@ -95,10 +91,10 @@ export function registerPairingCli(program: Command) {
);
}
console.log(`Approved ${provider} sender ${approved.id}.`);
console.log(`Approved ${channel} sender ${approved.id}.`);
if (!opts.notify) return;
await notifyApproved(provider, approved.id).catch((err) => {
await notifyApproved(channel, approved.id).catch((err) => {
console.log(`Failed to notify requester: ${String(err)}`);
});
});

View File

@@ -8,8 +8,8 @@ const configureCommandWithSections = vi.fn();
const setupCommand = vi.fn();
const onboardCommand = vi.fn();
const callGateway = vi.fn();
const runProviderLogin = vi.fn();
const runProviderLogout = vi.fn();
const runChannelLogin = vi.fn();
const runChannelLogout = vi.fn();
const runTui = vi.fn();
const runtime = {
@@ -30,7 +30,7 @@ vi.mock("../commands/configure.js", () => ({
"model",
"gateway",
"daemon",
"providers",
"channels",
"skills",
"health",
],
@@ -40,9 +40,9 @@ vi.mock("../commands/configure.js", () => ({
vi.mock("../commands/setup.js", () => ({ setupCommand }));
vi.mock("../commands/onboard.js", () => ({ onboardCommand }));
vi.mock("../runtime.js", () => ({ defaultRuntime: runtime }));
vi.mock("./provider-auth.js", () => ({
runProviderLogin,
runProviderLogout,
vi.mock("./channel-auth.js", () => ({
runChannelLogin,
runChannelLogout,
}));
vi.mock("../tui/tui.js", () => ({
runTui,
@@ -248,33 +248,24 @@ describe("cli program", () => {
);
});
it("runs providers login", async () => {
it("runs channels login", async () => {
const program = buildProgram();
await program.parseAsync(["providers", "login", "--account", "work"], {
await program.parseAsync(["channels", "login", "--account", "work"], {
from: "user",
});
expect(runProviderLogin).toHaveBeenCalledWith(
{ provider: undefined, account: "work", verbose: false },
expect(runChannelLogin).toHaveBeenCalledWith(
{ channel: undefined, account: "work", verbose: false },
runtime,
);
});
it("runs providers logout", async () => {
it("runs channels logout", async () => {
const program = buildProgram();
await program.parseAsync(["providers", "logout", "--account", "work"], {
await program.parseAsync(["channels", "logout", "--account", "work"], {
from: "user",
});
expect(runProviderLogout).toHaveBeenCalledWith(
{ provider: undefined, account: "work" },
runtime,
);
});
it("runs hidden login alias", async () => {
const program = buildProgram();
await program.parseAsync(["login", "--account", "work"], { from: "user" });
expect(runProviderLogin).toHaveBeenCalledWith(
{ provider: undefined, account: "work", verbose: false },
expect(runChannelLogout).toHaveBeenCalledWith(
{ channel: undefined, account: "work" },
runtime,
);
});

View File

@@ -1,4 +1,6 @@
import { Command } from "commander";
import { listChannelPlugins } from "../channels/plugins/index.js";
import { DEFAULT_CHAT_CHANNEL } from "../channels/registry.js";
import { agentCliCommand } from "../commands/agent-via-gateway.js";
import {
agentsAddCommand,
@@ -30,8 +32,6 @@ import {
import { danger, setVerbose } from "../globals.js";
import { autoMigrateLegacyState } from "../infra/state-migrations.js";
import { registerPluginCliCommands } from "../plugins/cli.js";
import { listProviderPlugins } from "../providers/plugins/index.js";
import { DEFAULT_CHAT_PROVIDER } from "../providers/registry.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { isRich, theme } from "../terminal/theme.js";
@@ -42,6 +42,7 @@ import {
hasEmittedCliBanner,
} from "./banner.js";
import { registerBrowserCli } from "./browser-cli.js";
import { registerChannelsCli } from "./channels-cli.js";
import { hasExplicitOptions } from "./command-options.js";
import { registerCronCli } from "./cron-cli.js";
import { registerDaemonCli } from "./daemon-cli.js";
@@ -57,8 +58,6 @@ import { registerNodesCli } from "./nodes-cli.js";
import { registerPairingCli } from "./pairing-cli.js";
import { registerPluginsCli } from "./plugins-cli.js";
import { forceFreePort } from "./ports.js";
import { runProviderLogin, runProviderLogout } from "./provider-auth.js";
import { registerProvidersCli } from "./providers-cli.js";
import { registerSandboxCli } from "./sandbox-cli.js";
import { registerSkillsCli } from "./skills-cli.js";
import { registerTuiCli } from "./tui-cli.js";
@@ -73,9 +72,9 @@ function collectOption(value: string, previous: string[] = []): string[] {
export function buildProgram() {
const program = new Command();
const PROGRAM_VERSION = VERSION;
const providerOptions = listProviderPlugins().map((plugin) => plugin.id);
const messageProviderOptions = providerOptions.join("|");
const agentProviderOptions = ["last", ...providerOptions].join("|");
const channelOptions = listChannelPlugins().map((plugin) => plugin.id);
const messageChannelOptions = channelOptions.join("|");
const agentChannelOptions = ["last", ...channelOptions].join("|");
program
.name("clawdbot")
@@ -167,7 +166,7 @@ export function buildProgram() {
});
const examples = [
[
"clawdbot providers login --verbose",
"clawdbot channels login --verbose",
"Link personal WhatsApp Web and show QR + connection logs.",
],
[
@@ -189,7 +188,7 @@ export function buildProgram() {
"Talk directly to the agent using the Gateway; optionally send the WhatsApp reply.",
],
[
'clawdbot message send --provider telegram --to @mychat --message "Hi"',
'clawdbot message send --channel telegram --to @mychat --message "Hi"',
"Send via your Telegram bot.",
],
] as const;
@@ -304,7 +303,7 @@ export function buildProgram() {
.option("--no-install-daemon", "Skip gateway daemon install")
.option("--skip-daemon", "Skip gateway daemon install")
.option("--daemon-runtime <runtime>", "Daemon runtime: node|bun")
.option("--skip-providers", "Skip provider setup")
.option("--skip-channels", "Skip channel setup")
.option("--skip-skills", "Skip skills setup")
.option("--skip-health", "Skip health check")
.option("--skip-ui", "Skip Control UI/TUI prompts")
@@ -385,7 +384,7 @@ export function buildProgram() {
tailscaleResetOnExit: Boolean(opts.tailscaleResetOnExit),
installDaemon,
daemonRuntime: opts.daemonRuntime as "node" | "bun" | undefined,
skipProviders: Boolean(opts.skipProviders),
skipChannels: Boolean(opts.skipChannels),
skipSkills: Boolean(opts.skipSkills),
skipHealth: Boolean(opts.skipHealth),
skipUi: Boolean(opts.skipUi),
@@ -404,7 +403,7 @@ export function buildProgram() {
.command("configure")
.alias("config")
.description(
"Interactive wizard to update models, providers, skills, and gateway",
"Interactive wizard to update models, channels, skills, and gateway",
)
.option(
"--section <name>",
@@ -446,7 +445,7 @@ export function buildProgram() {
program
.command("doctor")
.description("Health checks + quick fixes for the gateway and providers")
.description("Health checks + quick fixes for the gateway and channels")
.option(
"--no-workspace-suggestions",
"Disable workspace memory system suggestions",
@@ -559,52 +558,9 @@ export function buildProgram() {
}
});
// Deprecated hidden aliases: use `clawdbot providers login/logout`. Remove in a future major.
program
.command("login", { hidden: true })
.description("Link your personal WhatsApp via QR (web provider)")
.option("--verbose", "Verbose connection logs", false)
.option("--provider <provider>", "Provider alias (default: whatsapp)")
.option("--account <id>", "WhatsApp account id (accountId)")
.action(async (opts) => {
try {
await runProviderLogin(
{
provider: opts.provider as string | undefined,
account: opts.account as string | undefined,
verbose: Boolean(opts.verbose),
},
defaultRuntime,
);
} catch (err) {
defaultRuntime.error(danger(`Web login failed: ${String(err)}`));
defaultRuntime.exit(1);
}
});
program
.command("logout", { hidden: true })
.description("Log out of WhatsApp Web (keeps config)")
.option("--provider <provider>", "Provider alias (default: whatsapp)")
.option("--account <id>", "WhatsApp account id (accountId)")
.action(async (opts) => {
try {
await runProviderLogout(
{
provider: opts.provider as string | undefined,
account: opts.account as string | undefined,
},
defaultRuntime,
);
} catch (err) {
defaultRuntime.error(danger(`Logout failed: ${String(err)}`));
defaultRuntime.exit(1);
}
});
const message = program
.command("message")
.description("Send messages and provider actions")
.description("Send messages and channel actions")
.addHelpText(
"after",
() =>
@@ -612,8 +568,8 @@ export function buildProgram() {
Examples:
clawdbot message send --to +15555550123 --message "Hi"
clawdbot message send --to +15555550123 --message "Hi" --media photo.jpg
clawdbot message poll --provider discord --to channel:123 --poll-question "Snack?" --poll-option Pizza --poll-option Sushi
clawdbot message react --provider discord --to 123 --message-id 456 --emoji "✅"
clawdbot message poll --channel discord --to channel:123 --poll-question "Snack?" --poll-option Pizza --poll-option Sushi
clawdbot message react --channel discord --to 123 --message-id 456 --emoji "✅"
${theme.muted("Docs:")} ${formatDocsLink(
"/cli/message",
@@ -626,8 +582,8 @@ ${theme.muted("Docs:")} ${formatDocsLink(
const withMessageBase = (command: Command) =>
command
.option("--provider <provider>", `Provider: ${messageProviderOptions}`)
.option("--account <id>", "Provider account id (accountId)")
.option("--channel <channel>", `Channel: ${messageChannelOptions}`)
.option("--account <id>", "Channel account id (accountId)")
.option("--json", "Output result as JSON", false)
.option("--dry-run", "Print payload and skip sending", false)
.option("--verbose", "Verbose logging", false);
@@ -1097,17 +1053,17 @@ ${theme.muted("Docs:")} ${formatDocsLink(
)
.option("--verbose <on|off>", "Persist agent verbose level for the session")
.option(
"--provider <provider>",
`Delivery provider: ${agentProviderOptions} (default: ${DEFAULT_CHAT_PROVIDER})`,
"--channel <channel>",
`Delivery channel: ${agentChannelOptions} (default: ${DEFAULT_CHAT_CHANNEL})`,
)
.option(
"--local",
"Run the embedded agent locally (requires provider API keys in your shell)",
"Run the embedded agent locally (requires model provider API keys in your shell)",
false,
)
.option(
"--deliver",
"Send the agent's reply back to the selected provider (requires --to)",
"Send the agent's reply back to the selected channel (requires --to)",
false,
)
.option("--json", "Output result as JSON", false)
@@ -1172,8 +1128,8 @@ ${theme.muted("Docs:")} ${formatDocsLink(
.option("--model <id>", "Model id for this agent")
.option("--agent-dir <dir>", "Agent state directory for this agent")
.option(
"--bind <provider[:accountId]>",
"Route provider binding (repeatable)",
"--bind <channel[:accountId]>",
"Route channel binding (repeatable)",
collectOption,
[],
)
@@ -1253,20 +1209,20 @@ ${theme.muted("Docs:")} ${formatDocsLink(
registerHooksCli(program);
registerPairingCli(program);
registerPluginsCli(program);
registerProvidersCli(program);
registerChannelsCli(program);
registerSkillsCli(program);
registerUpdateCli(program);
registerPluginCliCommands(program, loadConfig());
program
.command("status")
.description("Show provider health and recent session recipients")
.description("Show channel health and recent session recipients")
.option("--json", "Output JSON instead of text", false)
.option("--all", "Full diagnosis (read-only, pasteable)", false)
.option("--usage", "Show provider usage/quota snapshots", false)
.option("--usage", "Show model provider usage/quota snapshots", false)
.option(
"--deep",
"Probe providers (WhatsApp Web + Telegram + Discord + Slack + Signal)",
"Probe channels (WhatsApp Web + Telegram + Discord + Slack + Signal)",
false,
)
.option("--timeout <ms>", "Probe timeout in milliseconds", "10000")
@@ -1279,10 +1235,10 @@ Examples:
clawdbot status # show linked account + session store summary
clawdbot status --all # full diagnosis (read-only)
clawdbot status --json # machine-readable output
clawdbot status --usage # show provider usage/quota snapshots
clawdbot status --deep # run provider probes (WA + Telegram + Discord + Slack + Signal)
clawdbot status --usage # show model provider usage/quota snapshots
clawdbot status --deep # run channel probes (WA + Telegram + Discord + Slack + Signal)
clawdbot status --deep --timeout 5000 # tighten probe timeout
clawdbot providers status # gateway provider runtime + probes`,
clawdbot channels status # gateway channel runtime + probes`,
)
.action(async (opts) => {
const verbose = Boolean(opts.verbose || opts.debug);

View File

@@ -1,68 +0,0 @@
import { loadConfig } from "../config/config.js";
import { setVerbose } from "../globals.js";
import { resolveProviderDefaultAccountId } from "../providers/plugins/helpers.js";
import {
getProviderPlugin,
normalizeProviderId,
} from "../providers/plugins/index.js";
import { DEFAULT_CHAT_PROVIDER } from "../providers/registry.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
type ProviderAuthOptions = {
provider?: string;
account?: string;
verbose?: boolean;
};
export async function runProviderLogin(
opts: ProviderAuthOptions,
runtime: RuntimeEnv = defaultRuntime,
) {
const providerInput = opts.provider ?? DEFAULT_CHAT_PROVIDER;
const providerId = normalizeProviderId(providerInput);
if (!providerId) {
throw new Error(`Unsupported provider: ${providerInput}`);
}
const plugin = getProviderPlugin(providerId);
if (!plugin?.auth?.login) {
throw new Error(`Provider ${providerId} does not support login`);
}
// Auth-only flow: do not mutate provider config here.
setVerbose(Boolean(opts.verbose));
const cfg = loadConfig();
const accountId =
opts.account?.trim() || resolveProviderDefaultAccountId({ plugin, cfg });
await plugin.auth.login({
cfg,
accountId,
runtime,
verbose: Boolean(opts.verbose),
providerInput,
});
}
export async function runProviderLogout(
opts: ProviderAuthOptions,
runtime: RuntimeEnv = defaultRuntime,
) {
const providerInput = opts.provider ?? DEFAULT_CHAT_PROVIDER;
const providerId = normalizeProviderId(providerInput);
if (!providerId) {
throw new Error(`Unsupported provider: ${providerInput}`);
}
const plugin = getProviderPlugin(providerId);
if (!plugin?.gateway?.logoutAccount) {
throw new Error(`Provider ${providerId} does not support logout`);
}
// Auth-only flow: resolve account + clear session state only.
const cfg = loadConfig();
const accountId =
opts.account?.trim() || resolveProviderDefaultAccountId({ plugin, cfg });
const account = plugin.config.resolveAccount(cfg, accountId);
await plugin.gateway.logoutAccount({
cfg,
accountId,
account,
runtime,
});
}