refactor: split providers commands

This commit is contained in:
Peter Steinberger
2026-01-08 06:46:40 +01:00
parent da9bc80bf2
commit 9b152ecb12
7 changed files with 1380 additions and 1180 deletions

View File

@@ -7,6 +7,10 @@ const configMocks = vi.hoisted(() => ({
writeConfigFile: vi.fn().mockResolvedValue(undefined),
}));
const authMocks = vi.hoisted(() => ({
loadAuthProfileStore: vi.fn(),
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
@@ -16,9 +20,19 @@ vi.mock("../config/config.js", async (importOriginal) => {
};
});
vi.mock("../agents/auth-profiles.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../agents/auth-profiles.js")>();
return {
...actual,
loadAuthProfileStore: authMocks.loadAuthProfileStore,
};
});
import {
formatGatewayProvidersStatusLines,
providersAddCommand,
providersListCommand,
providersRemoveCommand,
} from "./providers.js";
@@ -43,9 +57,14 @@ describe("providers command", () => {
beforeEach(() => {
configMocks.readConfigFileSnapshot.mockReset();
configMocks.writeConfigFile.mockClear();
authMocks.loadAuthProfileStore.mockReset();
runtime.log.mockClear();
runtime.error.mockClear();
runtime.exit.mockClear();
authMocks.loadAuthProfileStore.mockReturnValue({
version: 1,
profiles: {},
});
});
it("adds a non-default telegram account", async () => {
@@ -116,6 +135,116 @@ describe("providers command", () => {
expect(next.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" },
runtime,
{ hasFlags: true },
);
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
whatsapp?: { accounts?: Record<string, { name?: string }> };
};
expect(next.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" },
},
},
},
});
await providersAddCommand(
{
provider: "signal",
account: "lab",
name: "Lab",
signalNumber: "+15555550123",
},
runtime,
{ hasFlags: true },
);
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
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");
});
it("disables a default provider account when remove has no delete flag", async () => {
configMocks.readConfigFileSnapshot.mockResolvedValue({
...baseSnapshot,
config: {
discord: { token: "d0", enabled: true },
},
});
const prompt = { confirm: vi.fn().mockResolvedValue(true) };
const prompterModule = await import("../wizard/clack-prompter.js");
const promptSpy = vi
.spyOn(prompterModule, "createClackPrompter")
.mockReturnValue(prompt as never);
await providersRemoveCommand(
{ provider: "discord", account: "default" },
runtime,
{ hasFlags: true },
);
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
discord?: { enabled?: boolean };
};
expect(next.discord?.enabled).toBe(false);
promptSpy.mockRestore();
});
it("includes external auth profiles in JSON output", async () => {
configMocks.readConfigFileSnapshot.mockResolvedValue({
...baseSnapshot,
config: {},
});
authMocks.loadAuthProfileStore.mockReturnValue({
version: 1,
profiles: {
"anthropic:claude-cli": {
type: "oauth",
provider: "anthropic",
access: "token",
refresh: "refresh",
expires: 0,
created: 0,
},
"openai-codex:codex-cli": {
type: "oauth",
provider: "openai",
access: "token",
refresh: "refresh",
expires: 0,
created: 0,
},
},
});
await providersListCommand({ json: true, usage: false }, runtime);
const payload = JSON.parse(
String(runtime.log.mock.calls[0]?.[0] ?? "{}"),
) as { auth?: Array<{ id: string }> };
const ids = payload.auth?.map((entry) => entry.id) ?? [];
expect(ids).toContain("anthropic:claude-cli");
expect(ids).toContain("openai-codex:codex-cli");
});
it("stores default account names in accounts when multiple accounts exist", async () => {
configMocks.readConfigFileSnapshot.mockResolvedValue({
...baseSnapshot,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,549 @@
import { type ClawdbotConfig, writeConfigFile } from "../../config/config.js";
import {
type ChatProviderId,
normalizeChatProviderId,
} from "../../providers/registry.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 {
providerLabel,
requireValidConfig,
shouldUseWizard,
} from "./shared.js";
type ChatProvider = ChatProviderId;
export type ProvidersAddOptions = {
provider?: string;
account?: string;
name?: string;
token?: string;
tokenFile?: string;
botToken?: string;
appToken?: string;
signalNumber?: string;
cliPath?: string;
dbPath?: string;
service?: "imessage" | "sms" | "auto";
region?: string;
authDir?: string;
httpUrl?: string;
httpHost?: string;
httpPort?: string;
useEnv?: boolean;
};
function providerHasAccounts(cfg: ClawdbotConfig, provider: ChatProvider) {
if (provider === "whatsapp") return true;
const base = (cfg as Record<string, unknown>)[provider] as
| { accounts?: Record<string, unknown> }
| undefined;
return Boolean(base?.accounts && Object.keys(base.accounts).length > 0);
}
function shouldStoreNameInAccounts(
cfg: ClawdbotConfig,
provider: ChatProvider,
accountId: string,
): boolean {
if (provider === "whatsapp") return true;
if (accountId !== DEFAULT_ACCOUNT_ID) return true;
return providerHasAccounts(cfg, provider);
}
function migrateBaseNameToDefaultAccount(
cfg: ClawdbotConfig,
provider: ChatProvider,
): ClawdbotConfig {
if (provider === "whatsapp") return cfg;
const base = (cfg as Record<string, unknown>)[provider] as
| { name?: string; accounts?: Record<string, Record<string, unknown>> }
| undefined;
const baseName = base?.name?.trim();
if (!baseName) return cfg;
const accounts: Record<string, Record<string, unknown>> = {
...base?.accounts,
};
const defaultAccount = accounts[DEFAULT_ACCOUNT_ID] ?? {};
if (!defaultAccount.name) {
accounts[DEFAULT_ACCOUNT_ID] = { ...defaultAccount, name: baseName };
}
const { name: _ignored, ...rest } = base ?? {};
return {
...cfg,
[provider]: {
...rest,
accounts,
},
} as ClawdbotConfig;
}
function applyAccountName(params: {
cfg: ClawdbotConfig;
provider: ChatProvider;
accountId: string;
name?: string;
}): ClawdbotConfig {
const trimmed = params.name?.trim();
if (!trimmed) return params.cfg;
const accountId = normalizeAccountId(params.accountId);
if (params.provider === "whatsapp") {
return {
...params.cfg,
whatsapp: {
...params.cfg.whatsapp,
accounts: {
...params.cfg.whatsapp?.accounts,
[accountId]: {
...params.cfg.whatsapp?.accounts?.[accountId],
name: trimmed,
},
},
},
};
}
const key = params.provider;
const useAccounts = shouldStoreNameInAccounts(params.cfg, key, accountId);
if (!useAccounts && accountId === DEFAULT_ACCOUNT_ID) {
const baseConfig = (params.cfg as Record<string, unknown>)[key];
const safeBase =
typeof baseConfig === "object" && baseConfig
? (baseConfig as Record<string, unknown>)
: {};
return {
...params.cfg,
[key]: {
...safeBase,
name: trimmed,
},
} as ClawdbotConfig;
}
const base = (params.cfg as Record<string, unknown>)[key] as
| { name?: string; accounts?: Record<string, Record<string, unknown>> }
| undefined;
const baseAccounts: Record<
string,
Record<string, unknown>
> = base?.accounts ?? {};
const existingAccount = baseAccounts[accountId] ?? {};
const baseWithoutName =
accountId === DEFAULT_ACCOUNT_ID
? (({ name: _ignored, ...rest }) => rest)(base ?? {})
: (base ?? {});
return {
...params.cfg,
[key]: {
...baseWithoutName,
accounts: {
...baseAccounts,
[accountId]: {
...existingAccount,
name: trimmed,
},
},
},
} as ClawdbotConfig;
}
function applyProviderAccountConfig(params: {
cfg: ClawdbotConfig;
provider: ChatProvider;
accountId: string;
name?: string;
token?: string;
tokenFile?: string;
botToken?: string;
appToken?: string;
signalNumber?: string;
cliPath?: string;
dbPath?: string;
service?: "imessage" | "sms" | "auto";
region?: string;
authDir?: string;
httpUrl?: string;
httpHost?: string;
httpPort?: string;
useEnv?: boolean;
}): ClawdbotConfig {
const accountId = normalizeAccountId(params.accountId);
const name = params.name?.trim() || undefined;
const namedConfig = applyAccountName({
cfg: params.cfg,
provider: params.provider,
accountId,
name,
});
const next =
accountId !== DEFAULT_ACCOUNT_ID
? migrateBaseNameToDefaultAccount(namedConfig, params.provider)
: namedConfig;
if (params.provider === "whatsapp") {
const entry = {
...next.whatsapp?.accounts?.[accountId],
...(params.authDir ? { authDir: params.authDir } : {}),
enabled: true,
};
return {
...next,
whatsapp: {
...next.whatsapp,
accounts: {
...next.whatsapp?.accounts,
[accountId]: entry,
},
},
};
}
if (params.provider === "telegram") {
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...next,
telegram: {
...next.telegram,
enabled: true,
...(params.useEnv
? {}
: params.tokenFile
? { tokenFile: params.tokenFile }
: params.token
? { botToken: params.token }
: {}),
},
};
}
return {
...next,
telegram: {
...next.telegram,
enabled: true,
accounts: {
...next.telegram?.accounts,
[accountId]: {
...next.telegram?.accounts?.[accountId],
enabled: true,
...(params.tokenFile
? { tokenFile: params.tokenFile }
: params.token
? { botToken: params.token }
: {}),
},
},
},
};
}
if (params.provider === "discord") {
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...next,
discord: {
...next.discord,
enabled: true,
...(params.useEnv ? {} : params.token ? { token: params.token } : {}),
},
};
}
return {
...next,
discord: {
...next.discord,
enabled: true,
accounts: {
...next.discord?.accounts,
[accountId]: {
...next.discord?.accounts?.[accountId],
enabled: true,
...(params.token ? { token: params.token } : {}),
},
},
},
};
}
if (params.provider === "slack") {
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...next,
slack: {
...next.slack,
enabled: true,
...(params.useEnv
? {}
: {
...(params.botToken ? { botToken: params.botToken } : {}),
...(params.appToken ? { appToken: params.appToken } : {}),
}),
},
};
}
return {
...next,
slack: {
...next.slack,
enabled: true,
accounts: {
...next.slack?.accounts,
[accountId]: {
...next.slack?.accounts?.[accountId],
enabled: true,
...(params.botToken ? { botToken: params.botToken } : {}),
...(params.appToken ? { appToken: params.appToken } : {}),
},
},
},
};
}
if (params.provider === "signal") {
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...next,
signal: {
...next.signal,
enabled: true,
...(params.signalNumber ? { account: params.signalNumber } : {}),
...(params.cliPath ? { cliPath: params.cliPath } : {}),
...(params.httpUrl ? { httpUrl: params.httpUrl } : {}),
...(params.httpHost ? { httpHost: params.httpHost } : {}),
...(params.httpPort ? { httpPort: Number(params.httpPort) } : {}),
},
};
}
return {
...next,
signal: {
...next.signal,
enabled: true,
accounts: {
...next.signal?.accounts,
[accountId]: {
...next.signal?.accounts?.[accountId],
enabled: true,
...(params.signalNumber ? { account: params.signalNumber } : {}),
...(params.cliPath ? { cliPath: params.cliPath } : {}),
...(params.httpUrl ? { httpUrl: params.httpUrl } : {}),
...(params.httpHost ? { httpHost: params.httpHost } : {}),
...(params.httpPort ? { httpPort: Number(params.httpPort) } : {}),
},
},
},
};
}
if (params.provider === "imessage") {
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...next,
imessage: {
...next.imessage,
enabled: true,
...(params.cliPath ? { cliPath: params.cliPath } : {}),
...(params.dbPath ? { dbPath: params.dbPath } : {}),
...(params.service ? { service: params.service } : {}),
...(params.region ? { region: params.region } : {}),
},
};
}
return {
...next,
imessage: {
...next.imessage,
enabled: true,
accounts: {
...next.imessage?.accounts,
[accountId]: {
...next.imessage?.accounts?.[accountId],
enabled: true,
...(params.cliPath ? { cliPath: params.cliPath } : {}),
...(params.dbPath ? { dbPath: params.dbPath } : {}),
...(params.service ? { service: params.service } : {}),
...(params.region ? { region: params.region } : {}),
},
},
},
};
}
return next;
}
export async function providersAddCommand(
opts: ProvidersAddOptions,
runtime: RuntimeEnv = defaultRuntime,
params?: { hasFlags?: boolean },
) {
const cfg = await requireValidConfig(runtime);
if (!cfg) return;
const useWizard = shouldUseWizard(params);
if (useWizard) {
const prompter = createClackPrompter();
let selection: ProviderChoice[] = [];
const accountIds: Partial<Record<ProviderChoice, string>> = {};
await prompter.intro("Provider setup");
let nextConfig = await setupProviders(cfg, runtime, prompter, {
allowDisable: false,
allowSignalInstall: true,
promptAccountIds: true,
onSelection: (value) => {
selection = value;
},
onAccountId: (provider, accountId) => {
accountIds[provider] = accountId;
},
});
if (selection.length === 0) {
await prompter.outro("No providers selected.");
return;
}
const wantsNames = await prompter.confirm({
message: "Add display names for these accounts? (optional)",
initialValue: false,
});
if (wantsNames) {
for (const provider of selection) {
const accountId = accountIds[provider] ?? DEFAULT_ACCOUNT_ID;
const existingName =
provider === "whatsapp"
? nextConfig.whatsapp?.accounts?.[accountId]?.name
: provider === "telegram"
? (nextConfig.telegram?.accounts?.[accountId]?.name ??
nextConfig.telegram?.name)
: provider === "discord"
? (nextConfig.discord?.accounts?.[accountId]?.name ??
nextConfig.discord?.name)
: provider === "slack"
? (nextConfig.slack?.accounts?.[accountId]?.name ??
nextConfig.slack?.name)
: provider === "signal"
? (nextConfig.signal?.accounts?.[accountId]?.name ??
nextConfig.signal?.name)
: provider === "imessage"
? (nextConfig.imessage?.accounts?.[accountId]?.name ??
nextConfig.imessage?.name)
: undefined;
const name = await prompter.text({
message: `${provider} account name (${accountId})`,
initialValue: existingName,
});
if (name?.trim()) {
nextConfig = applyAccountName({
cfg: nextConfig,
provider,
accountId,
name,
});
}
}
}
await writeConfigFile(nextConfig);
await prompter.outro("Providers updated.");
return;
}
const provider = normalizeChatProviderId(opts.provider);
if (!provider) {
runtime.error(`Unknown provider: ${String(opts.provider ?? "")}`);
runtime.exit(1);
return;
}
const accountId = normalizeAccountId(opts.account);
const useEnv = opts.useEnv === true;
if (provider === "telegram") {
if (useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
runtime.error(
"TELEGRAM_BOT_TOKEN can only be used for the default account.",
);
runtime.exit(1);
return;
}
if (!useEnv && !opts.token && !opts.tokenFile) {
runtime.error(
"Telegram requires --token or --token-file (or --use-env).",
);
runtime.exit(1);
return;
}
}
if (provider === "discord") {
if (useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
runtime.error(
"DISCORD_BOT_TOKEN can only be used for the default account.",
);
runtime.exit(1);
return;
}
if (!useEnv && !opts.token) {
runtime.error("Discord requires --token (or --use-env).");
runtime.exit(1);
return;
}
}
if (provider === "slack") {
if (useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
runtime.error(
"Slack env tokens can only be used for the default account.",
);
runtime.exit(1);
return;
}
if (!useEnv && (!opts.botToken || !opts.appToken)) {
runtime.error(
"Slack requires --bot-token and --app-token (or --use-env).",
);
runtime.exit(1);
return;
}
}
if (provider === "signal") {
if (
!opts.signalNumber &&
!opts.httpUrl &&
!opts.httpHost &&
!opts.httpPort &&
!opts.cliPath
) {
runtime.error(
"Signal requires --signal-number or --http-url/--http-host/--http-port/--cli-path.",
);
runtime.exit(1);
return;
}
}
const nextConfig = applyProviderAccountConfig({
cfg,
provider,
accountId,
name: opts.name,
token: opts.token,
tokenFile: opts.tokenFile,
botToken: opts.botToken,
appToken: opts.appToken,
signalNumber: opts.signalNumber,
cliPath: opts.cliPath,
dbPath: opts.dbPath,
service: opts.service,
region: opts.region,
authDir: opts.authDir,
httpUrl: opts.httpUrl,
httpHost: opts.httpHost,
httpPort: opts.httpPort,
useEnv,
});
await writeConfigFile(nextConfig);
runtime.log(`Added ${providerLabel(provider)} account "${accountId}".`);
}

