feat: refine providers onboarding and cli

This commit is contained in:
Peter Steinberger
2026-01-08 06:25:01 +01:00
parent f2557d5390
commit b50ea3ec59
14 changed files with 705 additions and 261 deletions

View File

@@ -654,7 +654,8 @@
"platforms/ios", "platforms/ios",
"platforms/android", "platforms/android",
"platforms/windows", "platforms/windows",
"platforms/linux" "platforms/linux",
"platforms/exe-dev"
] ]
}, },
{ {

174
docs/platforms/exe-dev.md Normal file
View File

@@ -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 dont 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 youre 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.devs proxy at `8080` (or whatever port you chose) and open your VMs 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)

View File

@@ -19,7 +19,7 @@ Status: external CLI integration. Gateway spawns `imsg rpc` (JSON-RPC over stdio
- macOS with Messages signed in. - macOS with Messages signed in.
- Full Disk Access for Clawdbot + `imsg` (Messages DB access). - Full Disk Access for Clawdbot + `imsg` (Messages DB access).
- Automation permission when sending. - 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) ## Setup (fast path)
1) Ensure Messages is signed in on this Mac. 1) Ensure Messages is signed in on this Mac.
@@ -46,7 +46,7 @@ Example:
{ {
imessage: { imessage: {
enabled: true, enabled: true,
cliPath: "imsg", cliPath: "/usr/local/bin/imessage-remote",
dmPolicy: "pairing", dmPolicy: "pairing",
allowFrom: ["+15555550123"] allowFrom: ["+15555550123"]
} }

View File

@@ -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 wont be able to respond without it. If `health` shows “no auth configured”, go back to the wizard and set OAuth/key auth — the agent wont be able to respond without it.
Local probe tip: `pnpm clawdbot status --deep` runs provider checks without needing a gateway connection. 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) ## Next steps (optional, but great)

View File

@@ -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: This onboarding chat is where the agent:
- does the BOOTSTRAP.md identity ritual (one question at a time) - does the BOOTSTRAP.md identity ritual (one question at a time)
- visits **soul.md** with the user and writes `SOUL.md` (values, tone, boundaries) - 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) - 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. If the workspace bootstrap is already complete (BOOTSTRAP.md removed), the onboarding chat step is skipped.

View File

@@ -30,7 +30,7 @@ clawdbot configure
- Model/auth (Anthropic or OpenAI Codex OAuth recommended, API key optional, Minimax M2.1 via LM Studio) - Model/auth (Anthropic or OpenAI Codex OAuth recommended, API key optional, Minimax M2.1 via LM Studio)
- Workspace location + bootstrap files - Workspace location + bootstrap files
- Gateway settings (port/bind/auth/tailscale) - Gateway settings (port/bind/auth/tailscale)
- Providers (WhatsApp, Telegram, Discord, Signal) - Providers (Telegram, WhatsApp, Discord, Signal)
- Daemon install (LaunchAgent / systemd user unit) - Daemon install (LaunchAgent / systemd user unit)
- Health check - Health check
- Skills (recommended) - Skills (recommended)

View File

