feat: refine providers onboarding and cli
This commit is contained in:
@@ -654,7 +654,8 @@
|
||||
"platforms/ios",
|
||||
"platforms/android",
|
||||
"platforms/windows",
|
||||
"platforms/linux"
|
||||
"platforms/linux",
|
||||
"platforms/exe-dev"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
174
docs/platforms/exe-dev.md
Normal file
174
docs/platforms/exe-dev.md
Normal 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 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)
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 <name>",
|
||||
"Provider (whatsapp|telegram|discord|slack|signal|imessage)",
|
||||
)
|
||||
.option("--provider <name>", `Provider (${providerNames})`)
|
||||
.option("--account <id>", "Account id (default when omitted)")
|
||||
.option("--name <name>", "Display name for this account")
|
||||
.option("--token <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 <name>",
|
||||
"Provider (whatsapp|telegram|discord|slack|signal|imessage)",
|
||||
)
|
||||
.option("--provider <name>", `Provider (${providerNames})`)
|
||||
.option("--account <id>", "Account id (default when omitted)")
|
||||
.option("--delete", "Delete config entries (no prompt)", false)
|
||||
.action(async (opts, command) => {
|
||||
|
||||
@@ -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<void> {
|
||||
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 <provider> <code>",
|
||||
'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<void> {
|
||||
"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<void> {
|
||||
"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} <code>`,
|
||||
`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<ProviderChoice, string> = {
|
||||
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",
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ClawdbotConfig | null> {
|
||||
@@ -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<string, unknown>)[provider] as
|
||||
| { accounts?: Record<string, unknown> }
|
||||
| undefined;
|
||||
return Boolean(base?.accounts && Object.keys(base.accounts).length > 0);
|
||||
}
|
||||
|
||||
function shouldStoreNameInAccounts(
|
||||
cfg: ClawdbotConfig,
|
||||
provider: ChatProvider,
|
||||
accountId: string,
|
||||
): boolean {
|
||||
if (provider === "whatsapp") return true;
|
||||
if (accountId !== DEFAULT_ACCOUNT_ID) return true;
|
||||
return providerHasAccounts(cfg, provider);
|
||||
}
|
||||
|
||||
function migrateBaseNameToDefaultAccount(
|
||||
cfg: ClawdbotConfig,
|
||||
provider: ChatProvider,
|
||||
): ClawdbotConfig {
|
||||
if (provider === "whatsapp") return cfg;
|
||||
const base = (cfg as Record<string, unknown>)[provider] as
|
||||
| { name?: string; accounts?: Record<string, Record<string, unknown>> }
|
||||
| undefined;
|
||||
const baseName = base?.name?.trim();
|
||||
if (!baseName) return cfg;
|
||||
const accounts: Record<string, Record<string, unknown>> = {
|
||||
...base?.accounts,
|
||||
};
|
||||
const defaultAccount = accounts[DEFAULT_ACCOUNT_ID] ?? {};
|
||||
if (!defaultAccount.name) {
|
||||
accounts[DEFAULT_ACCOUNT_ID] = { ...defaultAccount, name: baseName };
|
||||
}
|
||||
const { name: _ignored, ...rest } = base ?? {};
|
||||
return {
|
||||
...cfg,
|
||||
[provider]: {
|
||||
...rest,
|
||||
accounts,
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
}
|
||||
|
||||
function applyAccountName(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
provider: ChatProvider;
|
||||
@@ -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<string, unknown>)[key];
|
||||
const safeBase =
|
||||
typeof baseConfig === "object" && baseConfig
|
||||
@@ -202,17 +238,21 @@ function applyAccountName(params: {
|
||||
} as ClawdbotConfig;
|
||||
}
|
||||
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;
|
||||
const baseAccounts: Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
> = base?.accounts ?? {};
|
||||
const existingAccount = baseAccounts[accountId] ?? {};
|
||||
const baseWithoutName =
|
||||
accountId === DEFAULT_ACCOUNT_ID
|
||||
? (({ name: _ignored, ...rest }) => rest)(base ?? {})
|
||||
: (base ?? {});
|
||||
return {
|
||||
...params.cfg,
|
||||
[key]: {
|
||||
...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, unknown>,
|
||||
): string[] {
|
||||
const lines: string[] = [];
|
||||
lines.push(theme.success("Gateway reachable."));
|
||||
const accountLines = (
|
||||
label: string,
|
||||
accounts: Array<Record<string, unknown>>,
|
||||
) =>
|
||||
accounts.map((account) => {
|
||||
const bits: string[] = [];
|
||||
if (typeof account.enabled === "boolean") {
|
||||
bits.push(account.enabled ? "enabled" : "disabled");
|
||||
}
|
||||
if (typeof account.configured === "boolean") {
|
||||
bits.push(account.configured ? "configured" : "not configured");
|
||||
}
|
||||
if (typeof account.linked === "boolean") {
|
||||
bits.push(account.linked ? "linked" : "not linked");
|
||||
}
|
||||
if (typeof account.running === "boolean") {
|
||||
bits.push(account.running ? "running" : "stopped");
|
||||
}
|
||||
const probe = account.probe as { ok?: boolean } | undefined;
|
||||
if (probe && typeof probe.ok === "boolean") {
|
||||
bits.push(probe.ok ? "works" : "probe failed");
|
||||
}
|
||||
const accountId =
|
||||
typeof account.accountId === "string" ? account.accountId : "default";
|
||||
const name = typeof account.name === "string" ? account.name.trim() : "";
|
||||
const labelText = `${label} ${formatAccountLabel({
|
||||
accountId,
|
||||
name: name || undefined,
|
||||
})}`;
|
||||
return `- ${labelText}: ${bits.join(", ")}`;
|
||||
});
|
||||
|
||||
const accountPayloads: Partial<
|
||||
Record<ChatProvider, Array<Record<string, unknown>>>
|
||||
> = {
|
||||
whatsapp: Array.isArray(payload.whatsappAccounts)
|
||||
? (payload.whatsappAccounts as Array<Record<string, unknown>>)
|
||||
: undefined,
|
||||
telegram: Array.isArray(payload.telegramAccounts)
|
||||
? (payload.telegramAccounts as Array<Record<string, unknown>>)
|
||||
: undefined,
|
||||
discord: Array.isArray(payload.discordAccounts)
|
||||
? (payload.discordAccounts as Array<Record<string, unknown>>)
|
||||
: undefined,
|
||||
slack: Array.isArray(payload.slackAccounts)
|
||||
? (payload.slackAccounts as Array<Record<string, unknown>>)
|
||||
: undefined,
|
||||
signal: Array.isArray(payload.signalAccounts)
|
||||
? (payload.signalAccounts as Array<Record<string, unknown>>)
|
||||
: undefined,
|
||||
imessage: Array.isArray(payload.imessageAccounts)
|
||||
? (payload.imessageAccounts as Array<Record<string, unknown>>)
|
||||
: undefined,
|
||||
};
|
||||
|
||||
for (const meta of listChatProviders()) {
|
||||
const accounts = accountPayloads[meta.id];
|
||||
if (accounts && accounts.length > 0) {
|
||||
lines.push(...accountLines(meta.label, accounts));
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push(
|
||||
`Tip: ${formatDocsLink("/cli#status", "status --deep")} runs local probes without a gateway.`,
|
||||
);
|
||||
return lines;
|
||||
}
|
||||
|
||||
export async function providersStatusCommand(
|
||||
opts: ProvidersStatusOptions,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
@@ -664,96 +769,11 @@ export async function providersStatusCommand(
|
||||
runtime.log(JSON.stringify(payload, null, 2));
|
||||
return;
|
||||
}
|
||||
const data = payload as Record<string, unknown>;
|
||||
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(", ")}`;
|
||||
});
|
||||
|
||||
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(
|
||||
formatGatewayProvidersStatusLines(
|
||||
payload as Record<string, unknown>,
|
||||
).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}".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
29
src/providers/registry.test.ts
Normal file
29
src/providers/registry.test.ts
Normal 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
109
src/providers/registry.ts
Normal 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
24
src/terminal/links.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user