diff --git a/docs/docs.json b/docs/docs.json index 87e104ad4..8728ef949 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -654,7 +654,8 @@ "platforms/ios", "platforms/android", "platforms/windows", - "platforms/linux" + "platforms/linux", + "platforms/exe-dev" ] }, { diff --git a/docs/platforms/exe-dev.md b/docs/platforms/exe-dev.md new file mode 100644 index 000000000..ba9556a59 --- /dev/null +++ b/docs/platforms/exe-dev.md @@ -0,0 +1,174 @@ +--- +summary: "Run Clawdbot Gateway on exe.dev (VM + HTTPS proxy) for remote access" +read_when: + - You want a cheap always-on Linux host for the Gateway + - You want remote Control UI access without running your own VPS +--- + +# exe.dev + +Goal: Clawdbot Gateway running on an exe.dev VM, reachable from your laptop via: +- **exe.dev HTTPS proxy** (easy, no tunnel) or +- **SSH tunnel** (most secure; loopback-only Gateway) + +This page assumes **Ubuntu/Debian**. If you picked a different distro, map packages accordingly. + +## What you need + +- exe.dev account + `ssh exe.dev` working on your laptop +- SSH keys set up (your laptop → exe.dev) +- Model auth (OAuth or API key) you want to use +- Provider credentials (optional): WhatsApp QR scan, Telegram bot token, Discord bot token, … + +## 1) Create the VM + +From your laptop: + +```bash +ssh exe.dev new --name=clawdbot +``` + +Then connect: + +```bash +ssh clawdbot.exe.xyz +``` + +Tip: keep this VM **stateful**. Clawdbot stores state under `~/.clawdbot/` and `~/clawd/`. + +## 2) Install prerequisites (on the VM) + +```bash +sudo apt-get update +sudo apt-get install -y git curl jq ca-certificates openssl +``` + +### Node 22 + +Install Node **>= 22.12** (any method is fine). Quick check: + +```bash +node -v +``` + +If you don’t already have Node 22 on the VM, use your preferred Node manager (nvm/mise/asdf) or a distro package source that provides Node 22+. + +Common Ubuntu/Debian option (NodeSource): + +```bash +curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - +sudo apt-get install -y nodejs +``` + +## 3) Install Clawdbot + +Recommended on servers: npm global install. + +```bash +npm i -g clawdbot@latest +clawdbot --version +``` + +If native deps fail to install (rare; usually `sharp`), add build tools: + +```bash +sudo apt-get install -y build-essential python3 +``` + +## 4) First-time setup (wizard) + +Run the onboarding wizard on the VM: + +```bash +clawdbot onboard --install-daemon +``` + +It can set up: +- `~/clawd` workspace bootstrap +- `~/.clawdbot/clawdbot.json` config +- model auth profiles +- provider config/login +- Linux systemd **user** service (daemon) + +If you’re doing OAuth on a headless VM: do OAuth on a normal machine first, then copy the auth profile to the VM (see [FAQ](/start/faq)). + +## 5) Remote access options + +### Option A (recommended): SSH tunnel (loopback-only) + +Keep Gateway on loopback (default) and tunnel it from your laptop: + +```bash +ssh -N -L 18789:127.0.0.1:18789 clawdbot.exe.xyz +``` + +Open locally: +- `http://127.0.0.1:18789/` (Control UI) + +Runbook: [Remote access](/gateway/remote) + +### Option B: exe.dev HTTPS proxy (no tunnel) + +To let exe.dev proxy traffic to the VM, bind the Gateway to the LAN interface and set a token: + +```bash +export CLAWDBOT_GATEWAY_TOKEN="$(openssl rand -hex 32)" +clawdbot gateway --bind lan --port 8080 --token "$CLAWDBOT_GATEWAY_TOKEN" +``` + +For daemon runs, persist it in `~/.clawdbot/clawdbot.json`: + +```json5 +{ + gateway: { + mode: "local", + port: 8080, + bind: "lan", + auth: { mode: "token", token: "YOUR_TOKEN" } + } +} +``` + +Then point exe.dev’s proxy at `8080` (or whatever port you chose) and open your VM’s HTTPS URL: + +```bash +ssh exe.dev share port clawdbot 8080 +``` + +Open: +- `https://clawdbot.exe.xyz/` + +In the Control UI, paste the token (UI → Settings → token). The UI sends it as `connect.params.auth.token`. + +Notes: +- Prefer a **non-default** port (like `8080`) if your proxy expects an app port. +- Treat the token like a password. + +Control UI details: [Control UI](/web/control-ui) + +## 6) Keep it running (daemon) + +On Linux, Clawdbot uses a systemd **user** service. After `--install-daemon`, verify: + +```bash +systemctl --user status clawdbot-gateway.service +``` + +If the service dies after logout, enable lingering: + +```bash +sudo loginctl enable-linger "$USER" +``` + +More: [Linux](/platforms/linux) + +## 7) Updates + +```bash +npm i -g clawdbot@latest +clawdbot doctor +clawdbot gateway restart +clawdbot health +``` + +Guide: [Updating](/install/updating) diff --git a/docs/providers/imessage.md b/docs/providers/imessage.md index 3eb48fd74..f036ae629 100644 --- a/docs/providers/imessage.md +++ b/docs/providers/imessage.md @@ -19,7 +19,7 @@ Status: external CLI integration. Gateway spawns `imsg rpc` (JSON-RPC over stdio - macOS with Messages signed in. - Full Disk Access for Clawdbot + `imsg` (Messages DB access). - Automation permission when sending. -- `imessage.cliPath` can point to a wrapper script (for example, an SSH hop to another Mac that runs `imsg rpc`). +- `imessage.cliPath` can point to any command that proxies stdin/stdout (for example, a wrapper script that SSHes to another Mac and runs `imsg rpc`). ## Setup (fast path) 1) Ensure Messages is signed in on this Mac. @@ -46,7 +46,7 @@ Example: { imessage: { enabled: true, - cliPath: "imsg", + cliPath: "/usr/local/bin/imessage-remote", dmPolicy: "pairing", allowFrom: ["+15555550123"] } diff --git a/docs/start/getting-started.md b/docs/start/getting-started.md index 5c1727243..9f53906cd 100644 --- a/docs/start/getting-started.md +++ b/docs/start/getting-started.md @@ -140,6 +140,7 @@ pnpm clawdbot send --to +15555550123 --message "Hello from Clawdbot" If `health` shows “no auth configured”, go back to the wizard and set OAuth/key auth — the agent won’t be able to respond without it. Local probe tip: `pnpm clawdbot status --deep` runs provider checks without needing a gateway connection. +Gateway snapshot: `pnpm clawdbot providers status` shows what the gateway reports (use `status --deep` for local-only probes). ## Next steps (optional, but great) diff --git a/docs/start/onboarding.md b/docs/start/onboarding.md index f3884c229..e724ff08e 100644 --- a/docs/start/onboarding.md +++ b/docs/start/onboarding.md @@ -80,7 +80,7 @@ The onboarding flow now embeds the SwiftUI chat view directly. It uses a **speci This onboarding chat is where the agent: - does the BOOTSTRAP.md identity ritual (one question at a time) - visits **soul.md** with the user and writes `SOUL.md` (values, tone, boundaries) -- asks how the user wants to talk (web-only / WhatsApp / Telegram) +- asks how the user wants to talk (web-only / Telegram / WhatsApp) - guides linking steps (including showing a QR inline for WhatsApp via the `whatsapp_login` tool) If the workspace bootstrap is already complete (BOOTSTRAP.md removed), the onboarding chat step is skipped. diff --git a/docs/start/wizard.md b/docs/start/wizard.md index b3b68120a..233332e16 100644 --- a/docs/start/wizard.md +++ b/docs/start/wizard.md @@ -30,7 +30,7 @@ clawdbot configure - Model/auth (Anthropic or OpenAI Codex OAuth recommended, API key optional, Minimax M2.1 via LM Studio) - Workspace location + bootstrap files - Gateway settings (port/bind/auth/tailscale) -- Providers (WhatsApp, Telegram, Discord, Signal) +- Providers (Telegram, WhatsApp, Discord, Signal) - Daemon install (LaunchAgent / systemd user unit) - Health check - Skills (recommended) diff --git a/src/cli/providers-cli.ts b/src/cli/providers-cli.ts index d768bdc1f..3210fe9bf 100644 --- a/src/cli/providers-cli.ts +++ b/src/cli/providers-cli.ts @@ -6,6 +6,7 @@ import { providersRemoveCommand, providersStatusCommand, } from "../commands/providers.js"; +import { listChatProviders } from "../providers/registry.js"; import { defaultRuntime } from "../runtime.js"; import { hasExplicitOptions } from "./command-options.js"; @@ -31,6 +32,10 @@ const optionNamesAdd = [ const optionNamesRemove = ["provider", "account", "delete"] as const; +const providerNames = listChatProviders() + .map((meta) => meta.id) + .join("|"); + export function registerProvidersCli(program: Command) { const providers = program .command("providers") @@ -69,10 +74,7 @@ export function registerProvidersCli(program: Command) { providers .command("add") .description("Add or update a provider account") - .option( - "--provider ", - "Provider (whatsapp|telegram|discord|slack|signal|imessage)", - ) + .option("--provider ", `Provider (${providerNames})`) .option("--account ", "Account id (default when omitted)") .option("--name ", "Display name for this account") .option("--token ", "Bot token (Telegram/Discord)") @@ -102,10 +104,7 @@ export function registerProvidersCli(program: Command) { providers .command("remove") .description("Disable or delete a provider account") - .option( - "--provider ", - "Provider (whatsapp|telegram|discord|slack|signal|imessage)", - ) + .option("--provider ", `Provider (${providerNames})`) .option("--account ", "Account id (default when omitted)") .option("--delete", "Delete config entries (no prompt)", false) .action(async (opts, command) => { diff --git a/src/commands/onboard-providers.ts b/src/commands/onboard-providers.ts index cd3f6d89d..dd7f02c0d 100644 --- a/src/commands/onboard-providers.ts +++ b/src/commands/onboard-providers.ts @@ -13,6 +13,11 @@ import { resolveIMessageAccount, } from "../imessage/accounts.js"; import { loginWeb } from "../provider-web.js"; +import { + formatProviderPrimerLine, + formatProviderSelectionLine, + listChatProviders, +} from "../providers/registry.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId, @@ -33,7 +38,8 @@ import { resolveDefaultTelegramAccountId, resolveTelegramAccount, } from "../telegram/accounts.js"; -import { formatTerminalLink, normalizeE164 } from "../utils.js"; +import { formatDocsLink } from "../terminal/links.js"; +import { normalizeE164 } from "../utils.js"; import { listWhatsAppAccountIds, resolveDefaultWhatsAppAccountId, @@ -44,14 +50,6 @@ import { detectBinary } from "./onboard-helpers.js"; import type { ProviderChoice } from "./onboard-types.js"; import { installSignalCli } from "./signal-install.js"; -const DOCS_BASE = "https://docs.clawd.bot"; - -function docsLink(path: string, label?: string): string { - const cleanPath = path.startsWith("/") ? path : `/${path}`; - const url = `${DOCS_BASE}${cleanPath}`; - return formatTerminalLink(label ?? url, url, { fallback: url }); -} - async function promptAccountId(params: { cfg: ClawdbotConfig; prompter: WizardPrompter; @@ -118,19 +116,17 @@ async function detectWhatsAppLinked( } async function noteProviderPrimer(prompter: WizardPrompter): Promise { + const providerLines = listChatProviders().map((meta) => + formatProviderPrimerLine(meta), + ); await prompter.note( [ "DM security: default is pairing; unknown DMs get a pairing code.", "Approve with: clawdbot pairing approve --provider ", 'Public DMs require dmPolicy="open" + allowFrom=["*"].', - `Docs: ${docsLink("/start/pairing", "start/pairing")}`, + `Docs: ${formatDocsLink("/start/pairing", "start/pairing")}`, "", - "Telegram: simplest way to get started — register a bot with @BotFather and get going.", - "WhatsApp: works with your own number; recommend a separate phone + eSIM.", - "Discord: very well supported right now.", - "Slack: supported (Socket Mode).", - 'Signal: signal-cli linked device; more setup (David Reagans: "Hop on Discord.").', - "iMessage: this is still a work in progress.", + ...providerLines, ].join("\n"), "How providers work", ); @@ -143,7 +139,7 @@ async function noteTelegramTokenHelp(prompter: WizardPrompter): Promise { "2) Run /newbot (or /mybots)", "3) Copy the token (looks like 123456:ABC...)", "Tip: you can also set TELEGRAM_BOT_TOKEN in your env.", - `Docs: ${docsLink("/telegram", "telegram")}`, + `Docs: ${formatDocsLink("/telegram", "telegram")}`, ].join("\n"), "Telegram bot token", ); @@ -156,7 +152,7 @@ async function noteDiscordTokenHelp(prompter: WizardPrompter): Promise { "2) Bot → Add Bot → Reset Token → copy token", "3) OAuth2 → URL Generator → scope 'bot' → invite to your server", "Tip: enable Message Content Intent if you need message text.", - `Docs: ${docsLink("/discord", "discord")}`, + `Docs: ${formatDocsLink("/discord", "discord")}`, ].join("\n"), "Discord bot token", ); @@ -244,7 +240,7 @@ async function noteSlackTokenHelp( "4) Enable Event Subscriptions (socket) for message events", "5) App Home → enable the Messages tab for DMs", "Tip: set SLACK_BOT_TOKEN + SLACK_APP_TOKEN in your env.", - `Docs: ${docsLink("/slack", "slack")}`, + `Docs: ${formatDocsLink("/slack", "slack")}`, "", "Manifest (JSON):", manifest, @@ -417,7 +413,7 @@ async function maybeConfigureDmPolicies(params: { "Default: pairing (unknown DMs get a pairing code).", `Approve: clawdbot pairing approve --provider ${params.provider} `, `Public DMs: ${params.policyKey}="open" + ${params.allowFromKey} includes "*".`, - `Docs: ${docsLink("/start/pairing", "start/pairing")}`, + `Docs: ${formatDocsLink("/start/pairing", "start/pairing")}`, ].join("\n"), `${params.label} DM access`, ); @@ -504,7 +500,7 @@ async function promptWhatsAppAllowFrom( "- disabled: ignore WhatsApp DMs", "", `Current: dmPolicy=${existingPolicy}, allowFrom=${existingLabel}`, - `Docs: ${docsLink("/whatsapp", "whatsapp")}`, + `Docs: ${formatDocsLink("/whatsapp", "whatsapp")}`, ].join("\n"), "WhatsApp DM access", ); @@ -712,42 +708,57 @@ export async function setupProviders( await noteProviderPrimer(prompter); + const selectionOptions = listChatProviders().map((meta) => { + switch (meta.id) { + case "telegram": + return { + value: meta.id, + label: meta.selectionLabel, + hint: telegramConfigured + ? "recommended · configured" + : "recommended · newcomer-friendly", + }; + case "whatsapp": + return { + value: meta.id, + label: meta.selectionLabel, + hint: whatsappLinked ? "linked" : "not linked", + }; + case "discord": + return { + value: meta.id, + label: meta.selectionLabel, + hint: discordConfigured ? "configured" : "needs token", + }; + case "slack": + return { + value: meta.id, + label: meta.selectionLabel, + hint: slackConfigured ? "configured" : "needs tokens", + }; + case "signal": + return { + value: meta.id, + label: meta.selectionLabel, + hint: signalCliDetected ? "signal-cli found" : "signal-cli missing", + }; + case "imessage": + return { + value: meta.id, + label: meta.selectionLabel, + hint: imessageCliDetected ? "imsg found" : "imsg missing", + }; + default: + return { + value: meta.id, + label: meta.selectionLabel, + }; + } + }); + const selection = (await prompter.multiselect({ message: "Select providers", - options: [ - { - value: "telegram", - label: "Telegram (Bot API)", - hint: telegramConfigured - ? "recommended · configured" - : "recommended · newcomer-friendly", - }, - { - value: "whatsapp", - label: "WhatsApp (QR link)", - hint: whatsappLinked ? "linked" : "not linked", - }, - { - value: "discord", - label: "Discord (Bot API)", - hint: discordConfigured ? "configured" : "needs token", - }, - { - value: "slack", - label: "Slack (Socket Mode)", - hint: slackConfigured ? "configured" : "needs tokens", - }, - { - value: "signal", - label: "Signal (signal-cli)", - hint: signalCliDetected ? "signal-cli found" : "signal-cli missing", - }, - { - value: "imessage", - label: "iMessage (imsg)", - hint: imessageCliDetected ? "imsg found" : "imsg missing", - }, - ], + options: selectionOptions, })) as ProviderChoice[]; options?.onSelection?.(selection); @@ -764,17 +775,15 @@ export async function setupProviders( } }; - const selectionNotes: Record = { - telegram: `Telegram — simplest way to get started: register a bot with @BotFather and get going. Docs: ${docsLink("/telegram", "telegram")}`, - whatsapp: `WhatsApp — works with your own number; recommend a separate phone + eSIM. Docs: ${docsLink("/whatsapp", "whatsapp")}`, - discord: `Discord — very well supported right now. Docs: ${docsLink("/discord", "discord")}`, - slack: `Slack — supported (Socket Mode). Docs: ${docsLink("/slack", "slack")}`, - signal: `Signal — signal-cli linked device; more setup (David Reagans: "Hop on Discord."). Docs: ${docsLink("/signal", "signal")}`, - imessage: `iMessage — this is still a work in progress. Docs: ${docsLink("/imessage", "imessage")}`, - }; + const selectionNotes = new Map( + listChatProviders().map((meta) => [ + meta.id, + formatProviderSelectionLine(meta, formatDocsLink), + ]), + ); const selectedLines = selection - .map((provider) => selectionNotes[provider]) - .filter(Boolean); + .map((provider) => selectionNotes.get(provider)) + .filter((line): line is string => Boolean(line)); if (selectedLines.length > 0) { await prompter.note(selectedLines.join("\n"), "Selected providers"); } @@ -827,7 +836,7 @@ export async function setupProviders( [ "Scan the QR with WhatsApp on your phone.", `Credentials are stored under ${authDir}/ for future runs.`, - `Docs: ${docsLink("/whatsapp", "whatsapp")}`, + `Docs: ${formatDocsLink("/whatsapp", "whatsapp")}`, ].join("\n"), "WhatsApp linking", ); @@ -844,7 +853,7 @@ export async function setupProviders( } catch (err) { runtime.error(`WhatsApp login failed: ${String(err)}`); await prompter.note( - `Docs: ${docsLink("/whatsapp", "whatsapp")}`, + `Docs: ${formatDocsLink("/whatsapp", "whatsapp")}`, "WhatsApp help", ); } @@ -1328,7 +1337,7 @@ export async function setupProviders( 'Link device with: signal-cli link -n "Clawdbot"', "Scan QR in Signal → Linked Devices", "Then run: clawdbot gateway call providers.status --params '{\"probe\":true}'", - `Docs: ${docsLink("/signal", "signal")}`, + `Docs: ${formatDocsLink("/signal", "signal")}`, ].join("\n"), "Signal next steps", ); @@ -1409,7 +1418,7 @@ export async function setupProviders( "Ensure Clawdbot has Full Disk Access to Messages DB.", "Grant Automation permission for Messages when prompted.", "List chats with: imsg chats --limit 20", - `Docs: ${docsLink("/imessage", "imessage")}`, + `Docs: ${formatDocsLink("/imessage", "imessage")}`, ].join("\n"), "iMessage next steps", ); diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index ef6227f2e..09feace3b 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -1,3 +1,4 @@ +import type { ChatProviderId } from "../providers/registry.js"; import type { GatewayDaemonRuntime } from "./daemon-runtime.js"; export type OnboardMode = "local" | "remote"; @@ -15,13 +16,7 @@ export type ResetScope = "config" | "config+creds+sessions" | "full"; export type GatewayBind = "loopback" | "lan" | "tailnet" | "auto"; export type TailscaleMode = "off" | "serve" | "funnel"; export type NodeManagerChoice = "npm" | "pnpm" | "bun"; -export type ProviderChoice = - | "whatsapp" - | "telegram" - | "discord" - | "slack" - | "signal" - | "imessage"; +export type ProviderChoice = ChatProviderId; export type OnboardOptions = { mode?: OnboardMode; diff --git a/src/commands/providers.test.ts b/src/commands/providers.test.ts index 382683b17..f42717ee9 100644 --- a/src/commands/providers.test.ts +++ b/src/commands/providers.test.ts @@ -16,7 +16,11 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); -import { providersAddCommand, providersRemoveCommand } from "./providers.js"; +import { + formatGatewayProvidersStatusLines, + providersAddCommand, + providersRemoveCommand, +} from "./providers.js"; const runtime: RuntimeEnv = { log: vi.fn(), @@ -111,4 +115,83 @@ describe("providers command", () => { expect(next.discord?.accounts?.work).toBeUndefined(); expect(next.discord?.accounts?.default?.token).toBe("d0"); }); + + it("stores default account names in accounts when multiple accounts exist", async () => { + configMocks.readConfigFileSnapshot.mockResolvedValue({ + ...baseSnapshot, + config: { + telegram: { + name: "Legacy Name", + accounts: { + work: { botToken: "t0" }, + }, + }, + }, + }); + + await providersAddCommand( + { + provider: "telegram", + account: "default", + token: "123:abc", + name: "Primary Bot", + }, + runtime, + { hasFlags: true }, + ); + + const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as { + telegram?: { + name?: string; + accounts?: Record; + }; + }; + expect(next.telegram?.name).toBeUndefined(); + expect(next.telegram?.accounts?.default?.name).toBe("Primary Bot"); + }); + + it("migrates base names when adding non-default accounts", async () => { + configMocks.readConfigFileSnapshot.mockResolvedValue({ + ...baseSnapshot, + config: { + discord: { + name: "Primary Bot", + token: "d0", + }, + }, + }); + + await providersAddCommand( + { provider: "discord", account: "work", token: "d1" }, + runtime, + { hasFlags: true }, + ); + + const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as { + discord?: { + name?: string; + accounts?: Record; + }; + }; + expect(next.discord?.name).toBeUndefined(); + expect(next.discord?.accounts?.default?.name).toBe("Primary Bot"); + expect(next.discord?.accounts?.work?.token).toBe("d1"); + }); + + it("formats gateway provider status lines in registry order", () => { + const lines = formatGatewayProvidersStatusLines({ + telegramAccounts: [{ accountId: "default", configured: true }], + whatsappAccounts: [{ accountId: "default", linked: true }], + }); + + const telegramIndex = lines.findIndex((line) => + line.includes("Telegram default"), + ); + const whatsappIndex = lines.findIndex((line) => + line.includes("WhatsApp default"), + ); + expect(telegramIndex).toBeGreaterThan(-1); + expect(whatsappIndex).toBeGreaterThan(-1); + expect(telegramIndex).toBeLessThan(whatsappIndex); + }); }); diff --git a/src/commands/providers.ts b/src/commands/providers.ts index a829689f4..e60737c13 100644 --- a/src/commands/providers.ts +++ b/src/commands/providers.ts @@ -4,8 +4,11 @@ import { loadAuthProfileStore, } from "../agents/auth-profiles.js"; import { withProgress } from "../cli/progress.js"; -import type { ClawdbotConfig } from "../config/config.js"; -import { readConfigFileSnapshot, writeConfigFile } from "../config/config.js"; +import { + type ClawdbotConfig, + readConfigFileSnapshot, + writeConfigFile, +} from "../config/config.js"; import { listDiscordAccountIds, resolveDiscordAccount, @@ -19,12 +22,17 @@ 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 type { RuntimeEnv } from "../runtime.js"; -import { defaultRuntime } from "../runtime.js"; +import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { listSignalAccountIds, resolveSignalAccount, @@ -34,8 +42,8 @@ import { listTelegramAccountIds, resolveTelegramAccount, } from "../telegram/accounts.js"; +import { formatDocsLink } from "../terminal/links.js"; import { theme } from "../terminal/theme.js"; -import { formatTerminalLink } from "../utils.js"; import { listWhatsAppAccountIds, resolveWhatsAppAuthDir, @@ -45,23 +53,7 @@ import { createClackPrompter } from "../wizard/clack-prompter.js"; import { setupProviders } from "./onboard-providers.js"; import type { ProviderChoice } from "./onboard-types.js"; -const DOCS_ROOT = "https://docs.clawd.bot"; - -const CHAT_PROVIDERS = [ - "whatsapp", - "telegram", - "discord", - "slack", - "signal", - "imessage", -] as const; - -type ChatProvider = (typeof CHAT_PROVIDERS)[number]; - -function docsLink(path: string, label?: string): string { - const url = `${DOCS_ROOT}${path}`; - return formatTerminalLink(label ?? url, url, { fallback: url }); -} +type ChatProvider = ChatProviderId; type ProvidersListOptions = { json?: boolean; @@ -100,15 +92,6 @@ export type ProvidersRemoveOptions = { delete?: boolean; }; -function normalizeChatProvider(raw?: string): ChatProvider | null { - const trimmed = (raw ?? "").trim().toLowerCase(); - if (!trimmed) return null; - const normalized = trimmed === "imsg" ? "imessage" : trimmed; - return CHAT_PROVIDERS.includes(normalized as ChatProvider) - ? (normalized as ChatProvider) - : null; -} - async function requireValidConfig( runtime: RuntimeEnv, ): Promise { @@ -134,6 +117,9 @@ function formatAccountLabel(params: { accountId: string; name?: string }) { 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); @@ -162,6 +148,55 @@ 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; @@ -187,7 +222,8 @@ function applyAccountName(params: { }; } const key = params.provider; - if (accountId === DEFAULT_ACCOUNT_ID) { + 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 @@ -202,17 +238,21 @@ function applyAccountName(params: { } as ClawdbotConfig; } const base = (params.cfg as Record)[key] as - | { accounts?: Record> } + | { 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]: { - ...base, + ...baseWithoutName, accounts: { ...baseAccounts, [accountId]: { @@ -246,19 +286,22 @@ function applyProviderAccountConfig(params: { }): ClawdbotConfig { const accountId = normalizeAccountId(params.accountId); const name = params.name?.trim() || undefined; - const next = applyAccountName({ + 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, - ...(name ? { name } : {}), }; return { ...next, @@ -286,7 +329,6 @@ function applyProviderAccountConfig(params: { : params.token ? { botToken: params.token } : {}), - ...(name ? { name } : {}), }, }; } @@ -305,7 +347,6 @@ function applyProviderAccountConfig(params: { : params.token ? { botToken: params.token } : {}), - ...(name ? { name } : {}), }, }, }, @@ -320,7 +361,6 @@ function applyProviderAccountConfig(params: { ...next.discord, enabled: true, ...(params.useEnv ? {} : params.token ? { token: params.token } : {}), - ...(name ? { name } : {}), }, }; } @@ -335,7 +375,6 @@ function applyProviderAccountConfig(params: { ...next.discord?.accounts?.[accountId], enabled: true, ...(params.token ? { token: params.token } : {}), - ...(name ? { name } : {}), }, }, }, @@ -355,7 +394,6 @@ function applyProviderAccountConfig(params: { ...(params.botToken ? { botToken: params.botToken } : {}), ...(params.appToken ? { appToken: params.appToken } : {}), }), - ...(name ? { name } : {}), }, }; } @@ -371,7 +409,6 @@ function applyProviderAccountConfig(params: { enabled: true, ...(params.botToken ? { botToken: params.botToken } : {}), ...(params.appToken ? { appToken: params.appToken } : {}), - ...(name ? { name } : {}), }, }, }, @@ -390,7 +427,6 @@ function applyProviderAccountConfig(params: { ...(params.httpUrl ? { httpUrl: params.httpUrl } : {}), ...(params.httpHost ? { httpHost: params.httpHost } : {}), ...(params.httpPort ? { httpPort: Number(params.httpPort) } : {}), - ...(name ? { name } : {}), }, }; } @@ -409,7 +445,6 @@ function applyProviderAccountConfig(params: { ...(params.httpUrl ? { httpUrl: params.httpUrl } : {}), ...(params.httpHost ? { httpHost: params.httpHost } : {}), ...(params.httpPort ? { httpPort: Number(params.httpPort) } : {}), - ...(name ? { name } : {}), }, }, }, @@ -427,7 +462,6 @@ function applyProviderAccountConfig(params: { ...(params.dbPath ? { dbPath: params.dbPath } : {}), ...(params.service ? { service: params.service } : {}), ...(params.region ? { region: params.region } : {}), - ...(name ? { name } : {}), }, }; } @@ -445,7 +479,6 @@ function applyProviderAccountConfig(params: { ...(params.dbPath ? { dbPath: params.dbPath } : {}), ...(params.service ? { service: params.service } : {}), ...(params.region ? { region: params.region } : {}), - ...(name ? { name } : {}), }, }, }, @@ -502,12 +535,26 @@ export async function providersListCommand( 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("WhatsApp")} ${theme.heading( + `- ${theme.accent(providerLabel("whatsapp"))} ${theme.heading( formatAccountLabel({ accountId, name, @@ -520,24 +567,10 @@ export async function providersListCommand( ); } - for (const accountId of telegramAccounts) { - const account = resolveTelegramAccount({ cfg, accountId }); - lines.push( - `- ${theme.accent("Telegram")} ${theme.heading( - formatAccountLabel({ - accountId, - name: account.name, - }), - )}: ${formatConfigured(Boolean(account.token))}, ${formatTokenSource( - account.tokenSource, - )}, ${formatEnabled(account.enabled)}`, - ); - } - for (const accountId of discordAccounts) { const account = resolveDiscordAccount({ cfg, accountId }); lines.push( - `- ${theme.accent("Discord")} ${theme.heading( + `- ${theme.accent(providerLabel("discord"))} ${theme.heading( formatAccountLabel({ accountId, name: account.name, @@ -552,7 +585,7 @@ export async function providersListCommand( const account = resolveSlackAccount({ cfg, accountId }); const configured = Boolean(account.botToken && account.appToken); lines.push( - `- ${theme.accent("Slack")} ${theme.heading( + `- ${theme.accent(providerLabel("slack"))} ${theme.heading( formatAccountLabel({ accountId, name: account.name, @@ -569,7 +602,7 @@ export async function providersListCommand( for (const accountId of signalAccounts) { const account = resolveSignalAccount({ cfg, accountId }); lines.push( - `- ${theme.accent("Signal")} ${theme.heading( + `- ${theme.accent(providerLabel("signal"))} ${theme.heading( formatAccountLabel({ accountId, name: account.name, @@ -583,7 +616,7 @@ export async function providersListCommand( for (const accountId of imessageAccounts) { const account = resolveIMessageAccount({ cfg, accountId }); lines.push( - `- ${theme.accent("iMessage")} ${theme.heading( + `- ${theme.accent(providerLabel("imessage"))} ${theme.heading( formatAccountLabel({ accountId, name: account.name, @@ -621,9 +654,7 @@ export async function providersListCommand( runtime.log(""); runtime.log( - `Docs: gateway/configuration -> ${formatTerminalLink(DOCS_ROOT, DOCS_ROOT, { - fallback: DOCS_ROOT, - })}`, + `Docs: ${formatDocsLink("/gateway/configuration", "gateway/configuration")}`, ); } @@ -641,6 +672,80 @@ async function loadUsageWithProgress( } } +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, @@ -664,96 +769,11 @@ export async function providersStatusCommand( runtime.log(JSON.stringify(payload, null, 2)); return; } - const data = payload as Record; - 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(", ")}`; - }); - - if (Array.isArray(data.whatsappAccounts)) { - lines.push( - ...accountLines( - "WhatsApp", - data.whatsappAccounts as Array>, - ), - ); - } - if (Array.isArray(data.telegramAccounts)) { - lines.push( - ...accountLines( - "Telegram", - data.telegramAccounts as Array>, - ), - ); - } - if (Array.isArray(data.discordAccounts)) { - lines.push( - ...accountLines( - "Discord", - data.discordAccounts as Array>, - ), - ); - } - if (Array.isArray(data.slackAccounts)) { - lines.push( - ...accountLines( - "Slack", - data.slackAccounts as Array>, - ), - ); - } - if (Array.isArray(data.signalAccounts)) { - lines.push( - ...accountLines( - "Signal", - data.signalAccounts as Array>, - ), - ); - } - if (Array.isArray(data.imessageAccounts)) { - lines.push( - ...accountLines( - "iMessage", - data.imessageAccounts as Array>, - ), - ); - } - - lines.push(""); - lines.push( - `Tip: ${docsLink("/cli#status", "status --deep")} runs local probes without a gateway.`, + runtime.log( + formatGatewayProvidersStatusLines( + payload as Record, + ).join("\n"), ); - runtime.log(lines.join("\n")); } catch (err) { runtime.error(`Gateway not reachable: ${String(err)}`); runtime.exit(1); @@ -768,7 +788,7 @@ export async function providersAddCommand( const cfg = await requireValidConfig(runtime); if (!cfg) return; - const useWizard = params?.hasFlags === false; + const useWizard = shouldUseWizard(params); if (useWizard) { const prompter = createClackPrompter(); let selection: ProviderChoice[] = []; @@ -836,7 +856,7 @@ export async function providersAddCommand( return; } - const provider = normalizeChatProvider(opts.provider); + const provider = normalizeChatProviderId(opts.provider); if (!provider) { runtime.error(`Unknown provider: ${String(opts.provider ?? "")}`); runtime.exit(1); @@ -930,7 +950,7 @@ export async function providersAddCommand( }); await writeConfigFile(nextConfig); - runtime.log(`Added ${provider} account "${accountId}".`); + runtime.log(`Added ${providerLabel(provider)} account "${accountId}".`); } export async function providersRemoveCommand( @@ -941,9 +961,9 @@ export async function providersRemoveCommand( const cfg = await requireValidConfig(runtime); if (!cfg) return; - const useWizard = params?.hasFlags === false; + const useWizard = shouldUseWizard(params); const prompter = useWizard ? createClackPrompter() : null; - let provider = normalizeChatProvider(opts.provider); + let provider = normalizeChatProviderId(opts.provider); let accountId = normalizeAccountId(opts.account); const deleteConfig = Boolean(opts.delete); @@ -951,9 +971,9 @@ export async function providersRemoveCommand( await prompter.intro("Remove provider account"); provider = (await prompter.select({ message: "Provider", - options: CHAT_PROVIDERS.map((value) => ({ - value, - label: value, + options: listChatProviders().map((meta) => ({ + value: meta.id, + label: meta.label, })), })) as ChatProvider; @@ -983,7 +1003,7 @@ export async function providersRemoveCommand( })(); const wantsDisable = await prompter.confirm({ - message: `Disable ${provider} account "${accountId}"? (keeps config)`, + message: `Disable ${providerLabel(provider)} account "${accountId}"? (keeps config)`, initialValue: true, }); if (!wantsDisable) { @@ -999,7 +1019,7 @@ export async function providersRemoveCommand( if (!deleteConfig) { const confirm = createClackPrompter(); const ok = await confirm.confirm({ - message: `Disable ${provider} account "${accountId}"? (keeps config)`, + message: `Disable ${providerLabel(provider)} account "${accountId}"? (keeps config)`, initialValue: true, }); if (!ok) { @@ -1147,14 +1167,14 @@ export async function providersRemoveCommand( if (useWizard && prompter) { await prompter.outro( deleteConfig - ? `Deleted ${provider} account "${accountKey}".` - : `Disabled ${provider} account "${accountKey}".`, + ? `Deleted ${providerLabel(provider)} account "${accountKey}".` + : `Disabled ${providerLabel(provider)} account "${accountKey}".`, ); } else { runtime.log( deleteConfig - ? `Deleted ${provider} account "${accountKey}".` - : `Disabled ${provider} account "${accountKey}".`, + ? `Deleted ${providerLabel(provider)} account "${accountKey}".` + : `Disabled ${providerLabel(provider)} account "${accountKey}".`, ); } } diff --git a/src/providers/registry.test.ts b/src/providers/registry.test.ts new file mode 100644 index 000000000..d4cc99730 --- /dev/null +++ b/src/providers/registry.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +import { + formatProviderSelectionLine, + listChatProviders, + normalizeChatProviderId, +} from "./registry.js"; + +describe("provider registry", () => { + it("normalizes aliases", () => { + expect(normalizeChatProviderId("imsg")).toBe("imessage"); + }); + + it("keeps Telegram first in the default order", () => { + const providers = listChatProviders(); + expect(providers[0]?.id).toBe("telegram"); + }); + + it("formats selection lines with docs labels", () => { + const providers = listChatProviders(); + const first = providers[0]; + if (!first) throw new Error("Missing provider metadata."); + const line = formatProviderSelectionLine(first, (path, label) => + [label, path].filter(Boolean).join(":"), + ); + expect(line).toContain("Docs:"); + expect(line).toContain("telegram"); + }); +}); diff --git a/src/providers/registry.ts b/src/providers/registry.ts new file mode 100644 index 000000000..f021dd18a --- /dev/null +++ b/src/providers/registry.ts @@ -0,0 +1,109 @@ +export const CHAT_PROVIDER_ORDER = [ + "telegram", + "whatsapp", + "discord", + "slack", + "signal", + "imessage", +] as const; + +export type ChatProviderId = (typeof CHAT_PROVIDER_ORDER)[number]; + +export type ChatProviderMeta = { + id: ChatProviderId; + label: string; + selectionLabel: string; + docsPath: string; + docsLabel?: string; + blurb: string; +}; + +const CHAT_PROVIDER_META: Record = { + telegram: { + id: "telegram", + label: "Telegram", + selectionLabel: "Telegram (Bot API)", + docsPath: "/telegram", + docsLabel: "telegram", + blurb: + "simplest way to get started — register a bot with @BotFather and get going.", + }, + whatsapp: { + id: "whatsapp", + label: "WhatsApp", + selectionLabel: "WhatsApp (QR link)", + docsPath: "/whatsapp", + docsLabel: "whatsapp", + blurb: "works with your own number; recommend a separate phone + eSIM.", + }, + discord: { + id: "discord", + label: "Discord", + selectionLabel: "Discord (Bot API)", + docsPath: "/discord", + docsLabel: "discord", + blurb: "very well supported right now.", + }, + slack: { + id: "slack", + label: "Slack", + selectionLabel: "Slack (Socket Mode)", + docsPath: "/slack", + docsLabel: "slack", + blurb: "supported (Socket Mode).", + }, + signal: { + id: "signal", + label: "Signal", + selectionLabel: "Signal (signal-cli)", + docsPath: "/signal", + docsLabel: "signal", + blurb: + 'signal-cli linked device; more setup (David Reagans: "Hop on Discord.").', + }, + imessage: { + id: "imessage", + label: "iMessage", + selectionLabel: "iMessage (imsg)", + docsPath: "/imessage", + docsLabel: "imessage", + blurb: "this is still a work in progress.", + }, +}; + +const CHAT_PROVIDER_ALIASES: Record = { + imsg: "imessage", +}; + +export function listChatProviders(): ChatProviderMeta[] { + return CHAT_PROVIDER_ORDER.map((id) => CHAT_PROVIDER_META[id]); +} + +export function getChatProviderMeta(id: ChatProviderId): ChatProviderMeta { + return CHAT_PROVIDER_META[id]; +} + +export function normalizeChatProviderId( + raw?: string | null, +): ChatProviderId | null { + const trimmed = (raw ?? "").trim().toLowerCase(); + if (!trimmed) return null; + const normalized = CHAT_PROVIDER_ALIASES[trimmed] ?? trimmed; + return CHAT_PROVIDER_ORDER.includes(normalized as ChatProviderId) + ? (normalized as ChatProviderId) + : null; +} + +export function formatProviderPrimerLine(meta: ChatProviderMeta): string { + return `${meta.label}: ${meta.blurb}`; +} + +export function formatProviderSelectionLine( + meta: ChatProviderMeta, + docsLink: (path: string, label?: string) => string, +): string { + return `${meta.label} — ${meta.blurb} Docs: ${docsLink( + meta.docsPath, + meta.docsLabel ?? meta.id, + )}`; +} diff --git a/src/terminal/links.ts b/src/terminal/links.ts new file mode 100644 index 000000000..b26ff37bb --- /dev/null +++ b/src/terminal/links.ts @@ -0,0 +1,24 @@ +import { formatTerminalLink } from "../utils.js"; + +export const DOCS_ROOT = "https://docs.clawd.bot"; + +export function formatDocsLink( + path: string, + label?: string, + opts?: { fallback?: string; force?: boolean }, +): string { + const trimmed = path.trim(); + const url = trimmed.startsWith("http") + ? trimmed + : `${DOCS_ROOT}${trimmed.startsWith("/") ? trimmed : `/${trimmed}`}`; + return formatTerminalLink(label ?? url, url, { + fallback: opts?.fallback ?? url, + force: opts?.force, + }); +} + +export function formatDocsRootLink(label?: string): string { + return formatTerminalLink(label ?? DOCS_ROOT, DOCS_ROOT, { + fallback: DOCS_ROOT, + }); +}