@@ -6,6 +6,7 @@ import {
providersRemoveCommand, providersRemoveCommand,
providersStatusCommand, providersStatusCommand,
} from "../commands/providers.js"; } from "../commands/providers.js";
import { listChatProviders } from "../providers/registry.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { hasExplicitOptions } from "./command-options.js"; import { hasExplicitOptions } from "./command-options.js";
@@ -31,6 +32,10 @@ const optionNamesAdd = [
const optionNamesRemove = ["provider", "account", "delete"] as const; const optionNamesRemove = ["provider", "account", "delete"] as const;
const providerNames = listChatProviders()
.map((meta) => meta.id)
.join("|");
export function registerProvidersCli(program: Command) { export function registerProvidersCli(program: Command) {
const providers = program const providers = program
.command("providers") .command("providers")
@@ -69,10 +74,7 @@ export function registerProvidersCli(program: Command) {
providers providers
.command("add") .command("add")
.description("Add or update a provider account") .description("Add or update a provider account")
.option( .option("--provider <name>", `Provider (${providerNames})`)
"--provider <name>",
"Provider (whatsapp|telegram|discord|slack|signal|imessage)",
)
.option("--account <id>", "Account id (default when omitted)") .option("--account <id>", "Account id (default when omitted)")
.option("--name <name>", "Display name for this account") .option("--name <name>", "Display name for this account")
.option("--token <token>", "Bot token (Telegram/Discord)") .option("--token <token>", "Bot token (Telegram/Discord)")
@@ -102,10 +104,7 @@ export function registerProvidersCli(program: Command) {
providers providers
.command("remove") .command("remove")
.description("Disable or delete a provider account") .description("Disable or delete a provider account")
.option( .option("--provider <name>", `Provider (${providerNames})`)
"--provider <name>",
"Provider (whatsapp|telegram|discord|slack|signal|imessage)",
)
.option("--account <id>", "Account id (default when omitted)") .option("--account <id>", "Account id (default when omitted)")
.option("--delete", "Delete config entries (no prompt)", false) .option("--delete", "Delete config entries (no prompt)", false)
.action(async (opts, command) => { .action(async (opts, command) => {

View File

@@ -13,6 +13,11 @@ import {
resolveIMessageAccount, resolveIMessageAccount,
} from "../imessage/accounts.js"; } from "../imessage/accounts.js";
import { loginWeb } from "../provider-web.js"; import { loginWeb } from "../provider-web.js";
import {
formatProviderPrimerLine,
formatProviderSelectionLine,
listChatProviders,
} from "../providers/registry.js";
import { import {
DEFAULT_ACCOUNT_ID, DEFAULT_ACCOUNT_ID,
normalizeAccountId, normalizeAccountId,
@@ -33,7 +38,8 @@ import {
resolveDefaultTelegramAccountId, resolveDefaultTelegramAccountId,
resolveTelegramAccount, resolveTelegramAccount,
} from "../telegram/accounts.js"; } from "../telegram/accounts.js";
import { formatTerminalLink, normalizeE164 } from "../utils.js"; import { formatDocsLink } from "../terminal/links.js";
import { normalizeE164 } from "../utils.js";
import { import {
listWhatsAppAccountIds, listWhatsAppAccountIds,
resolveDefaultWhatsAppAccountId, resolveDefaultWhatsAppAccountId,
@@ -44,14 +50,6 @@ import { detectBinary } from "./onboard-helpers.js";
import type { ProviderChoice } from "./onboard-types.js"; import type { ProviderChoice } from "./onboard-types.js";
import { installSignalCli } from "./signal-install.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: { async function promptAccountId(params: {
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
prompter: WizardPrompter; prompter: WizardPrompter;
@@ -118,19 +116,17 @@ async function detectWhatsAppLinked(
} }
async function noteProviderPrimer(prompter: WizardPrompter): Promise<void> { async function noteProviderPrimer(prompter: WizardPrompter): Promise<void> {
const providerLines = listChatProviders().map((meta) =>
formatProviderPrimerLine(meta),
);
await prompter.note( await prompter.note(
[ [
"DM security: default is pairing; unknown DMs get a pairing code.", "DM security: default is pairing; unknown DMs get a pairing code.",
"Approve with: clawdbot pairing approve --provider <provider> <code>", "Approve with: clawdbot pairing approve --provider <provider> <code>",
'Public DMs require dmPolicy="open" + allowFrom=["*"].', '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.", ...providerLines,
"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.",
].join("\n"), ].join("\n"),
"How providers work", "How providers work",
); );
@@ -143,7 +139,7 @@ async function noteTelegramTokenHelp(prompter: WizardPrompter): Promise<void> {
"2) Run /newbot (or /mybots)", "2) Run /newbot (or /mybots)",
"3) Copy the token (looks like 123456:ABC...)", "3) Copy the token (looks like 123456:ABC...)",
"Tip: you can also set TELEGRAM_BOT_TOKEN in your env.", "Tip: you can also set TELEGRAM_BOT_TOKEN in your env.",
`Docs: ${docsLink("/telegram", "telegram")}`, `Docs: ${formatDocsLink("/telegram", "telegram")}`,
].join("\n"), ].join("\n"),
"Telegram bot token", "Telegram bot token",
); );
@@ -156,7 +152,7 @@ async function noteDiscordTokenHelp(prompter: WizardPrompter): Promise<void> {
"2) Bot → Add Bot → Reset Token → copy token", "2) Bot → Add Bot → Reset Token → copy token",
"3) OAuth2 → URL Generator → scope 'bot' → invite to your server", "3) OAuth2 → URL Generator → scope 'bot' → invite to your server",
"Tip: enable Message Content Intent if you need message text.", "Tip: enable Message Content Intent if you need message text.",
`Docs: ${docsLink("/discord", "discord")}`, `Docs: ${formatDocsLink("/discord", "discord")}`,
].join("\n"), ].join("\n"),
"Discord bot token", "Discord bot token",
); );
@@ -244,7 +240,7 @@ async function noteSlackTokenHelp(
"4) Enable Event Subscriptions (socket) for message events", "4) Enable Event Subscriptions (socket) for message events",
"5) App Home → enable the Messages tab for DMs", "5) App Home → enable the Messages tab for DMs",
"Tip: set SLACK_BOT_TOKEN + SLACK_APP_TOKEN in your env.", "Tip: set SLACK_BOT_TOKEN + SLACK_APP_TOKEN in your env.",
`Docs: ${docsLink("/slack", "slack")}`, `Docs: ${formatDocsLink("/slack", "slack")}`,
"", "",
"Manifest (JSON):", "Manifest (JSON):",
manifest, manifest,
@@ -417,7 +413,7 @@ async function maybeConfigureDmPolicies(params: {
"Default: pairing (unknown DMs get a pairing code).", "Default: pairing (unknown DMs get a pairing code).",
`Approve: clawdbot pairing approve --provider ${params.provider} <code>`, `Approve: clawdbot pairing approve --provider ${params.provider} <code>`,
`Public DMs: ${params.policyKey}="open" + ${params.allowFromKey} includes "*".`, `Public DMs: ${params.policyKey}="open" + ${params.allowFromKey} includes "*".`,
`Docs: ${docsLink("/start/pairing", "start/pairing")}`, `Docs: ${formatDocsLink("/start/pairing", "start/pairing")}`,
].join("\n"), ].join("\n"),
`${params.label} DM access`, `${params.label} DM access`,
); );
@@ -504,7 +500,7 @@ async function promptWhatsAppAllowFrom(
"- disabled: ignore WhatsApp DMs", "- disabled: ignore WhatsApp DMs",
"", "",
`Current: dmPolicy=${existingPolicy}, allowFrom=${existingLabel}`, `Current: dmPolicy=${existingPolicy}, allowFrom=${existingLabel}`,
`Docs: ${docsLink("/whatsapp", "whatsapp")}`, `Docs: ${formatDocsLink("/whatsapp", "whatsapp")}`,
].join("\n"), ].join("\n"),
"WhatsApp DM access", "WhatsApp DM access",
); );
@@ -712,42 +708,57 @@ export async function setupProviders(
await noteProviderPrimer(prompter); 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({ const selection = (await prompter.multiselect({
message: "Select providers", message: "Select providers",
options: [ options: selectionOptions,
{
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",
},
],
})) as ProviderChoice[]; })) as ProviderChoice[];
options?.onSelection?.(selection); options?.onSelection?.(selection);
@@ -764,17 +775,15 @@ export async function setupProviders(
} }
}; };
const selectionNotes: Record<ProviderChoice, string> = { const selectionNotes = new Map(
telegram: `Telegram — simplest way to get started: register a bot with @BotFather and get going. Docs: ${docsLink("/telegram", "telegram")}`, listChatProviders().map((meta) => [
whatsapp: `WhatsApp — works with your own number; recommend a separate phone + eSIM. Docs: ${docsLink("/whatsapp", "whatsapp")}`, meta.id,
discord: `Discord — very well supported right now. Docs: ${docsLink("/discord", "discord")}`, formatProviderSelectionLine(meta, formatDocsLink),
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 selectedLines = selection const selectedLines = selection
.map((provider) => selectionNotes[provider]) .map((provider) => selectionNotes.get(provider))
.filter(Boolean); .filter((line): line is string => Boolean(line));
if (selectedLines.length > 0) { if (selectedLines.length > 0) {
await prompter.note(selectedLines.join("\n"), "Selected providers"); await prompter.note(selectedLines.join("\n"), "Selected providers");
} }
@@ -827,7 +836,7 @@ export async function setupProviders(
[ [
"Scan the QR with WhatsApp on your phone.", "Scan the QR with WhatsApp on your phone.",
`Credentials are stored under ${authDir}/ for future runs.`, `Credentials are stored under ${authDir}/ for future runs.`,
`Docs: ${docsLink("/whatsapp", "whatsapp")}`, `Docs: ${formatDocsLink("/whatsapp", "whatsapp")}`,
].join("\n"), ].join("\n"),
"WhatsApp linking", "WhatsApp linking",
); );
@@ -844,7 +853,7 @@ export async function setupProviders(
} catch (err) { } catch (err) {
runtime.error(`WhatsApp login failed: ${String(err)}`); runtime.error(`WhatsApp login failed: ${String(err)}`);
await prompter.note( await prompter.note(
`Docs: ${docsLink("/whatsapp", "whatsapp")}`, `Docs: ${formatDocsLink("/whatsapp", "whatsapp")}`,
"WhatsApp help", "WhatsApp help",
); );
} }
@@ -1328,7 +1337,7 @@ export async function setupProviders(
'Link device with: signal-cli link -n "Clawdbot"', 'Link device with: signal-cli link -n "Clawdbot"',
"Scan QR in Signal → Linked Devices", "Scan QR in Signal → Linked Devices",
"Then run: clawdbot gateway call providers.status --params '{\"probe\":true}'", "Then run: clawdbot gateway call providers.status --params '{\"probe\":true}'",
`Docs: ${docsLink("/signal", "signal")}`, `Docs: ${formatDocsLink("/signal", "signal")}`,
].join("\n"), ].join("\n"),
"Signal next steps", "Signal next steps",
); );
@@ -1409,7 +1418,7 @@ export async function setupProviders(
"Ensure Clawdbot has Full Disk Access to Messages DB.", "Ensure Clawdbot has Full Disk Access to Messages DB.",
"Grant Automation permission for Messages when prompted.", "Grant Automation permission for Messages when prompted.",
"List chats with: imsg chats --limit 20", "List chats with: imsg chats --limit 20",
`Docs: ${docsLink("/imessage", "imessage")}`, `Docs: ${formatDocsLink("/imessage", "imessage")}`,
].join("\n"), ].join("\n"),
"iMessage next steps", "iMessage next steps",
); );