View File

@@ -0,0 +1,263 @@
import {
CLAUDE_CLI_PROFILE_ID,
CODEX_CLI_PROFILE_ID,
loadAuthProfileStore,
} from "../../agents/auth-profiles.js";
import { withProgress } from "../../cli/progress.js";
import {
listDiscordAccountIds,
resolveDiscordAccount,
} from "../../discord/accounts.js";
import {
listIMessageAccountIds,
resolveIMessageAccount,
} from "../../imessage/accounts.js";
import {
formatUsageReportLines,
loadProviderUsageSummary,
} from "../../infra/provider-usage.js";
import {
type ChatProviderId,
listChatProviders,
} from "../../providers/registry.js";
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
import {
listSignalAccountIds,
resolveSignalAccount,
} from "../../signal/accounts.js";
import {
listSlackAccountIds,
resolveSlackAccount,
} from "../../slack/accounts.js";
import {
listTelegramAccountIds,
resolveTelegramAccount,
} from "../../telegram/accounts.js";
import { formatDocsLink } from "../../terminal/links.js";
import { theme } from "../../terminal/theme.js";
import {
listWhatsAppAccountIds,
resolveWhatsAppAuthDir,
} from "../../web/accounts.js";
import { webAuthExists } from "../../web/session.js";
import {
formatAccountLabel,
providerLabel,
requireValidConfig,
} from "./shared.js";
export type ProvidersListOptions = {
json?: boolean;
usage?: boolean;
};
const colorValue = (value: string) => {
if (value === "none") return theme.error(value);
if (value === "env") return theme.accent(value);
return theme.success(value);
};
function formatEnabled(value: boolean | undefined): string {
return value === false ? theme.error("disabled") : theme.success("enabled");
}
function formatConfigured(value: boolean): string {
return value ? theme.success("configured") : theme.warn("not configured");
}
function formatTokenSource(source?: string): string {
const value = source || "none";
return `token=${colorValue(value)}`;
}
function formatSource(label: string, source?: string): string {
const value = source || "none";
return `${label}=${colorValue(value)}`;
}
function formatLinked(value: boolean): string {
return value ? theme.success("linked") : theme.warn("not linked");
}
async function loadUsageWithProgress(
runtime: RuntimeEnv,
): Promise<Awaited<ReturnType<typeof loadProviderUsageSummary>> | null> {
try {
return await withProgress(
{ label: "Fetching usage snapshot…", indeterminate: true, enabled: true },
async () => await loadProviderUsageSummary(),
);
} catch (err) {
runtime.error(String(err));
return null;
}
}
export async function providersListCommand(
opts: ProvidersListOptions,
runtime: RuntimeEnv = defaultRuntime,
) {
const cfg = await requireValidConfig(runtime);
if (!cfg) return;
const includeUsage = opts.usage !== false;
const accountIdsByProvider: Record<ChatProviderId, string[]> = {
whatsapp: listWhatsAppAccountIds(cfg),
telegram: listTelegramAccountIds(cfg),
discord: listDiscordAccountIds(cfg),
slack: listSlackAccountIds(cfg),
signal: listSignalAccountIds(cfg),
imessage: listIMessageAccountIds(cfg),
};
const lineBuilders: Record<
ChatProviderId,
(accountId: string) => Promise<string>
> = {
telegram: async (accountId) => {
const account = resolveTelegramAccount({ cfg, accountId });
return `- ${theme.accent(providerLabel("telegram"))} ${theme.heading(
formatAccountLabel({
accountId,
name: account.name,
}),
)}: ${formatConfigured(Boolean(account.token))}, ${formatTokenSource(
account.tokenSource,
)}, ${formatEnabled(account.enabled)}`;
},
whatsapp: async (accountId) => {
const { authDir } = resolveWhatsAppAuthDir({ cfg, accountId });
const linked = await webAuthExists(authDir);
const name = cfg.whatsapp?.accounts?.[accountId]?.name;
return `- ${theme.accent(providerLabel("whatsapp"))} ${theme.heading(
formatAccountLabel({
accountId,
name,
}),
)}: ${formatLinked(linked)}, ${formatEnabled(
cfg.whatsapp?.accounts?.[accountId]?.enabled ??
cfg.web?.enabled ??
true,
)}`;
},
discord: async (accountId) => {
const account = resolveDiscordAccount({ cfg, accountId });
return `- ${theme.accent(providerLabel("discord"))} ${theme.heading(
formatAccountLabel({
accountId,
name: account.name,
}),
)}: ${formatConfigured(Boolean(account.token))}, ${formatTokenSource(
account.tokenSource,
)}, ${formatEnabled(account.enabled)}`;
},
slack: async (accountId) => {
const account = resolveSlackAccount({ cfg, accountId });
const configured = Boolean(account.botToken && account.appToken);
return `- ${theme.accent(providerLabel("slack"))} ${theme.heading(
formatAccountLabel({
accountId,
name: account.name,
}),
)}: ${formatConfigured(configured)}, ${formatSource(
"bot",
account.botTokenSource,
)}, ${formatSource("app", account.appTokenSource)}, ${formatEnabled(
account.enabled,
)}`;
},
signal: async (accountId) => {
const account = resolveSignalAccount({ cfg, accountId });
return `- ${theme.accent(providerLabel("signal"))} ${theme.heading(
formatAccountLabel({
accountId,
name: account.name,
}),
)}: ${formatConfigured(account.configured)}, base=${theme.muted(
account.baseUrl,
)}, ${formatEnabled(account.enabled)}`;
},
imessage: async (accountId) => {
const account = resolveIMessageAccount({ cfg, accountId });
return `- ${theme.accent(providerLabel("imessage"))} ${theme.heading(
formatAccountLabel({
accountId,
name: account.name,
}),
)}: ${formatEnabled(account.enabled)}`;
},
};
const authStore = loadAuthProfileStore();
const authProfiles = Object.entries(authStore.profiles).map(
([profileId, profile]) => ({
id: profileId,
provider: profile.provider,
type: profile.type,
isExternal:
profileId === CLAUDE_CLI_PROFILE_ID ||
profileId === CODEX_CLI_PROFILE_ID,
}),
);
if (opts.json) {
const usage = includeUsage ? await loadProviderUsageSummary() : undefined;
const payload = {
chat: {
whatsapp: accountIdsByProvider.whatsapp,
telegram: accountIdsByProvider.telegram,
discord: accountIdsByProvider.discord,
slack: accountIdsByProvider.slack,
signal: accountIdsByProvider.signal,
imessage: accountIdsByProvider.imessage,
},
auth: authProfiles,
...(usage ? { usage } : {}),
};
runtime.log(JSON.stringify(payload, null, 2));
return;
}
const lines: string[] = [];
lines.push(theme.heading("Chat providers:"));
for (const meta of listChatProviders()) {
const accounts = accountIdsByProvider[meta.id];
if (!accounts || accounts.length === 0) continue;
for (const accountId of accounts) {
const line = await lineBuilders[meta.id](accountId);
lines.push(line);
}
}
lines.push("");
lines.push(theme.heading("Auth providers (OAuth + API keys):"));
if (authProfiles.length === 0) {
lines.push(theme.muted("- none"));
} else {
for (const profile of authProfiles) {
const external = profile.isExternal ? theme.muted(" (synced)") : "";
lines.push(
`- ${theme.accent(profile.id)} (${theme.success(profile.type)}${external})`,
);
}
}
runtime.log(lines.join("\n"));
if (includeUsage) {
runtime.log("");
const usage = await loadUsageWithProgress(runtime);
if (usage) {
const usageLines = formatUsageReportLines(usage);
if (usageLines.length > 0) {
usageLines[0] = theme.accent(usageLines[0]);
runtime.log(usageLines.join("\n"));
}
}
}
runtime.log("");
runtime.log(
`Docs: ${formatDocsLink("/gateway/configuration", "gateway/configuration")}`,
);
}

