1181 lines
33 KiB
TypeScript
1181 lines
33 KiB
TypeScript
import {
|
|
CLAUDE_CLI_PROFILE_ID,
|
|
CODEX_CLI_PROFILE_ID,
|
|
loadAuthProfileStore,
|
|
} from "../agents/auth-profiles.js";
|
|
import { withProgress } from "../cli/progress.js";
|
|
import {
|
|
type ClawdbotConfig,
|
|
readConfigFileSnapshot,
|
|
writeConfigFile,
|
|
} from "../config/config.js";
|
|
import {
|
|
listDiscordAccountIds,
|
|
resolveDiscordAccount,
|
|
} from "../discord/accounts.js";
|
|
import { callGateway } from "../gateway/call.js";
|
|
import {
|
|
listIMessageAccountIds,
|
|
resolveIMessageAccount,
|
|
} from "../imessage/accounts.js";
|
|
import {
|
|
formatUsageReportLines,
|
|
loadProviderUsageSummary,
|
|
} from "../infra/provider-usage.js";
|
|
import {
|
|
type ChatProviderId,
|
|
getChatProviderMeta,
|
|
listChatProviders,
|
|
normalizeChatProviderId,
|
|
} from "../providers/registry.js";
|
|
import {
|
|
DEFAULT_ACCOUNT_ID,
|
|
normalizeAccountId,
|
|
} from "../routing/session-key.js";
|
|
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
|
import {
|
|
listSignalAccountIds,
|
|
resolveSignalAccount,
|
|
} from "../signal/accounts.js";
|
|
import { listSlackAccountIds, resolveSlackAccount } from "../slack/accounts.js";
|
|
import {
|
|
listTelegramAccountIds,
|
|
resolveTelegramAccount,
|
|
} from "../telegram/accounts.js";
|
|
import { formatDocsLink } from "../terminal/links.js";
|
|
import { theme } from "../terminal/theme.js";
|
|
import {
|
|
listWhatsAppAccountIds,
|
|
resolveWhatsAppAuthDir,
|
|
} from "../web/accounts.js";
|
|
import { webAuthExists } from "../web/session.js";
|
|
import { createClackPrompter } from "../wizard/clack-prompter.js";
|
|
import { setupProviders } from "./onboard-providers.js";
|
|
import type { ProviderChoice } from "./onboard-types.js";
|
|
|
|
type ChatProvider = ChatProviderId;
|
|
|
|
type ProvidersListOptions = {
|
|
json?: boolean;
|
|
usage?: boolean;
|
|
};
|
|
|
|
type ProvidersStatusOptions = {
|
|
json?: boolean;
|
|
probe?: boolean;
|
|
timeout?: string;
|
|
};
|
|
|
|
export type ProvidersAddOptions = {
|
|
provider?: string;
|
|
account?: string;
|
|
name?: string;
|
|
token?: string;
|
|
tokenFile?: string;
|
|
botToken?: string;
|
|
appToken?: string;
|
|
signalNumber?: string;
|
|
cliPath?: string;
|
|
dbPath?: string;
|
|
service?: "imessage" | "sms" | "auto";
|
|
region?: string;
|
|
authDir?: string;
|
|
httpUrl?: string;
|
|
httpHost?: string;
|
|
httpPort?: string;
|
|
useEnv?: boolean;
|
|
};
|
|
|
|
export type ProvidersRemoveOptions = {
|
|
provider?: string;
|
|
account?: string;
|
|
delete?: boolean;
|
|
};
|
|
|
|
async function requireValidConfig(
|
|
runtime: RuntimeEnv,
|
|
): Promise<ClawdbotConfig | null> {
|
|
const snapshot = await readConfigFileSnapshot();
|
|
if (snapshot.exists && !snapshot.valid) {
|
|
const issues =
|
|
snapshot.issues.length > 0
|
|
? snapshot.issues
|
|
.map((issue) => `- ${issue.path}: ${issue.message}`)
|
|
.join("\n")
|
|
: "Unknown validation issue.";
|
|
runtime.error(`Config invalid:\n${issues}`);
|
|
runtime.error("Fix the config or run clawdbot doctor.");
|
|
runtime.exit(1);
|
|
return null;
|
|
}
|
|
return snapshot.config;
|
|
}
|
|
|
|
function formatAccountLabel(params: { accountId: string; name?: string }) {
|
|
const base = params.accountId || DEFAULT_ACCOUNT_ID;
|
|
if (params.name?.trim()) return `${base} (${params.name.trim()})`;
|
|
return base;
|
|
}
|
|
|
|
const providerLabel = (provider: ChatProvider) =>
|
|
getChatProviderMeta(provider).label;
|
|
|
|
const colorValue = (value: string) => {
|
|
if (value === "none") return theme.error(value);
|
|
if (value === "env") return theme.accent(value);
|
|
return theme.success(value);
|
|
};
|
|
|
|
function formatEnabled(value: boolean | undefined): string {
|
|
return value === false ? theme.error("disabled") : theme.success("enabled");
|
|
}
|
|
|
|
function formatConfigured(value: boolean): string {
|
|
return value ? theme.success("configured") : theme.warn("not configured");
|
|
}
|
|
|
|
function formatTokenSource(source?: string): string {
|
|
const value = source || "none";
|
|
return `token=${colorValue(value)}`;
|
|
}
|
|
|
|
function formatSource(label: string, source?: string): string {
|
|
const value = source || "none";
|
|
return `${label}=${colorValue(value)}`;
|
|
}
|
|
|
|
function formatLinked(value: boolean): string {
|
|
return value ? theme.success("linked") : theme.warn("not linked");
|
|
}
|
|
|
|
function shouldUseWizard(params?: { hasFlags?: boolean }) {
|
|
return params?.hasFlags === false;
|
|
}
|
|
|
|
function providerHasAccounts(cfg: ClawdbotConfig, provider: ChatProvider) {
|
|
if (provider === "whatsapp") return true;
|
|
const base = (cfg as Record<string, unknown>)[provider] as
|
|
| { accounts?: Record<string, unknown> }
|
|
| undefined;
|
|
return Boolean(base?.accounts && Object.keys(base.accounts).length > 0);
|
|
}
|
|
|
|
function shouldStoreNameInAccounts(
|
|
cfg: ClawdbotConfig,
|
|
provider: ChatProvider,
|
|
accountId: string,
|
|
): boolean {
|
|
if (provider === "whatsapp") return true;
|
|
if (accountId !== DEFAULT_ACCOUNT_ID) return true;
|
|
return providerHasAccounts(cfg, provider);
|
|
}
|
|
|
|
function migrateBaseNameToDefaultAccount(
|
|
cfg: ClawdbotConfig,
|
|
provider: ChatProvider,
|
|
): ClawdbotConfig {
|
|
if (provider === "whatsapp") return cfg;
|
|
const base = (cfg as Record<string, unknown>)[provider] as
|
|
| { name?: string; accounts?: Record<string, Record<string, unknown>> }
|
|
| undefined;
|
|
const baseName = base?.name?.trim();
|
|
if (!baseName) return cfg;
|
|
const accounts: Record<string, Record<string, unknown>> = {
|
|
...base?.accounts,
|
|
};
|
|
const defaultAccount = accounts[DEFAULT_ACCOUNT_ID] ?? {};
|
|
if (!defaultAccount.name) {
|
|
accounts[DEFAULT_ACCOUNT_ID] = { ...defaultAccount, name: baseName };
|
|
}
|
|
const { name: _ignored, ...rest } = base ?? {};
|
|
return {
|
|
...cfg,
|
|
[provider]: {
|
|
...rest,
|
|
accounts,
|
|
},
|
|
} as ClawdbotConfig;
|
|
}
|
|
|
|
function applyAccountName(params: {
|
|
cfg: ClawdbotConfig;
|
|
provider: ChatProvider;
|
|
accountId: string;
|
|
name?: string;
|
|
}): ClawdbotConfig {
|
|
const trimmed = params.name?.trim();
|
|
if (!trimmed) return params.cfg;
|
|
const accountId = normalizeAccountId(params.accountId);
|
|
if (params.provider === "whatsapp") {
|
|
return {
|
|
...params.cfg,
|
|
whatsapp: {
|
|
...params.cfg.whatsapp,
|
|
accounts: {
|
|
...params.cfg.whatsapp?.accounts,
|
|
[accountId]: {
|
|
...params.cfg.whatsapp?.accounts?.[accountId],
|
|
name: trimmed,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
const key = params.provider;
|
|
const useAccounts = shouldStoreNameInAccounts(params.cfg, key, accountId);
|
|
if (!useAccounts && accountId === DEFAULT_ACCOUNT_ID) {
|
|
const baseConfig = (params.cfg as Record<string, unknown>)[key];
|
|
const safeBase =
|
|
typeof baseConfig === "object" && baseConfig
|
|
? (baseConfig as Record<string, unknown>)
|
|
: {};
|
|
return {
|
|
...params.cfg,
|
|
[key]: {
|
|
...safeBase,
|
|
name: trimmed,
|
|
},
|
|
} as ClawdbotConfig;
|
|
}
|
|
const base = (params.cfg as Record<string, unknown>)[key] as
|
|
| { name?: string; accounts?: Record<string, Record<string, unknown>> }
|
|
| undefined;
|
|
const baseAccounts: Record<
|
|
string,
|
|
Record<string, unknown>
|
|
> = base?.accounts ?? {};
|
|
const existingAccount = baseAccounts[accountId] ?? {};
|
|
const baseWithoutName =
|
|
accountId === DEFAULT_ACCOUNT_ID
|
|
? (({ name: _ignored, ...rest }) => rest)(base ?? {})
|
|
: (base ?? {});
|
|
return {
|
|
...params.cfg,
|
|
[key]: {
|
|
...baseWithoutName,
|
|
accounts: {
|
|
...baseAccounts,
|
|
[accountId]: {
|
|
...existingAccount,
|
|
name: trimmed,
|
|
},
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
}
|
|
|
|
function applyProviderAccountConfig(params: {
|
|
cfg: ClawdbotConfig;
|
|
provider: ChatProvider;
|
|
accountId: string;
|
|
name?: string;
|
|
token?: string;
|
|
tokenFile?: string;
|
|
botToken?: string;
|
|
appToken?: string;
|
|
signalNumber?: string;
|
|
cliPath?: string;
|
|
dbPath?: string;
|
|
service?: "imessage" | "sms" | "auto";
|
|
region?: string;
|
|
authDir?: string;
|
|
httpUrl?: string;
|
|
httpHost?: string;
|
|
httpPort?: string;
|
|
useEnv?: boolean;
|
|
}): ClawdbotConfig {
|
|
const accountId = normalizeAccountId(params.accountId);
|
|
const name = params.name?.trim() || undefined;
|
|
const namedConfig = applyAccountName({
|
|
cfg: params.cfg,
|
|
provider: params.provider,
|
|
accountId,
|
|
name,
|
|
});
|
|
const next =
|
|
accountId !== DEFAULT_ACCOUNT_ID
|
|
? migrateBaseNameToDefaultAccount(namedConfig, params.provider)
|
|
: namedConfig;
|
|
|
|
if (params.provider === "whatsapp") {
|
|
const entry = {
|
|
...next.whatsapp?.accounts?.[accountId],
|
|
...(params.authDir ? { authDir: params.authDir } : {}),
|
|
enabled: true,
|
|
};
|
|
return {
|
|
...next,
|
|
whatsapp: {
|
|
...next.whatsapp,
|
|
accounts: {
|
|
...next.whatsapp?.accounts,
|
|
[accountId]: entry,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
if (params.provider === "telegram") {
|
|
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
return {
|
|
...next,
|
|
telegram: {
|
|
...next.telegram,
|
|
enabled: true,
|
|
...(params.useEnv
|
|
? {}
|
|
: params.tokenFile
|
|
? { tokenFile: params.tokenFile }
|
|
: params.token
|
|
? { botToken: params.token }
|
|
: {}),
|
|
},
|
|
};
|
|
}
|
|
return {
|
|
...next,
|
|
telegram: {
|
|
...next.telegram,
|
|
enabled: true,
|
|
accounts: {
|
|
...next.telegram?.accounts,
|
|
[accountId]: {
|
|
...next.telegram?.accounts?.[accountId],
|
|
enabled: true,
|
|
...(params.tokenFile
|
|
? { tokenFile: params.tokenFile }
|
|
: params.token
|
|
? { botToken: params.token }
|
|
: {}),
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
if (params.provider === "discord") {
|
|
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
return {
|
|
...next,
|
|
discord: {
|
|
...next.discord,
|
|
enabled: true,
|
|
...(params.useEnv ? {} : params.token ? { token: params.token } : {}),
|
|
},
|
|
};
|
|
}
|
|
return {
|
|
...next,
|
|
discord: {
|
|
...next.discord,
|
|
enabled: true,
|
|
accounts: {
|
|
...next.discord?.accounts,
|
|
[accountId]: {
|
|
...next.discord?.accounts?.[accountId],
|
|
enabled: true,
|
|
...(params.token ? { token: params.token } : {}),
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
if (params.provider === "slack") {
|
|
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
return {
|
|
...next,
|
|
slack: {
|
|
...next.slack,
|
|
enabled: true,
|
|
...(params.useEnv
|
|
? {}
|
|
: {
|
|
...(params.botToken ? { botToken: params.botToken } : {}),
|
|
...(params.appToken ? { appToken: params.appToken } : {}),
|
|
}),
|
|
},
|
|
};
|
|
}
|
|
return {
|
|
...next,
|
|
slack: {
|
|
...next.slack,
|
|
enabled: true,
|
|
accounts: {
|
|
...next.slack?.accounts,
|
|
[accountId]: {
|
|
...next.slack?.accounts?.[accountId],
|
|
enabled: true,
|
|
...(params.botToken ? { botToken: params.botToken } : {}),
|
|
...(params.appToken ? { appToken: params.appToken } : {}),
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
if (params.provider === "signal") {
|
|
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
return {
|
|
...next,
|
|
signal: {
|
|
...next.signal,
|
|
enabled: true,
|
|
...(params.signalNumber ? { account: params.signalNumber } : {}),
|
|
...(params.cliPath ? { cliPath: params.cliPath } : {}),
|
|
...(params.httpUrl ? { httpUrl: params.httpUrl } : {}),
|
|
...(params.httpHost ? { httpHost: params.httpHost } : {}),
|
|
...(params.httpPort ? { httpPort: Number(params.httpPort) } : {}),
|
|
},
|
|
};
|
|
}
|
|
return {
|
|
...next,
|
|
signal: {
|
|
...next.signal,
|
|
enabled: true,
|
|
accounts: {
|
|
...next.signal?.accounts,
|
|
[accountId]: {
|
|
...next.signal?.accounts?.[accountId],
|
|
enabled: true,
|
|
...(params.signalNumber ? { account: params.signalNumber } : {}),
|
|
...(params.cliPath ? { cliPath: params.cliPath } : {}),
|
|
...(params.httpUrl ? { httpUrl: params.httpUrl } : {}),
|
|
...(params.httpHost ? { httpHost: params.httpHost } : {}),
|
|
...(params.httpPort ? { httpPort: Number(params.httpPort) } : {}),
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
if (params.provider === "imessage") {
|
|
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
return {
|
|
...next,
|
|
imessage: {
|
|
...next.imessage,
|
|
enabled: true,
|
|
...(params.cliPath ? { cliPath: params.cliPath } : {}),
|
|
...(params.dbPath ? { dbPath: params.dbPath } : {}),
|
|
...(params.service ? { service: params.service } : {}),
|
|
...(params.region ? { region: params.region } : {}),
|
|
},
|
|
};
|
|
}
|
|
return {
|
|
...next,
|
|
imessage: {
|
|
...next.imessage,
|
|
enabled: true,
|
|
accounts: {
|
|
...next.imessage?.accounts,
|
|
[accountId]: {
|
|
...next.imessage?.accounts?.[accountId],
|
|
enabled: true,
|
|
...(params.cliPath ? { cliPath: params.cliPath } : {}),
|
|
...(params.dbPath ? { dbPath: params.dbPath } : {}),
|
|
...(params.service ? { service: params.service } : {}),
|
|
...(params.region ? { region: params.region } : {}),
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
return next;
|
|
}
|
|
|
|
export async function providersListCommand(
|
|
opts: ProvidersListOptions,
|
|
runtime: RuntimeEnv = defaultRuntime,
|
|
) {
|
|
const cfg = await requireValidConfig(runtime);
|
|
if (!cfg) return;
|
|
const includeUsage = opts.usage !== false;
|
|
|
|
const whatsappAccounts = listWhatsAppAccountIds(cfg);
|
|
const telegramAccounts = listTelegramAccountIds(cfg);
|
|
const discordAccounts = listDiscordAccountIds(cfg);
|
|
const slackAccounts = listSlackAccountIds(cfg);
|
|
const signalAccounts = listSignalAccountIds(cfg);
|
|
const imessageAccounts = listIMessageAccountIds(cfg);
|
|
|
|
const authStore = loadAuthProfileStore();
|
|
const authProfiles = Object.entries(authStore.profiles).map(
|
|
([profileId, profile]) => ({
|
|
id: profileId,
|
|
provider: profile.provider,
|
|
type: profile.type,
|
|
isExternal:
|
|
profileId === CLAUDE_CLI_PROFILE_ID ||
|
|
profileId === CODEX_CLI_PROFILE_ID,
|
|
}),
|
|
);
|
|
if (opts.json) {
|
|
const usage = includeUsage ? await loadProviderUsageSummary() : undefined;
|
|
const payload = {
|
|
chat: {
|
|
whatsapp: whatsappAccounts,
|
|
telegram: telegramAccounts,
|
|
discord: discordAccounts,
|
|
slack: slackAccounts,
|
|
signal: signalAccounts,
|
|
imessage: imessageAccounts,
|
|
},
|
|
auth: authProfiles,
|
|
...(usage ? { usage } : {}),
|
|
};
|
|
runtime.log(JSON.stringify(payload, null, 2));
|
|
return;
|
|
}
|
|
|
|
const lines: string[] = [];
|
|
lines.push(theme.heading("Chat providers:"));
|
|
|
|
for (const accountId of telegramAccounts) {
|
|
const account = resolveTelegramAccount({ cfg, accountId });
|
|
lines.push(
|
|
`- ${theme.accent(providerLabel("telegram"))} ${theme.heading(
|
|
formatAccountLabel({
|
|
accountId,
|
|
name: account.name,
|
|
}),
|
|
)}: ${formatConfigured(Boolean(account.token))}, ${formatTokenSource(
|
|
account.tokenSource,
|
|
)}, ${formatEnabled(account.enabled)}`,
|
|
);
|
|
}
|
|
|
|
for (const accountId of whatsappAccounts) {
|
|
const { authDir } = resolveWhatsAppAuthDir({ cfg, accountId });
|
|
const linked = await webAuthExists(authDir);
|
|
const name = cfg.whatsapp?.accounts?.[accountId]?.name;
|
|
lines.push(
|
|
`- ${theme.accent(providerLabel("whatsapp"))} ${theme.heading(
|
|
formatAccountLabel({
|
|
accountId,
|
|
name,
|
|
}),
|
|
)}: ${formatLinked(linked)}, ${formatEnabled(
|
|
cfg.whatsapp?.accounts?.[accountId]?.enabled ??
|
|
cfg.web?.enabled ??
|
|
true,
|
|
)}`,
|
|
);
|
|
}
|
|
|
|
for (const accountId of discordAccounts) {
|
|
const account = resolveDiscordAccount({ cfg, accountId });
|
|
lines.push(
|
|
`- ${theme.accent(providerLabel("discord"))} ${theme.heading(
|
|
formatAccountLabel({
|
|
accountId,
|
|
name: account.name,
|
|
}),
|
|
)}: ${formatConfigured(Boolean(account.token))}, ${formatTokenSource(
|
|
account.tokenSource,
|
|
)}, ${formatEnabled(account.enabled)}`,
|
|
);
|
|
}
|
|
|
|
for (const accountId of slackAccounts) {
|
|
const account = resolveSlackAccount({ cfg, accountId });
|
|
const configured = Boolean(account.botToken && account.appToken);
|
|
lines.push(
|
|
`- ${theme.accent(providerLabel("slack"))} ${theme.heading(
|
|
formatAccountLabel({
|
|
accountId,
|
|
name: account.name,
|
|
}),
|
|
)}: ${formatConfigured(configured)}, ${formatSource(
|
|
"bot",
|
|
account.botTokenSource,
|
|
)}, ${formatSource("app", account.appTokenSource)}, ${formatEnabled(
|
|
account.enabled,
|
|
)}`,
|
|
);
|
|
}
|
|
|
|
for (const accountId of signalAccounts) {
|
|
const account = resolveSignalAccount({ cfg, accountId });
|
|
lines.push(
|
|
`- ${theme.accent(providerLabel("signal"))} ${theme.heading(
|
|
formatAccountLabel({
|
|
accountId,
|
|
name: account.name,
|
|
}),
|
|
)}: ${formatConfigured(account.configured)}, base=${theme.muted(
|
|
account.baseUrl,
|
|
)}, ${formatEnabled(account.enabled)}`,
|
|
);
|
|
}
|
|
|
|
for (const accountId of imessageAccounts) {
|
|
const account = resolveIMessageAccount({ cfg, accountId });
|
|
lines.push(
|
|
`- ${theme.accent(providerLabel("imessage"))} ${theme.heading(
|
|
formatAccountLabel({
|
|
accountId,
|
|
name: account.name,
|
|
}),
|
|
)}: ${formatEnabled(account.enabled)}`,
|
|
);
|
|
}
|
|
|
|
lines.push("");
|
|
lines.push(theme.heading("Auth providers (OAuth + API keys):"));
|
|
if (authProfiles.length === 0) {
|
|
lines.push(theme.muted("- none"));
|
|
} else {
|
|
for (const profile of authProfiles) {
|
|
const external = profile.isExternal ? theme.muted(" (synced)") : "";
|
|
lines.push(
|
|
`- ${theme.accent(profile.id)} (${theme.success(profile.type)}${external})`,
|
|
);
|
|
}
|
|
}
|
|
|
|
runtime.log(lines.join("\n"));
|
|
|
|
if (includeUsage) {
|
|
runtime.log("");
|
|
const usage = await loadUsageWithProgress(runtime);
|
|
if (usage) {
|
|
const usageLines = formatUsageReportLines(usage);
|
|
if (usageLines.length > 0) {
|
|
usageLines[0] = theme.accent(usageLines[0]);
|
|
runtime.log(usageLines.join("\n"));
|
|
}
|
|
}
|
|
}
|
|
|
|
runtime.log("");
|
|
runtime.log(
|
|
`Docs: ${formatDocsLink("/gateway/configuration", "gateway/configuration")}`,
|
|
);
|
|
}
|
|
|
|
async function loadUsageWithProgress(
|
|
runtime: RuntimeEnv,
|
|
): Promise<Awaited<ReturnType<typeof loadProviderUsageSummary>> | null> {
|
|
try {
|
|
return await withProgress(
|
|
{ label: "Fetching usage snapshot…", indeterminate: true, enabled: true },
|
|
async () => await loadProviderUsageSummary(),
|
|
);
|
|
} catch (err) {
|
|
runtime.error(String(err));
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function formatGatewayProvidersStatusLines(
|
|
payload: Record<string, unknown>,
|
|
): string[] {
|
|
const lines: string[] = [];
|
|
lines.push(theme.success("Gateway reachable."));
|
|
const accountLines = (
|
|
label: string,
|
|
accounts: Array<Record<string, unknown>>,
|
|
) =>
|
|
accounts.map((account) => {
|
|
const bits: string[] = [];
|
|
if (typeof account.enabled === "boolean") {
|
|
bits.push(account.enabled ? "enabled" : "disabled");
|
|
}
|
|
if (typeof account.configured === "boolean") {
|
|
bits.push(account.configured ? "configured" : "not configured");
|
|
}
|
|
if (typeof account.linked === "boolean") {
|
|
bits.push(account.linked ? "linked" : "not linked");
|
|
}
|
|
if (typeof account.running === "boolean") {
|
|
bits.push(account.running ? "running" : "stopped");
|
|
}
|
|
const probe = account.probe as { ok?: boolean } | undefined;
|
|
if (probe && typeof probe.ok === "boolean") {
|
|
bits.push(probe.ok ? "works" : "probe failed");
|
|
}
|
|
const accountId =
|
|
typeof account.accountId === "string" ? account.accountId : "default";
|
|
const name = typeof account.name === "string" ? account.name.trim() : "";
|
|
const labelText = `${label} ${formatAccountLabel({
|
|
accountId,
|
|
name: name || undefined,
|
|
})}`;
|
|
return `- ${labelText}: ${bits.join(", ")}`;
|
|
});
|
|
|
|
const accountPayloads: Partial<
|
|
Record<ChatProvider, Array<Record<string, unknown>>>
|
|
> = {
|
|
whatsapp: Array.isArray(payload.whatsappAccounts)
|
|
? (payload.whatsappAccounts as Array<Record<string, unknown>>)
|
|
: undefined,
|
|
telegram: Array.isArray(payload.telegramAccounts)
|
|
? (payload.telegramAccounts as Array<Record<string, unknown>>)
|
|
: undefined,
|
|
discord: Array.isArray(payload.discordAccounts)
|
|
? (payload.discordAccounts as Array<Record<string, unknown>>)
|
|
: undefined,
|
|
slack: Array.isArray(payload.slackAccounts)
|
|
? (payload.slackAccounts as Array<Record<string, unknown>>)
|
|
: undefined,
|
|
signal: Array.isArray(payload.signalAccounts)
|
|
? (payload.signalAccounts as Array<Record<string, unknown>>)
|
|
: undefined,
|
|
imessage: Array.isArray(payload.imessageAccounts)
|
|
? (payload.imessageAccounts as Array<Record<string, unknown>>)
|
|
: undefined,
|
|
};
|
|
|
|
for (const meta of listChatProviders()) {
|
|
const accounts = accountPayloads[meta.id];
|
|
if (accounts && accounts.length > 0) {
|
|
lines.push(...accountLines(meta.label, accounts));
|
|
}
|
|
}
|
|
|
|
lines.push("");
|
|
lines.push(
|
|
`Tip: ${formatDocsLink("/cli#status", "status --deep")} runs local probes without a gateway.`,
|
|
);
|
|
return lines;
|
|
}
|
|
|
|
export async function providersStatusCommand(
|
|
opts: ProvidersStatusOptions,
|
|
runtime: RuntimeEnv = defaultRuntime,
|
|
) {
|
|
const timeoutMs = Number(opts.timeout ?? 10_000);
|
|
try {
|
|
const payload = await withProgress(
|
|
{
|
|
label: "Checking provider status…",
|
|
indeterminate: true,
|
|
enabled: opts.json !== true,
|
|
},
|
|
async () =>
|
|
await callGateway({
|
|
method: "providers.status",
|
|
params: { probe: Boolean(opts.probe), timeoutMs },
|
|
timeoutMs,
|
|
}),
|
|
);
|
|
if (opts.json) {
|
|
runtime.log(JSON.stringify(payload, null, 2));
|
|
return;
|
|
}
|
|
runtime.log(
|
|
formatGatewayProvidersStatusLines(
|
|
payload as Record<string, unknown>,
|
|
).join("\n"),
|
|
);
|
|
} catch (err) {
|
|
runtime.error(`Gateway not reachable: ${String(err)}`);
|
|
runtime.exit(1);
|
|
}
|
|
}
|
|
|
|
export async function providersAddCommand(
|
|
opts: ProvidersAddOptions,
|
|
runtime: RuntimeEnv = defaultRuntime,
|
|
params?: { hasFlags?: boolean },
|
|
) {
|
|
const cfg = await requireValidConfig(runtime);
|
|
if (!cfg) return;
|
|
|
|
const useWizard = shouldUseWizard(params);
|
|
if (useWizard) {
|
|
const prompter = createClackPrompter();
|
|
let selection: ProviderChoice[] = [];
|
|
const accountIds: Partial<Record<ProviderChoice, string>> = {};
|
|
await prompter.intro("Provider setup");
|
|
let nextConfig = await setupProviders(cfg, runtime, prompter, {
|
|
allowDisable: false,
|
|
allowSignalInstall: true,
|
|
promptAccountIds: true,
|
|
onSelection: (value) => {
|
|
selection = value;
|
|
},
|
|
onAccountId: (provider, accountId) => {
|
|
accountIds[provider] = accountId;
|
|
},
|
|
});
|
|
if (selection.length === 0) {
|
|
await prompter.outro("No providers selected.");
|
|
return;
|
|
}
|
|
|
|
const wantsNames = await prompter.confirm({
|
|
message: "Add display names for these accounts? (optional)",
|
|
initialValue: false,
|
|
});
|
|
if (wantsNames) {
|
|
for (const provider of selection) {
|
|
const accountId = accountIds[provider] ?? DEFAULT_ACCOUNT_ID;
|
|
const existingName =
|
|
provider === "whatsapp"
|
|
? nextConfig.whatsapp?.accounts?.[accountId]?.name
|
|
: provider === "telegram"
|
|
? (nextConfig.telegram?.accounts?.[accountId]?.name ??
|
|
nextConfig.telegram?.name)
|
|
: provider === "discord"
|
|
? (nextConfig.discord?.accounts?.[accountId]?.name ??
|
|
nextConfig.discord?.name)
|
|
: provider === "slack"
|
|
? (nextConfig.slack?.accounts?.[accountId]?.name ??
|
|
nextConfig.slack?.name)
|
|
: provider === "signal"
|
|
? (nextConfig.signal?.accounts?.[accountId]?.name ??
|
|
nextConfig.signal?.name)
|
|
: provider === "imessage"
|
|
? (nextConfig.imessage?.accounts?.[accountId]?.name ??
|
|
nextConfig.imessage?.name)
|
|
: undefined;
|
|
const name = await prompter.text({
|
|
message: `${provider} account name (${accountId})`,
|
|
initialValue: existingName,
|
|
});
|
|
if (name?.trim()) {
|
|
nextConfig = applyAccountName({
|
|
cfg: nextConfig,
|
|
provider,
|
|
accountId,
|
|
name,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
await writeConfigFile(nextConfig);
|
|
await prompter.outro("Providers updated.");
|
|
return;
|
|
}
|
|
|
|
const provider = normalizeChatProviderId(opts.provider);
|
|
if (!provider) {
|
|
runtime.error(`Unknown provider: ${String(opts.provider ?? "")}`);
|
|
runtime.exit(1);
|
|
return;
|
|
}
|
|
|
|
const accountId = normalizeAccountId(opts.account);
|
|
const useEnv = opts.useEnv === true;
|
|
|
|
if (provider === "telegram") {
|
|
if (useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
|
|
runtime.error(
|
|
"TELEGRAM_BOT_TOKEN can only be used for the default account.",
|
|
);
|
|
runtime.exit(1);
|
|
return;
|
|
}
|
|
if (!useEnv && !opts.token && !opts.tokenFile) {
|
|
runtime.error(
|
|
"Telegram requires --token or --token-file (or --use-env).",
|
|
);
|
|
runtime.exit(1);
|
|
return;
|
|
}
|
|
}
|
|
if (provider === "discord") {
|
|
if (useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
|
|
runtime.error(
|
|
"DISCORD_BOT_TOKEN can only be used for the default account.",
|
|
);
|
|
runtime.exit(1);
|
|
return;
|
|
}
|
|
if (!useEnv && !opts.token) {
|
|
runtime.error("Discord requires --token (or --use-env).");
|
|
runtime.exit(1);
|
|
return;
|
|
}
|
|
}
|
|
if (provider === "slack") {
|
|
if (useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
|
|
runtime.error(
|
|
"Slack env tokens can only be used for the default account.",
|
|
);
|
|
runtime.exit(1);
|
|
return;
|
|
}
|
|
if (!useEnv && (!opts.botToken || !opts.appToken)) {
|
|
runtime.error(
|
|
"Slack requires --bot-token and --app-token (or --use-env).",
|
|
);
|
|
runtime.exit(1);
|
|
return;
|
|
}
|
|
}
|
|
if (provider === "signal") {
|
|
if (
|
|
!opts.signalNumber &&
|
|
!opts.httpUrl &&
|
|
!opts.httpHost &&
|
|
!opts.httpPort &&
|
|
!opts.cliPath
|
|
) {
|
|
runtime.error(
|
|
"Signal requires --signal-number or --http-url/--http-host/--http-port/--cli-path.",
|
|
);
|
|
runtime.exit(1);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const nextConfig = applyProviderAccountConfig({
|
|
cfg,
|
|
provider,
|
|
accountId,
|
|
name: opts.name,
|
|
token: opts.token,
|
|
tokenFile: opts.tokenFile,
|
|
botToken: opts.botToken,
|
|
appToken: opts.appToken,
|
|
signalNumber: opts.signalNumber,
|
|
cliPath: opts.cliPath,
|
|
dbPath: opts.dbPath,
|
|
service: opts.service,
|
|
region: opts.region,
|
|
authDir: opts.authDir,
|
|
httpUrl: opts.httpUrl,
|
|
httpHost: opts.httpHost,
|
|
httpPort: opts.httpPort,
|
|
useEnv,
|
|
});
|
|
|
|
await writeConfigFile(nextConfig);
|
|
runtime.log(`Added ${providerLabel(provider)} account "${accountId}".`);
|
|
}
|
|
|
|
export async function providersRemoveCommand(
|
|
opts: ProvidersRemoveOptions,
|
|
runtime: RuntimeEnv = defaultRuntime,
|
|
params?: { hasFlags?: boolean },
|
|
) {
|
|
const cfg = await requireValidConfig(runtime);
|
|
if (!cfg) return;
|
|
|
|
const useWizard = shouldUseWizard(params);
|
|
const prompter = useWizard ? createClackPrompter() : null;
|
|
let provider = normalizeChatProviderId(opts.provider);
|
|
let accountId = normalizeAccountId(opts.account);
|
|
const deleteConfig = Boolean(opts.delete);
|
|
|
|
if (useWizard && prompter) {
|
|
await prompter.intro("Remove provider account");
|
|
provider = (await prompter.select({
|
|
message: "Provider",
|
|
options: listChatProviders().map((meta) => ({
|
|
value: meta.id,
|
|
label: meta.label,
|
|
})),
|
|
})) as ChatProvider;
|
|
|
|
const listAccounts =
|
|
provider === "whatsapp"
|
|
? listWhatsAppAccountIds
|
|
: provider === "telegram"
|
|
? listTelegramAccountIds
|
|
: provider === "discord"
|
|
? listDiscordAccountIds
|
|
: provider === "slack"
|
|
? listSlackAccountIds
|
|
: provider === "signal"
|
|
? listSignalAccountIds
|
|
: listIMessageAccountIds;
|
|
accountId = await (async () => {
|
|
const ids = listAccounts(cfg);
|
|
const choice = (await prompter.select({
|
|
message: "Account",
|
|
options: ids.map((id) => ({
|
|
value: id,
|
|
label: id === DEFAULT_ACCOUNT_ID ? "default (primary)" : id,
|
|
})),
|
|
initialValue: ids[0] ?? DEFAULT_ACCOUNT_ID,
|
|
})) as string;
|
|
return normalizeAccountId(choice);
|
|
})();
|
|
|
|
const wantsDisable = await prompter.confirm({
|
|
message: `Disable ${providerLabel(provider)} account "${accountId}"? (keeps config)`,
|
|
initialValue: true,
|
|
});
|
|
if (!wantsDisable) {
|
|
await prompter.outro("Cancelled.");
|
|
return;
|
|
}
|
|
} else {
|
|
if (!provider) {
|
|
runtime.error("Provider is required. Use --provider <name>.");
|
|
runtime.exit(1);
|
|
return;
|
|
}
|
|
if (!deleteConfig) {
|
|
const confirm = createClackPrompter();
|
|
const ok = await confirm.confirm({
|
|
message: `Disable ${providerLabel(provider)} account "${accountId}"? (keeps config)`,
|
|
initialValue: true,
|
|
});
|
|
if (!ok) {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
let next = { ...cfg };
|
|
const accountKey = accountId || DEFAULT_ACCOUNT_ID;
|
|
|
|
const setAccountEnabled = (key: ChatProvider, enabled: boolean) => {
|
|
if (key === "whatsapp") {
|
|
next = {
|
|
...next,
|
|
whatsapp: {
|
|
...next.whatsapp,
|
|
accounts: {
|
|
...next.whatsapp?.accounts,
|
|
[accountKey]: {
|
|
...next.whatsapp?.accounts?.[accountKey],
|
|
enabled,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
return;
|
|
}
|
|
const base = (next as Record<string, unknown>)[key] as
|
|
| {
|
|
accounts?: Record<string, Record<string, unknown>>;
|
|
enabled?: boolean;
|
|
}
|
|
| undefined;
|
|
const baseAccounts: Record<
|
|
string,
|
|
Record<string, unknown>
|
|
> = base?.accounts ?? {};
|
|
const existingAccount = baseAccounts[accountKey] ?? {};
|
|
if (accountKey === DEFAULT_ACCOUNT_ID && !base?.accounts) {
|
|
next = {
|
|
...next,
|
|
[key]: {
|
|
...base,
|
|
enabled,
|
|
},
|
|
} as ClawdbotConfig;
|
|
return;
|
|
}
|
|
next = {
|
|
...next,
|
|
[key]: {
|
|
...base,
|
|
accounts: {
|
|
...baseAccounts,
|
|
[accountKey]: {
|
|
...existingAccount,
|
|
enabled,
|
|
},
|
|
},
|
|
},
|
|
} as ClawdbotConfig;
|
|
};
|
|
|
|
const deleteAccount = (key: ChatProvider) => {
|
|
if (key === "whatsapp") {
|
|
const accounts = { ...next.whatsapp?.accounts };
|
|
delete accounts[accountKey];
|
|
next = {
|
|
...next,
|
|
whatsapp: {
|
|
...next.whatsapp,
|
|
accounts: Object.keys(accounts).length ? accounts : undefined,
|
|
},
|
|
};
|
|
return;
|
|
}
|
|
const base = (next as Record<string, unknown>)[key] as
|
|
| {
|
|
accounts?: Record<string, Record<string, unknown>>;
|
|
enabled?: boolean;
|
|
}
|
|
| undefined;
|
|
if (accountKey !== DEFAULT_ACCOUNT_ID) {
|
|
const accounts = { ...base?.accounts };
|
|
delete accounts[accountKey];
|
|
next = {
|
|
...next,
|
|
[key]: {
|
|
...base,
|
|
accounts: Object.keys(accounts).length ? accounts : undefined,
|
|
},
|
|
} as ClawdbotConfig;
|
|
return;
|
|
}
|
|
if (base?.accounts && Object.keys(base.accounts).length > 0) {
|
|
const accounts = { ...base.accounts };
|
|
delete accounts[accountKey];
|
|
next = {
|
|
...next,
|
|
[key]: {
|
|
...base,
|
|
accounts: Object.keys(accounts).length ? accounts : undefined,
|
|
...(key === "telegram"
|
|
? { botToken: undefined, tokenFile: undefined, name: undefined }
|
|
: key === "discord"
|
|
? { token: undefined, name: undefined }
|
|
: key === "slack"
|
|
? { botToken: undefined, appToken: undefined, name: undefined }
|
|
: key === "signal"
|
|
? {
|
|
account: undefined,
|
|
httpUrl: undefined,
|
|
httpHost: undefined,
|
|
httpPort: undefined,
|
|
cliPath: undefined,
|
|
name: undefined,
|
|
}
|
|
: key === "imessage"
|
|
? {
|
|
cliPath: undefined,
|
|
dbPath: undefined,
|
|
service: undefined,
|
|
region: undefined,
|
|
name: undefined,
|
|
}
|
|
: {}),
|
|
},
|
|
} as ClawdbotConfig;
|
|
return;
|
|
}
|
|
// No accounts map: remove entire provider section.
|
|
const clone = { ...next } as Record<string, unknown>;
|
|
delete clone[key];
|
|
next = clone as ClawdbotConfig;
|
|
};
|
|
|
|
if (deleteConfig) {
|
|
deleteAccount(provider);
|
|
} else {
|
|
setAccountEnabled(provider, false);
|
|
}
|
|
|
|
await writeConfigFile(next);
|
|
if (useWizard && prompter) {
|
|
await prompter.outro(
|
|
deleteConfig
|
|
? `Deleted ${providerLabel(provider)} account "${accountKey}".`
|
|
: `Disabled ${providerLabel(provider)} account "${accountKey}".`,
|
|
);
|
|
} else {
|
|
runtime.log(
|
|
deleteConfig
|
|
? `Deleted ${providerLabel(provider)} account "${accountKey}".`
|
|
: `Disabled ${providerLabel(provider)} account "${accountKey}".`,
|
|
);
|
|
}
|
|
}
|