View File

@@ -1,3 +1,4 @@
import type { ChatProviderId } from "../providers/registry.js";
import type { GatewayDaemonRuntime } from "./daemon-runtime.js"; import type { GatewayDaemonRuntime } from "./daemon-runtime.js";
export type OnboardMode = "local" | "remote"; 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 GatewayBind = "loopback" | "lan" | "tailnet" | "auto";
export type TailscaleMode = "off" | "serve" | "funnel"; export type TailscaleMode = "off" | "serve" | "funnel";
export type NodeManagerChoice = "npm" | "pnpm" | "bun"; export type NodeManagerChoice = "npm" | "pnpm" | "bun";
export type ProviderChoice = export type ProviderChoice = ChatProviderId;
| "whatsapp"
| "telegram"
| "discord"
| "slack"
| "signal"
| "imessage";
export type OnboardOptions = { export type OnboardOptions = {
mode?: OnboardMode; mode?: OnboardMode;

View File

@@ -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 = { const runtime: RuntimeEnv = {
log: vi.fn(), log: vi.fn(),
@@ -111,4 +115,83 @@ describe("providers command", () => {
expect(next.discord?.accounts?.work).toBeUndefined(); expect(next.discord?.accounts?.work).toBeUndefined();
expect(next.discord?.accounts?.default?.token).toBe("d0"); 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<string, { botToken?: string; name?: string }>;
};
};
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<string, { name?: string; token?: string }>;
};
};
expect(next.discord?.name).toBeUndefined();
expect(next.discord?.accounts?.default?.name).toBe("Primary Bot");
expect(next.discord?.accounts?.work?.token).toBe("d1");
});
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);
});
}); });