View File

@@ -0,0 +1,260 @@
import { type ClawdbotConfig, writeConfigFile } from "../../config/config.js";
import { listDiscordAccountIds } from "../../discord/accounts.js";
import { listIMessageAccountIds } from "../../imessage/accounts.js";
import {
listChatProviders,
normalizeChatProviderId,
} from "../../providers/registry.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
} from "../../routing/session-key.js";
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
import { listSignalAccountIds } from "../../signal/accounts.js";
import { listSlackAccountIds } from "../../slack/accounts.js";
import { listTelegramAccountIds } from "../../telegram/accounts.js";
import { listWhatsAppAccountIds } from "../../web/accounts.js";
import { createClackPrompter } from "../../wizard/clack-prompter.js";
import {
type ChatProvider,
providerLabel,
requireValidConfig,
shouldUseWizard,
} from "./shared.js";
export type ProvidersRemoveOptions = {
provider?: string;
account?: string;
delete?: boolean;
};
function listAccountIds(cfg: ClawdbotConfig, provider: ChatProvider): string[] {
switch (provider) {
case "whatsapp":
return listWhatsAppAccountIds(cfg);
case "telegram":
return listTelegramAccountIds(cfg);
case "discord":
return listDiscordAccountIds(cfg);
case "slack":
return listSlackAccountIds(cfg);
case "signal":
return listSignalAccountIds(cfg);
case "imessage":
return listIMessageAccountIds(cfg);
}
}
export async function providersRemoveCommand(
opts: ProvidersRemoveOptions,
runtime: RuntimeEnv = defaultRuntime,
params?: { hasFlags?: boolean },
) {
const cfg = await requireValidConfig(runtime);
if (!cfg) return;
const useWizard = shouldUseWizard(params);
const prompter = useWizard ? createClackPrompter() : null;
let provider = normalizeChatProviderId(opts.provider);
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: listChatProviders().map((meta) => ({
value: meta.id,
label: meta.label,
})),
})) as ChatProvider;
accountId = await (async () => {
const ids = listAccountIds(cfg, provider);
const choice = (await prompter.select({
message: "Account",
options: ids.map((id) => ({
value: id,
label: id === DEFAULT_ACCOUNT_ID ? "default (primary)" : id,
})),
initialValue: ids[0] ?? DEFAULT_ACCOUNT_ID,
})) as string;
return normalizeAccountId(choice);
})();
const wantsDisable = await prompter.confirm({
message: `Disable ${providerLabel(provider)} account "${accountId}"? (keeps config)`,
initialValue: true,
});
if (!wantsDisable) {
await prompter.outro("Cancelled.");
return;
}
} else {
if (!provider) {
runtime.error("Provider is required. Use --provider <name>.");
runtime.exit(1);
return;
}
if (!deleteConfig) {
const confirm = createClackPrompter();
const ok = await confirm.confirm({
message: `Disable ${providerLabel(provider)} account "${accountId}"? (keeps config)`,
initialValue: true,
});
if (!ok) {
return;
}
}
}
let next = { ...cfg };
const accountKey = accountId || DEFAULT_ACCOUNT_ID;
const setAccountEnabled = (key: ChatProvider, enabled: boolean) => {
if (key === "whatsapp") {
next = {
...next,
whatsapp: {
...next.whatsapp,
accounts: {
...next.whatsapp?.accounts,
[accountKey]: {
...next.whatsapp?.accounts?.[accountKey],
enabled,
},
},
},
};
return;
}
const base = (next as Record<string, unknown>)[key] as
| {
accounts?: Record<string, Record<string, unknown>>;
enabled?: boolean;
}
| undefined;
const baseAccounts: Record<
string,
Record<string, unknown>
> = base?.accounts ?? {};
const existingAccount = baseAccounts[accountKey] ?? {};
if (accountKey === DEFAULT_ACCOUNT_ID && !base?.accounts) {
next = {
...next,
[key]: {
...base,
enabled,
},
} as ClawdbotConfig;
return;
}
next = {
...next,
[key]: {
...base,
accounts: {
...baseAccounts,
[accountKey]: {
...existingAccount,
enabled,
},
},
},
} as ClawdbotConfig;
};
const deleteAccount = (key: ChatProvider) => {
if (key === "whatsapp") {
const accounts = { ...next.whatsapp?.accounts };
delete accounts[accountKey];
next = {
...next,
whatsapp: {
...next.whatsapp,
accounts: Object.keys(accounts).length ? accounts : undefined,
},
};
return;
}
const base = (next as Record<string, unknown>)[key] as
| {
accounts?: Record<string, Record<string, unknown>>;
enabled?: boolean;
}
| undefined;
if (accountKey !== DEFAULT_ACCOUNT_ID) {
const accounts = { ...base?.accounts };
delete accounts[accountKey];
next = {
...next,
[key]: {
...base,
accounts: Object.keys(accounts).length ? accounts : undefined,
},
} as ClawdbotConfig;
return;
}
if (base?.accounts && Object.keys(base.accounts).length > 0) {
const accounts = { ...base.accounts };
delete accounts[accountKey];
next = {
...next,
[key]: {
...base,
accounts: Object.keys(accounts).length ? accounts : undefined,
...(key === "telegram"
? { botToken: undefined, tokenFile: undefined, name: undefined }
: key === "discord"
? { token: undefined, name: undefined }
: key === "slack"
? { botToken: undefined, appToken: undefined, name: undefined }
: key === "signal"
? {
account: undefined,
httpUrl: undefined,
httpHost: undefined,
httpPort: undefined,
cliPath: undefined,
name: undefined,
}
: key === "imessage"
? {
cliPath: undefined,
dbPath: undefined,
service: undefined,
region: undefined,
name: undefined,
}
: {}),
},
} as ClawdbotConfig;
return;
}
// No accounts map: remove entire provider section.
const clone = { ...next } as Record<string, unknown>;
delete clone[key];
next = clone as ClawdbotConfig;
};
if (deleteConfig) {
deleteAccount(provider);
} else {
setAccountEnabled(provider, false);
}
await writeConfigFile(next);
if (useWizard && prompter) {
await prompter.outro(
deleteConfig
? `Deleted ${providerLabel(provider)} account "${accountKey}".`
: `Disabled ${providerLabel(provider)} account "${accountKey}".`,
);
} else {
runtime.log(
deleteConfig
? `Deleted ${providerLabel(provider)} account "${accountKey}".`
: `Disabled ${providerLabel(provider)} account "${accountKey}".`,
);
}
}

