From 9b152ecb125b810fc70f7c9e94cad8a8161c26b3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 8 Jan 2026 06:46:40 +0100 Subject: [PATCH] refactor: split providers commands --- src/commands/providers.test.ts | 129 ++++ src/commands/providers.ts | 1191 +----------------------------- src/commands/providers/add.ts | 549 ++++++++++++++ src/commands/providers/list.ts | 263 +++++++ src/commands/providers/remove.ts | 260 +++++++ src/commands/providers/shared.ts | 47 ++ src/commands/providers/status.ts | 121 +++ 7 files changed, 1380 insertions(+), 1180 deletions(-) create mode 100644 src/commands/providers/add.ts create mode 100644 src/commands/providers/list.ts create mode 100644 src/commands/providers/remove.ts create mode 100644 src/commands/providers/shared.ts create mode 100644 src/commands/providers/status.ts diff --git a/src/commands/providers.test.ts b/src/commands/providers.test.ts index f42717ee9..f48eeaf11 100644 --- a/src/commands/providers.test.ts +++ b/src/commands/providers.test.ts @@ -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(); return { @@ -16,9 +20,19 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); +vi.mock("../agents/auth-profiles.js", async (importOriginal) => { + const actual = + await importOriginal(); + 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 }; + }; + 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; + }; + }; + 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, diff --git a/src/commands/providers.ts b/src/commands/providers.ts index e60737c13..4e9ac46ed 100644 --- a/src/commands/providers.ts +++ b/src/commands/providers.ts @@ -1,1180 +1,11 @@ -import { - CLAUDE_CLI_PROFILE_ID, - CODEX_CLI_PROFILE_ID, - loadAuthProfileStore, -} from "../agents/auth-profiles.js"; -import { withProgress } from "../cli/progress.js"; -import { - type ClawdbotConfig, - readConfigFileSnapshot, - writeConfigFile, -} from "../config/config.js"; -import { - listDiscordAccountIds, - resolveDiscordAccount, -} from "../discord/accounts.js"; -import { callGateway } from "../gateway/call.js"; -import { - listIMessageAccountIds, - resolveIMessageAccount, -} from "../imessage/accounts.js"; -import { - formatUsageReportLines, - loadProviderUsageSummary, -} from "../infra/provider-usage.js"; -import { - type ChatProviderId, - getChatProviderMeta, - 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, - 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 { createClackPrompter } from "../wizard/clack-prompter.js"; -import { setupProviders } from "./onboard-providers.js"; -import type { ProviderChoice } from "./onboard-types.js"; - -type ChatProvider = ChatProviderId; - -type ProvidersListOptions = { - json?: boolean; - usage?: boolean; -}; - -type ProvidersStatusOptions = { - json?: boolean; - probe?: boolean; - timeout?: string; -}; - -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; -}; - -export type ProvidersRemoveOptions = { - provider?: string; - account?: string; - delete?: boolean; -}; - -async function requireValidConfig( - runtime: RuntimeEnv, -): Promise { - 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; -} - -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; -} - -const providerLabel = (provider: ChatProvider) => - getChatProviderMeta(provider).label; - -const colorValue = (value: string) => { - if (value === "none") return theme.error(value); - if (value === "env") return theme.accent(value); - return theme.success(value); -}; - -function formatEnabled(value: boolean | undefined): string { - return value === false ? theme.error("disabled") : theme.success("enabled"); -} - -function formatConfigured(value: boolean): string { - return value ? theme.success("configured") : theme.warn("not configured"); -} - -function formatTokenSource(source?: string): string { - const value = source || "none"; - return `token=${colorValue(value)}`; -} - -function formatSource(label: string, source?: string): string { - const value = source || "none"; - return `${label}=${colorValue(value)}`; -} - -function formatLinked(value: boolean): string { - return value ? theme.success("linked") : theme.warn("not linked"); -} - -function shouldUseWizard(params?: { hasFlags?: boolean }) { - return params?.hasFlags === false; -} - -function providerHasAccounts(cfg: ClawdbotConfig, provider: ChatProvider) { - if (provider === "whatsapp") return true; - const base = (cfg as Record)[provider] as - | { accounts?: Record } - | 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)[provider] as - | { name?: string; accounts?: Record> } - | undefined; - const baseName = base?.name?.trim(); - if (!baseName) return cfg; - const accounts: Record> = { - ...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)[key]; - const safeBase = - typeof baseConfig === "object" && baseConfig - ? (baseConfig as Record) - : {}; - return { - ...params.cfg, - [key]: { - ...safeBase, - name: trimmed, - }, - } as ClawdbotConfig; - } - const base = (params.cfg as Record)[key] as - | { name?: string; accounts?: Record> } - | undefined; - const baseAccounts: Record< - string, - Record - > = 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 providersListCommand( - opts: ProvidersListOptions, - runtime: RuntimeEnv = defaultRuntime, -) { - const cfg = await requireValidConfig(runtime); - if (!cfg) return; - const includeUsage = opts.usage !== false; - - const whatsappAccounts = listWhatsAppAccountIds(cfg); - const telegramAccounts = listTelegramAccountIds(cfg); - const discordAccounts = listDiscordAccountIds(cfg); - const slackAccounts = listSlackAccountIds(cfg); - const signalAccounts = listSignalAccountIds(cfg); - const imessageAccounts = listIMessageAccountIds(cfg); - - 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: whatsappAccounts, - telegram: telegramAccounts, - discord: discordAccounts, - slack: slackAccounts, - signal: signalAccounts, - imessage: imessageAccounts, - }, - auth: authProfiles, - ...(usage ? { usage } : {}), - }; - runtime.log(JSON.stringify(payload, null, 2)); - return; - } - - const lines: string[] = []; - lines.push(theme.heading("Chat providers:")); - - for (const accountId of telegramAccounts) { - const account = resolveTelegramAccount({ cfg, accountId }); - lines.push( - `- ${theme.accent(providerLabel("telegram"))} ${theme.heading( - formatAccountLabel({ - accountId, - name: account.name, - }), - )}: ${formatConfigured(Boolean(account.token))}, ${formatTokenSource( - account.tokenSource, - )}, ${formatEnabled(account.enabled)}`, - ); - } - - for (const accountId of whatsappAccounts) { - const { authDir } = resolveWhatsAppAuthDir({ cfg, accountId }); - const linked = await webAuthExists(authDir); - const name = cfg.whatsapp?.accounts?.[accountId]?.name; - lines.push( - `- ${theme.accent(providerLabel("whatsapp"))} ${theme.heading( - formatAccountLabel({ - accountId, - name, - }), - )}: ${formatLinked(linked)}, ${formatEnabled( - cfg.whatsapp?.accounts?.[accountId]?.enabled ?? - cfg.web?.enabled ?? - true, - )}`, - ); - } - - for (const accountId of discordAccounts) { - const account = resolveDiscordAccount({ cfg, accountId }); - lines.push( - `- ${theme.accent(providerLabel("discord"))} ${theme.heading( - formatAccountLabel({ - accountId, - name: account.name, - }), - )}: ${formatConfigured(Boolean(account.token))}, ${formatTokenSource( - account.tokenSource, - )}, ${formatEnabled(account.enabled)}`, - ); - } - - for (const accountId of slackAccounts) { - const account = resolveSlackAccount({ cfg, accountId }); - const configured = Boolean(account.botToken && account.appToken); - lines.push( - `- ${theme.accent(providerLabel("slack"))} ${theme.heading( - formatAccountLabel({ - accountId, - name: account.name, - }), - )}: ${formatConfigured(configured)}, ${formatSource( - "bot", - account.botTokenSource, - )}, ${formatSource("app", account.appTokenSource)}, ${formatEnabled( - account.enabled, - )}`, - ); - } - - for (const accountId of signalAccounts) { - const account = resolveSignalAccount({ cfg, accountId }); - lines.push( - `- ${theme.accent(providerLabel("signal"))} ${theme.heading( - formatAccountLabel({ - accountId, - name: account.name, - }), - )}: ${formatConfigured(account.configured)}, base=${theme.muted( - account.baseUrl, - )}, ${formatEnabled(account.enabled)}`, - ); - } - - for (const accountId of imessageAccounts) { - const account = resolveIMessageAccount({ cfg, accountId }); - lines.push( - `- ${theme.accent(providerLabel("imessage"))} ${theme.heading( - formatAccountLabel({ - accountId, - name: account.name, - }), - )}: ${formatEnabled(account.enabled)}`, - ); - } - - 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")}`, - ); -} - -async function loadUsageWithProgress( - runtime: RuntimeEnv, -): Promise> | 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 function formatGatewayProvidersStatusLines( - payload: Record, -): string[] { - const lines: string[] = []; - lines.push(theme.success("Gateway reachable.")); - const accountLines = ( - label: string, - accounts: Array>, - ) => - 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>> - > = { - whatsapp: Array.isArray(payload.whatsappAccounts) - ? (payload.whatsappAccounts as Array>) - : undefined, - telegram: Array.isArray(payload.telegramAccounts) - ? (payload.telegramAccounts as Array>) - : undefined, - discord: Array.isArray(payload.discordAccounts) - ? (payload.discordAccounts as Array>) - : undefined, - slack: Array.isArray(payload.slackAccounts) - ? (payload.slackAccounts as Array>) - : undefined, - signal: Array.isArray(payload.signalAccounts) - ? (payload.signalAccounts as Array>) - : undefined, - imessage: Array.isArray(payload.imessageAccounts) - ? (payload.imessageAccounts as Array>) - : 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, - ).join("\n"), - ); - } catch (err) { - runtime.error(`Gateway not reachable: ${String(err)}`); - runtime.exit(1); - } -} - -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> = {}; - 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}".`); -} - -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; - - const listAccounts = - provider === "whatsapp" - ? listWhatsAppAccountIds - : provider === "telegram" - ? listTelegramAccountIds - : provider === "discord" - ? listDiscordAccountIds - : provider === "slack" - ? listSlackAccountIds - : provider === "signal" - ? listSignalAccountIds - : listIMessageAccountIds; - accountId = await (async () => { - const ids = listAccounts(cfg); - 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 ."); - 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)[key] as - | { - accounts?: Record>; - enabled?: boolean; - } - | undefined; - const baseAccounts: Record< - string, - Record - > = 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)[key] as - | { - accounts?: Record>; - 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; - 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}".`, - ); - } -} +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 { 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"; diff --git a/src/commands/providers/add.ts b/src/commands/providers/add.ts new file mode 100644 index 000000000..66f9f94b5 --- /dev/null +++ b/src/commands/providers/add.ts @@ -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)[provider] as + | { accounts?: Record } + | 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)[provider] as + | { name?: string; accounts?: Record> } + | undefined; + const baseName = base?.name?.trim(); + if (!baseName) return cfg; + const accounts: Record> = { + ...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)[key]; + const safeBase = + typeof baseConfig === "object" && baseConfig + ? (baseConfig as Record) + : {}; + return { + ...params.cfg, + [key]: { + ...safeBase, + name: trimmed, + }, + } as ClawdbotConfig; + } + const base = (params.cfg as Record)[key] as + | { name?: string; accounts?: Record> } + | undefined; + const baseAccounts: Record< + string, + Record + > = 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> = {}; + 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}".`); +} diff --git a/src/commands/providers/list.ts b/src/commands/providers/list.ts new file mode 100644 index 000000000..bc8797c18 --- /dev/null +++ b/src/commands/providers/list.ts @@ -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> | 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 = { + 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 + > = { + 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")}`, + ); +} diff --git a/src/commands/providers/remove.ts b/src/commands/providers/remove.ts new file mode 100644 index 000000000..2f47b9f8b --- /dev/null +++ b/src/commands/providers/remove.ts @@ -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 ."); + 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)[key] as + | { + accounts?: Record>; + enabled?: boolean; + } + | undefined; + const baseAccounts: Record< + string, + Record + > = 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)[key] as + | { + accounts?: Record>; + 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; + 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}".`, + ); + } +} diff --git a/src/commands/providers/shared.ts b/src/commands/providers/shared.ts new file mode 100644 index 000000000..ab9a589a6 --- /dev/null +++ b/src/commands/providers/shared.ts @@ -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 { + 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; +} diff --git a/src/commands/providers/status.ts b/src/commands/providers/status.ts new file mode 100644 index 000000000..1b76d8bf4 --- /dev/null +++ b/src/commands/providers/status.ts @@ -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[] { + const lines: string[] = []; + lines.push(theme.success("Gateway reachable.")); + const accountLines = ( + label: string, + accounts: Array>, + ) => + 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>> + > = { + whatsapp: Array.isArray(payload.whatsappAccounts) + ? (payload.whatsappAccounts as Array>) + : undefined, + telegram: Array.isArray(payload.telegramAccounts) + ? (payload.telegramAccounts as Array>) + : undefined, + discord: Array.isArray(payload.discordAccounts) + ? (payload.discordAccounts as Array>) + : undefined, + slack: Array.isArray(payload.slackAccounts) + ? (payload.slackAccounts as Array>) + : undefined, + signal: Array.isArray(payload.signalAccounts) + ? (payload.signalAccounts as Array>) + : undefined, + imessage: Array.isArray(payload.imessageAccounts) + ? (payload.imessageAccounts as Array>) + : 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, + ).join("\n"), + ); + } catch (err) { + runtime.error(`Gateway not reachable: ${String(err)}`); + runtime.exit(1); + } +}