View File

@@ -4,8 +4,11 @@ import {
loadAuthProfileStore, loadAuthProfileStore,
} from "../agents/auth-profiles.js"; } from "../agents/auth-profiles.js";
import { withProgress } from "../cli/progress.js"; import { withProgress } from "../cli/progress.js";
import type { ClawdbotConfig } from "../config/config.js"; import {
import { readConfigFileSnapshot, writeConfigFile } from "../config/config.js"; type ClawdbotConfig,
readConfigFileSnapshot,
writeConfigFile,
} from "../config/config.js";
import { import {
listDiscordAccountIds, listDiscordAccountIds,
resolveDiscordAccount, resolveDiscordAccount,
@@ -19,12 +22,17 @@ import {
formatUsageReportLines, formatUsageReportLines,
loadProviderUsageSummary, loadProviderUsageSummary,
} from "../infra/provider-usage.js"; } from "../infra/provider-usage.js";
import {
type ChatProviderId,
getChatProviderMeta,
listChatProviders,
normalizeChatProviderId,
} from "../providers/registry.js";
import { import {
DEFAULT_ACCOUNT_ID, DEFAULT_ACCOUNT_ID,
normalizeAccountId, normalizeAccountId,
} from "../routing/session-key.js"; } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { import {
listSignalAccountIds, listSignalAccountIds,
resolveSignalAccount, resolveSignalAccount,
@@ -34,8 +42,8 @@ import {
listTelegramAccountIds, listTelegramAccountIds,
resolveTelegramAccount, resolveTelegramAccount,
} from "../telegram/accounts.js"; } from "../telegram/accounts.js";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js"; import { theme } from "../terminal/theme.js";
import { formatTerminalLink } from "../utils.js";
import { import {
listWhatsAppAccountIds, listWhatsAppAccountIds,
resolveWhatsAppAuthDir, resolveWhatsAppAuthDir,
@@ -45,23 +53,7 @@ import { createClackPrompter } from "../wizard/clack-prompter.js";
import { setupProviders } from "./onboard-providers.js"; import { setupProviders } from "./onboard-providers.js";
import type { ProviderChoice } from "./onboard-types.js"; import type { ProviderChoice } from "./onboard-types.js";
const DOCS_ROOT = "https://docs.clawd.bot"; type ChatProvider = ChatProviderId;
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 ProvidersListOptions = { type ProvidersListOptions = {
json?: boolean; json?: boolean;
@@ -100,15 +92,6 @@ export type ProvidersRemoveOptions = {
delete?: boolean; 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( async function requireValidConfig(
runtime: RuntimeEnv, runtime: RuntimeEnv,
): Promise<ClawdbotConfig | null> { ): Promise<ClawdbotConfig | null> {
@@ -134,6 +117,9 @@ function formatAccountLabel(params: { accountId: string; name?: string }) {
return base; return base;
} }
const providerLabel = (provider: ChatProvider) =>
getChatProviderMeta(provider).label;
const colorValue = (value: string) => { const colorValue = (value: string) => {
if (value === "none") return theme.error(value); if (value === "none") return theme.error(value);
if (value === "env") return theme.accent(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"); 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<string, unknown>)[provider] as
| { accounts?: Record<string, unknown> }
| undefined;
return Boolean(base?.accounts && Object.keys(base.accounts).length > 0);
}
function shouldStoreNameInAccounts(
cfg: ClawdbotConfig,
provider: ChatProvider,
accountId: string,
): boolean {
if (provider === "whatsapp") return true;
if (accountId !== DEFAULT_ACCOUNT_ID) return true;
return providerHasAccounts(cfg, provider);
}
function migrateBaseNameToDefaultAccount(
cfg: ClawdbotConfig,
provider: ChatProvider,
): ClawdbotConfig {
if (provider === "whatsapp") return cfg;
const base = (cfg as Record<string, unknown>)[provider] as
| { name?: string; accounts?: Record<string, Record<string, unknown>> }
| undefined;
const baseName = base?.name?.trim();
if (!baseName) return cfg;
const accounts: Record<string, Record<string, unknown>> = {
...base?.accounts,
};
const defaultAccount = accounts[DEFAULT_ACCOUNT_ID] ?? {};
if (!defaultAccount.name) {
accounts[DEFAULT_ACCOUNT_ID] = { ...defaultAccount, name: baseName };
}
const { name: _ignored, ...rest } = base ?? {};
return {
...cfg,
[provider]: {
...rest,
accounts,
},
} as ClawdbotConfig;
}
function applyAccountName(params: { function applyAccountName(params: {
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
provider: ChatProvider; provider: ChatProvider;
@@ -187,7 +222,8 @@ function applyAccountName(params: {
}; };
} }
const key = params.provider; 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<string, unknown>)[key]; const baseConfig = (params.cfg as Record<string, unknown>)[key];
const safeBase = const safeBase =
typeof baseConfig === "object" && baseConfig typeof baseConfig === "object" && baseConfig
@@ -202,17 +238,21 @@ function applyAccountName(params: {
} as ClawdbotConfig; } as ClawdbotConfig;
} }
const base = (params.cfg as Record<string, unknown>)[key] as const base = (params.cfg as Record<string, unknown>)[key] as
| { accounts?: Record<string, Record<string, unknown>> } | { name?: string; accounts?: Record<string, Record<string, unknown>> }
| undefined; | undefined;
const baseAccounts: Record< const baseAccounts: Record<
string, string,
Record<string, unknown> Record<string, unknown>
> = base?.accounts ?? {}; > = base?.accounts ?? {};
const existingAccount = baseAccounts[accountId] ?? {}; const existingAccount = baseAccounts[accountId] ?? {};
const baseWithoutName =
accountId === DEFAULT_ACCOUNT_ID
? (({ name: _ignored, ...rest }) => rest)(base ?? {})
: (base ?? {});
return { return {
...params.cfg, ...params.cfg,
[key]: { [key]: {
...base, ...baseWithoutName,
accounts: { accounts: {
...baseAccounts, ...baseAccounts,
[accountId]: { [accountId]: {
@@ -246,19 +286,22 @@ function applyProviderAccountConfig(params: {
}): ClawdbotConfig { }): ClawdbotConfig {
const accountId = normalizeAccountId(params.accountId); const accountId = normalizeAccountId(params.accountId);
const name = params.name?.trim() || undefined; const name = params.name?.trim() || undefined;
const next = applyAccountName({ const namedConfig = applyAccountName({
cfg: params.cfg, cfg: params.cfg,
provider: params.provider, provider: params.provider,
accountId, accountId,
name, name,
}); });
const next =
accountId !== DEFAULT_ACCOUNT_ID
? migrateBaseNameToDefaultAccount(namedConfig, params.provider)
: namedConfig;
if (params.provider === "whatsapp") { if (params.provider === "whatsapp") {
const entry = { const entry = {
...next.whatsapp?.accounts?.[accountId], ...next.whatsapp?.accounts?.[accountId],
...(params.authDir ? { authDir: params.authDir } : {}), ...(params.authDir ? { authDir: params.authDir } : {}),
enabled: true, enabled: true,
...(name ? { name } : {}),
}; };
return { return {
...next, ...next,
@@ -286,7 +329,6 @@ function applyProviderAccountConfig(params: {
: params.token : params.token
? { botToken: params.token } ? { botToken: params.token }
: {}), : {}),
...(name ? { name } : {}),
}, },
}; };
} }
@@ -305,7 +347,6 @@ function applyProviderAccountConfig(params: {
: params.token : params.token
? { botToken: params.token } ? { botToken: params.token }
: {}), : {}),
...(name ? { name } : {}),
}, },
}, },
}, },
@@ -320,7 +361,6 @@ function applyProviderAccountConfig(params: {
...next.discord, ...next.discord,
enabled: true, enabled: true,
...(params.useEnv ? {} : params.token ? { token: params.token } : {}), ...(params.useEnv ? {} : params.token ? { token: params.token } : {}),
...(name ? { name } : {}),
}, },
}; };
} }
@@ -335,7 +375,6 @@ function applyProviderAccountConfig(params: {
...next.discord?.accounts?.[accountId], ...next.discord?.accounts?.[accountId],
enabled: true, enabled: true,
...(params.token ? { token: params.token } : {}), ...(params.token ? { token: params.token } : {}),
...(name ? { name } : {}),
}, },
}, },
}, },
@@ -355,7 +394,6 @@ function applyProviderAccountConfig(params: {
...(params.botToken ? { botToken: params.botToken } : {}), ...(params.botToken ? { botToken: params.botToken } : {}),
...(params.appToken ? { appToken: params.appToken } : {}), ...(params.appToken ? { appToken: params.appToken } : {}),
}), }),
...(name ? { name } : {}),
}, },
}; };
} }
@@ -371,7 +409,6 @@ function applyProviderAccountConfig(params: {
enabled: true, enabled: true,
...(params.botToken ? { botToken: params.botToken } : {}), ...(params.botToken ? { botToken: params.botToken } : {}),
...(params.appToken ? { appToken: params.appToken } : {}), ...(params.appToken ? { appToken: params.appToken } : {}),
...(name ? { name } : {}),
}, },
}, },
}, },
@@ -390,7 +427,6 @@ function applyProviderAccountConfig(params: {
...(params.httpUrl ? { httpUrl: params.httpUrl } : {}), ...(params.httpUrl ? { httpUrl: params.httpUrl } : {}),
...(params.httpHost ? { httpHost: params.httpHost } : {}), ...(params.httpHost ? { httpHost: params.httpHost } : {}),
...(params.httpPort ? { httpPort: Number(params.httpPort) } : {}), ...(params.httpPort ? { httpPort: Number(params.httpPort) } : {}),
...(name ? { name } : {}),
}, },
}; };
} }
@@ -409,7 +445,6 @@ function applyProviderAccountConfig(params: {
...(params.httpUrl ? { httpUrl: params.httpUrl } : {}), ...(params.httpUrl ? { httpUrl: params.httpUrl } : {}),
...(params.httpHost ? { httpHost: params.httpHost } : {}), ...(params.httpHost ? { httpHost: params.httpHost } : {}),
...(params.httpPort ? { httpPort: Number(params.httpPort) } : {}), ...(params.httpPort ? { httpPort: Number(params.httpPort) } : {}),
...(name ? { name } : {}),
}, },
}, },
}, },
@@ -427,7 +462,6 @@ function applyProviderAccountConfig(params: {
...(params.dbPath ? { dbPath: params.dbPath } : {}), ...(params.dbPath ? { dbPath: params.dbPath } : {}),
...(params.service ? { service: params.service } : {}), ...(params.service ? { service: params.service } : {}),
...(params.region ? { region: params.region } : {}), ...(params.region ? { region: params.region } : {}),
...(name ? { name } : {}),
}, },
}; };
} }
@@ -445,7 +479,6 @@ function applyProviderAccountConfig(params: {
...(params.dbPath ? { dbPath: params.dbPath } : {}), ...(params.dbPath ? { dbPath: params.dbPath } : {}),
...(params.service ? { service: params.service } : {}), ...(params.service ? { service: params.service } : {}),
...(params.region ? { region: params.region } : {}), ...(params.region ? { region: params.region } : {}),
...(name ? { name } : {}),
}, },
}, },
}, },
@@ -502,12 +535,26 @@ export async function providersListCommand(
const lines: string[] = []; const lines: string[] = [];
lines.push(theme.heading("Chat providers:")); 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) { for (const accountId of whatsappAccounts) {
const { authDir } = resolveWhatsAppAuthDir({ cfg, accountId }); const { authDir } = resolveWhatsAppAuthDir({ cfg, accountId });
const linked = await webAuthExists(authDir); const linked = await webAuthExists(authDir);
const name = cfg.whatsapp?.accounts?.[accountId]?.name; const name = cfg.whatsapp?.accounts?.[accountId]?.name;
lines.push( lines.push(
`- ${theme.accent("WhatsApp")} ${theme.heading( `- ${theme.accent(providerLabel("whatsapp"))} ${theme.heading(
formatAccountLabel({ formatAccountLabel({
accountId, accountId,
name, 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) { for (const accountId of discordAccounts) {
const account = resolveDiscordAccount({ cfg, accountId }); const account = resolveDiscordAccount({ cfg, accountId });
lines.push( lines.push(
`- ${theme.accent("Discord")} ${theme.heading( `- ${theme.accent(providerLabel("discord"))} ${theme.heading(
formatAccountLabel({ formatAccountLabel({
accountId, accountId,
name: account.name, name: account.name,
@@ -552,7 +585,7 @@ export async function providersListCommand(
const account = resolveSlackAccount({ cfg, accountId }); const account = resolveSlackAccount({ cfg, accountId });
const configured = Boolean(account.botToken && account.appToken); const configured = Boolean(account.botToken && account.appToken);
lines.push( lines.push(
`- ${theme.accent("Slack")} ${theme.heading( `- ${theme.accent(providerLabel("slack"))} ${theme.heading(
formatAccountLabel({ formatAccountLabel({
accountId, accountId,
name: account.name, name: account.name,
@@ -569,7 +602,7 @@ export async function providersListCommand(
for (const accountId of signalAccounts) { for (const accountId of signalAccounts) {
const account = resolveSignalAccount({ cfg, accountId }); const account = resolveSignalAccount({ cfg, accountId });
lines.push( lines.push(
`- ${theme.accent("Signal")} ${theme.heading( `- ${theme.accent(providerLabel("signal"))} ${theme.heading(
formatAccountLabel({ formatAccountLabel({
accountId, accountId,
name: account.name, name: account.name,
@@ -583,7 +616,7 @@ export async function providersListCommand(
for (const accountId of imessageAccounts) { for (const accountId of imessageAccounts) {
const account = resolveIMessageAccount({ cfg, accountId }); const account = resolveIMessageAccount({ cfg, accountId });
lines.push( lines.push(
`- ${theme.accent("iMessage")} ${theme.heading( `- ${theme.accent(providerLabel("imessage"))} ${theme.heading(
formatAccountLabel({ formatAccountLabel({
accountId, accountId,
name: account.name, name: account.name,
@@ -621,9 +654,7 @@ export async function providersListCommand(
runtime.log(""); runtime.log("");
runtime.log( runtime.log(
`Docs: gateway/configuration -> ${formatTerminalLink(DOCS_ROOT, DOCS_ROOT, { `Docs: ${formatDocsLink("/gateway/configuration", "gateway/configuration")}`,
fallback: DOCS_ROOT,
})}`,
); );
} }
@@ -641,6 +672,80 @@ async function loadUsageWithProgress(
} }
} }
export function formatGatewayProvidersStatusLines(
payload: Record<string, unknown>,
): string[] {
const lines: string[] = [];
lines.push(theme.success("Gateway reachable."));
const accountLines = (
label: string,
accounts: Array<Record<string, unknown>>,
) =>
accounts.map((account) => {
const bits: string[] = [];
if (typeof account.enabled === "boolean") {
bits.push(account.enabled ? "enabled" : "disabled");
}
if (typeof account.configured === "boolean") {
bits.push(account.configured ? "configured" : "not configured");
}
if (typeof account.linked === "boolean") {
bits.push(account.linked ? "linked" : "not linked");
}
if (typeof account.running === "boolean") {
bits.push(account.running ? "running" : "stopped");
}
const probe = account.probe as { ok?: boolean } | undefined;
if (probe && typeof probe.ok === "boolean") {
bits.push(probe.ok ? "works" : "probe failed");
}
const accountId =
typeof account.accountId === "string" ? account.accountId : "default";
const name = typeof account.name === "string" ? account.name.trim() : "";
const labelText = `${label} ${formatAccountLabel({
accountId,
name: name || undefined,
})}`;
return `- ${labelText}: ${bits.join(", ")}`;
});
const accountPayloads: Partial<
Record<ChatProvider, Array<Record<string, unknown>>>
> = {
whatsapp: Array.isArray(payload.whatsappAccounts)
? (payload.whatsappAccounts as Array<Record<string, unknown>>)
: undefined,
telegram: Array.isArray(payload.telegramAccounts)
? (payload.telegramAccounts as Array<Record<string, unknown>>)
: undefined,
discord: Array.isArray(payload.discordAccounts)
? (payload.discordAccounts as Array<Record<string, unknown>>)
: undefined,
slack: Array.isArray(payload.slackAccounts)
? (payload.slackAccounts as Array<Record<string, unknown>>)
: undefined,
signal: Array.isArray(payload.signalAccounts)
? (payload.signalAccounts as Array<Record<string, unknown>>)
: undefined,
imessage: Array.isArray(payload.imessageAccounts)
? (payload.imessageAccounts as Array<Record<string, unknown>>)
: undefined,
};
for (const meta of listChatProviders()) {
const accounts = accountPayloads[meta.id];
if (accounts && accounts.length > 0) {
lines.push(...accountLines(meta.label, accounts));
}
}
lines.push("");
lines.push(
`Tip: ${formatDocsLink("/cli#status", "status --deep")} runs local probes without a gateway.`,
);
return lines;
}
export async function providersStatusCommand( export async function providersStatusCommand(
opts: ProvidersStatusOptions, opts: ProvidersStatusOptions,
runtime: RuntimeEnv = defaultRuntime, runtime: RuntimeEnv = defaultRuntime,
@@ -664,96 +769,11 @@ export async function providersStatusCommand(
runtime.log(JSON.stringify(payload, null, 2)); runtime.log(JSON.stringify(payload, null, 2));
return; return;
} }
const data = payload as Record<string, unknown>; runtime.log(
const lines: string[] = []; formatGatewayProvidersStatusLines(
lines.push(theme.success("Gateway reachable.")); payload as Record<string, unknown>,
const accountLines = ( ).join("\n"),
label: string,
accounts: Array<Record<string, unknown>>,
) =>
accounts.map((account) => {
const bits: string[] = [];
if (typeof account.enabled === "boolean") {
bits.push(account.enabled ? "enabled" : "disabled");
}
if (typeof account.configured === "boolean") {
bits.push(account.configured ? "configured" : "not configured");
}
if (typeof account.linked === "boolean") {
bits.push(account.linked ? "linked" : "not linked");
}
if (typeof account.running === "boolean") {
bits.push(account.running ? "running" : "stopped");
}
const probe = account.probe as { ok?: boolean } | undefined;
if (probe && typeof probe.ok === "boolean") {
bits.push(probe.ok ? "works" : "probe failed");
}
const accountId =
typeof account.accountId === "string" ? account.accountId : "default";
const name =
typeof account.name === "string" ? account.name.trim() : "";
const labelText = `${label} ${formatAccountLabel({
accountId,
name: name || undefined,
})}`;
return `- ${labelText}: ${bits.join(", ")}`;
});
if (Array.isArray(data.whatsappAccounts)) {
lines.push(
...accountLines(
"WhatsApp",
data.whatsappAccounts as Array<Record<string, unknown>>,
),
);
}
if (Array.isArray(data.telegramAccounts)) {
lines.push(
...accountLines(
"Telegram",
data.telegramAccounts as Array<Record<string, unknown>>,
),
);
}
if (Array.isArray(data.discordAccounts)) {
lines.push(
...accountLines(
"Discord",
data.discordAccounts as Array<Record<string, unknown>>,
),
);
}
if (Array.isArray(data.slackAccounts)) {
lines.push(
...accountLines(
"Slack",
data.slackAccounts as Array<Record<string, unknown>>,
),
);
}
if (Array.isArray(data.signalAccounts)) {
lines.push(
...accountLines(
"Signal",
data.signalAccounts as Array<Record<string, unknown>>,
),
);
}
if (Array.isArray(data.imessageAccounts)) {
lines.push(
...accountLines(
"iMessage",
data.imessageAccounts as Array<Record<string, unknown>>,
),
);
}
lines.push("");
lines.push(
`Tip: ${docsLink("/cli#status", "status --deep")} runs local probes without a gateway.`,
); );
runtime.log(lines.join("\n"));
} catch (err) { } catch (err) {
runtime.error(`Gateway not reachable: ${String(err)}`); runtime.error(`Gateway not reachable: ${String(err)}`);
runtime.exit(1); runtime.exit(1);
@@ -768,7 +788,7 @@ export async function providersAddCommand(
const cfg = await requireValidConfig(runtime); const cfg = await requireValidConfig(runtime);
if (!cfg) return; if (!cfg) return;
const useWizard = params?.hasFlags === false; const useWizard = shouldUseWizard(params);
if (useWizard) { if (useWizard) {
const prompter = createClackPrompter(); const prompter = createClackPrompter();
let selection: ProviderChoice[] = []; let selection: ProviderChoice[] = [];
@@ -836,7 +856,7 @@ export async function providersAddCommand(
return; return;
} }
const provider = normalizeChatProvider(opts.provider); const provider = normalizeChatProviderId(opts.provider);
if (!provider) { if (!provider) {
runtime.error(`Unknown provider: ${String(opts.provider ?? "")}`); runtime.error(`Unknown provider: ${String(opts.provider ?? "")}`);
runtime.exit(1); runtime.exit(1);
@@ -930,7 +950,7 @@ export async function providersAddCommand(
}); });
await writeConfigFile(nextConfig); await writeConfigFile(nextConfig);
runtime.log(`Added ${provider} account "${accountId}".`); runtime.log(`Added ${providerLabel(provider)} account "${accountId}".`);
} }
export async function providersRemoveCommand( export async function providersRemoveCommand(
@@ -941,9 +961,9 @@ export async function providersRemoveCommand(
const cfg = await requireValidConfig(runtime); const cfg = await requireValidConfig(runtime);
if (!cfg) return; if (!cfg) return;
const useWizard = params?.hasFlags === false; const useWizard = shouldUseWizard(params);
const prompter = useWizard ? createClackPrompter() : null; const prompter = useWizard ? createClackPrompter() : null;
let provider = normalizeChatProvider(opts.provider); let provider = normalizeChatProviderId(opts.provider);
let accountId = normalizeAccountId(opts.account); let accountId = normalizeAccountId(opts.account);
const deleteConfig = Boolean(opts.delete); const deleteConfig = Boolean(opts.delete);
@@ -951,9 +971,9 @@ export async function providersRemoveCommand(
await prompter.intro("Remove provider account"); await prompter.intro("Remove provider account");
provider = (await prompter.select({ provider = (await prompter.select({
message: "Provider", message: "Provider",
options: CHAT_PROVIDERS.map((value) => ({ options: listChatProviders().map((meta) => ({
value, value: meta.id,
label: value, label: meta.label,
})), })),
})) as ChatProvider; })) as ChatProvider;
@@ -983,7 +1003,7 @@ export async function providersRemoveCommand(
})(); })();
const wantsDisable = await prompter.confirm({ const wantsDisable = await prompter.confirm({
message: `Disable ${provider} account "${accountId}"? (keeps config)`, message: `Disable ${providerLabel(provider)} account "${accountId}"? (keeps config)`,
initialValue: true, initialValue: true,
}); });
if (!wantsDisable) { if (!wantsDisable) {
@@ -999,7 +1019,7 @@ export async function providersRemoveCommand(
if (!deleteConfig) { if (!deleteConfig) {
const confirm = createClackPrompter(); const confirm = createClackPrompter();
const ok = await confirm.confirm({ const ok = await confirm.confirm({
message: `Disable ${provider} account "${accountId}"? (keeps config)`, message: `Disable ${providerLabel(provider)} account "${accountId}"? (keeps config)`,
initialValue: true, initialValue: true,
}); });
if (!ok) { if (!ok) {
@@ -1147,14 +1167,14 @@ export async function providersRemoveCommand(
if (useWizard && prompter) { if (useWizard && prompter) {
await prompter.outro( await prompter.outro(
deleteConfig deleteConfig
? `Deleted ${provider} account "${accountKey}".` ? `Deleted ${providerLabel(provider)} account "${accountKey}".`
: `Disabled ${provider} account "${accountKey}".`, : `Disabled ${providerLabel(provider)} account "${accountKey}".`,
); );
} else { } else {
runtime.log( runtime.log(
deleteConfig deleteConfig
? `Deleted ${provider} account "${accountKey}".` ? `Deleted ${providerLabel(provider)} account "${accountKey}".`
: `Disabled ${provider} account "${accountKey}".`, : `Disabled ${providerLabel(provider)} account "${accountKey}".`,
); );
} }
} }

View File

@@ -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");
});
});

109
src/providers/registry.ts Normal file
View File

@@ -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<ChatProviderId, ChatProviderMeta> = {
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<string, ChatProviderId> = {
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,
)}`;
}

24
src/terminal/links.ts Normal file
View File

@@ -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,
});
}