View File

@@ -0,0 +1,47 @@
import {
type ClawdbotConfig,
readConfigFileSnapshot,
} from "../../config/config.js";
import {
type ChatProviderId,
getChatProviderMeta,
} from "../../providers/registry.js";
import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js";
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
export type ChatProvider = ChatProviderId;
export async function requireValidConfig(
runtime: RuntimeEnv = defaultRuntime,
): Promise<ClawdbotConfig | null> {
const snapshot = await readConfigFileSnapshot();
if (snapshot.exists && !snapshot.valid) {
const issues =
snapshot.issues.length > 0
? snapshot.issues
.map((issue) => `- ${issue.path}: ${issue.message}`)
.join("\n")
: "Unknown validation issue.";
runtime.error(`Config invalid:\n${issues}`);
runtime.error("Fix the config or run clawdbot doctor.");
runtime.exit(1);
return null;
}
return snapshot.config;
}
export function formatAccountLabel(params: {
accountId: string;
name?: string;
}) {
const base = params.accountId || DEFAULT_ACCOUNT_ID;
if (params.name?.trim()) return `${base} (${params.name.trim()})`;
return base;
}
export const providerLabel = (provider: ChatProvider) =>
getChatProviderMeta(provider).label;
export function shouldUseWizard(params?: { hasFlags?: boolean }) {
return params?.hasFlags === false;
}

View File

@@ -0,0 +1,121 @@
import { withProgress } from "../../cli/progress.js";
import { callGateway } from "../../gateway/call.js";
import { listChatProviders } from "../../providers/registry.js";
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
import { formatDocsLink } from "../../terminal/links.js";
import { theme } from "../../terminal/theme.js";
import { type ChatProvider, formatAccountLabel } from "./shared.js";
export type ProvidersStatusOptions = {
json?: boolean;
probe?: boolean;
timeout?: string;
};
export function formatGatewayProvidersStatusLines(
payload: Record<string, unknown>,
): string[] {
const lines: string[] = [];
lines.push(theme.success("Gateway reachable."));
const accountLines = (
label: string,
accounts: Array<Record<string, unknown>>,
) =>
accounts.map((account) => {
const bits: string[] = [];
if (typeof account.enabled === "boolean") {
bits.push(account.enabled ? "enabled" : "disabled");
}
if (typeof account.configured === "boolean") {
bits.push(account.configured ? "configured" : "not configured");
}
if (typeof account.linked === "boolean") {
bits.push(account.linked ? "linked" : "not linked");
}
if (typeof account.running === "boolean") {
bits.push(account.running ? "running" : "stopped");
}
const probe = account.probe as { ok?: boolean } | undefined;
if (probe && typeof probe.ok === "boolean") {
bits.push(probe.ok ? "works" : "probe failed");
}
const accountId =
typeof account.accountId === "string" ? account.accountId : "default";
const name = typeof account.name === "string" ? account.name.trim() : "";
const labelText = `${label} ${formatAccountLabel({
accountId,
name: name || undefined,
})}`;
return `- ${labelText}: ${bits.join(", ")}`;
});
const accountPayloads: Partial<
Record<ChatProvider, Array<Record<string, unknown>>>
> = {
whatsapp: Array.isArray(payload.whatsappAccounts)
? (payload.whatsappAccounts as Array<Record<string, unknown>>)
: undefined,
telegram: Array.isArray(payload.telegramAccounts)
? (payload.telegramAccounts as Array<Record<string, unknown>>)
: undefined,
discord: Array.isArray(payload.discordAccounts)
? (payload.discordAccounts as Array<Record<string, unknown>>)
: undefined,
slack: Array.isArray(payload.slackAccounts)
? (payload.slackAccounts as Array<Record<string, unknown>>)
: undefined,
signal: Array.isArray(payload.signalAccounts)
? (payload.signalAccounts as Array<Record<string, unknown>>)
: undefined,
imessage: Array.isArray(payload.imessageAccounts)
? (payload.imessageAccounts as Array<Record<string, unknown>>)
: undefined,
};
for (const meta of listChatProviders()) {
const accounts = accountPayloads[meta.id];
if (accounts && accounts.length > 0) {
lines.push(...accountLines(meta.label, accounts));
}
}
lines.push("");
lines.push(
`Tip: ${formatDocsLink("/cli#status", "status --deep")} runs local probes without a gateway.`,
);
return lines;
}
export async function providersStatusCommand(
opts: ProvidersStatusOptions,
runtime: RuntimeEnv = defaultRuntime,
) {
const timeoutMs = Number(opts.timeout ?? 10_000);
try {
const payload = await withProgress(
{
label: "Checking provider status…",
indeterminate: true,
enabled: opts.json !== true,
},
async () =>
await callGateway({
method: "providers.status",
params: { probe: Boolean(opts.probe), timeoutMs },
timeoutMs,
}),
);
if (opts.json) {
runtime.log(JSON.stringify(payload, null, 2));
return;
}
runtime.log(
formatGatewayProvidersStatusLines(
payload as Record<string, unknown>,
).join("\n"),
);
} catch (err) {
runtime.error(`Gateway not reachable: ${String(err)}`);
runtime.exit(1);
}
}