From a483e588603b686896477cb552e1f03f716816ef Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 8 Jan 2026 11:54:40 +0100 Subject: [PATCH] feat: add quickstart onboarding defaults --- docs/start/wizard.md | 14 ++ src/cli/daemon-cli.ts | 27 +++- src/commands/auth-choice-options.ts | 53 +++++-- src/commands/doctor.ts | 12 +- src/commands/onboard-providers.ts | 133 +++++++++++++++++- src/commands/providers/status.ts | 64 ++++++--- src/commands/status.ts | 4 +- src/infra/provider-summary.ts | 45 ++++-- src/web/accounts.ts | 2 + src/wizard/onboarding.ts | 207 ++++++++++++++++++---------- 10 files changed, 431 insertions(+), 130 deletions(-) diff --git a/docs/start/wizard.md b/docs/start/wizard.md index ee12100a0..d3c355a1b 100644 --- a/docs/start/wizard.md +++ b/docs/start/wizard.md @@ -24,6 +24,20 @@ Follow‑up reconfiguration: clawdbot configure ``` +## QuickStart vs Advanced + +The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control). + +**QuickStart** keeps the defaults: +- Local gateway (loopback) +- Workspace default (or existing workspace) +- Gateway port **18789** +- Gateway auth **Off** (loopback only) +- Tailscale exposure **Off** +- Telegram + WhatsApp DMs default to **allowlist** (you’ll be prompted for a number) + +**Advanced** exposes every step (mode, workspace, gateway, providers, daemon, skills). + ## What the wizard does **Local mode (default)** walks you through: diff --git a/src/cli/daemon-cli.ts b/src/cli/daemon-cli.ts index 18da747ac..dc5976e8b 100644 --- a/src/cli/daemon-cli.ts +++ b/src/cli/daemon-cli.ts @@ -5,6 +5,7 @@ import { DEFAULT_GATEWAY_DAEMON_RUNTIME, isGatewayDaemonRuntime, } from "../commands/daemon-runtime.js"; +import { resolveControlUiLinks } from "../commands/onboard-helpers.js"; import { createConfigIO, loadConfig, @@ -30,7 +31,6 @@ import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; import { resolveGatewayService } from "../daemon/service.js"; import { callGateway } from "../gateway/call.js"; import { resolveGatewayBindHost } from "../gateway/net.js"; -import { resolveControlUiLinks } from "../commands/onboard-helpers.js"; import { formatPortDiagnostics, inspectPortUsage, @@ -48,10 +48,14 @@ type ConfigSummary = { exists: boolean; valid: boolean; issues?: Array<{ path: string; message: string }>; + controlUi?: { + enabled?: boolean; + basePath?: string; + }; }; type GatewayStatusSummary = { - bindMode: string; + bindMode: "auto" | "lan" | "tailnet" | "loopback"; bindHost: string | null; port: number; portSource: "service args" | "env/config"; @@ -372,6 +376,9 @@ async function gatherDaemonStatus(opts: { exists: cliSnapshot?.exists ?? false, valid: cliSnapshot?.valid ?? true, ...(cliSnapshot?.issues?.length ? { issues: cliSnapshot.issues } : {}), + ...(cliCfg.gateway?.controlUi + ? { controlUi: cliCfg.gateway.controlUi } + : {}), }; const daemonConfigSummary: ConfigSummary = { path: daemonSnapshot?.path ?? daemonConfigPath, @@ -380,6 +387,9 @@ async function gatherDaemonStatus(opts: { ...(daemonSnapshot?.issues?.length ? { issues: daemonSnapshot.issues } : {}), + ...(daemonCfg.gateway?.controlUi + ? { controlUi: daemonCfg.gateway.controlUi } + : {}), }; const configMismatch = cliConfigSummary.path !== daemonConfigSummary.path; @@ -390,7 +400,11 @@ async function gatherDaemonStatus(opts: { ? "service args" : "env/config"; - const bindMode = daemonCfg.gateway?.bind ?? "loopback"; + const bindMode = (daemonCfg.gateway?.bind ?? "loopback") as + | "auto" + | "lan" + | "tailnet" + | "loopback"; const bindHost = resolveGatewayBindHost(bindMode); const tailnetIPv4 = pickPrimaryTailnetIPv4(); const probeHost = pickProbeHostForBind(bindMode, tailnetIPv4); @@ -587,7 +601,12 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) { if (runtimeLine) { defaultRuntime.log(`Runtime: ${runtimeLine}`); } - if (rpc && !rpc.ok && service.loaded && service.runtime?.status === "running") { + if ( + rpc && + !rpc.ok && + service.loaded && + service.runtime?.status === "running" + ) { defaultRuntime.log( "Warm-up: launch agents can take a few seconds. Try again shortly.", ); diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts index 9c52a9dbe..0552545de 100644 --- a/src/commands/auth-choice-options.ts +++ b/src/commands/auth-choice-options.ts @@ -3,9 +3,38 @@ import { CLAUDE_CLI_PROFILE_ID, CODEX_CLI_PROFILE_ID, } from "../agents/auth-profiles.js"; +import { colorize, isRich, theme } from "../terminal/theme.js"; import type { AuthChoice } from "./onboard-types.js"; -export type AuthChoiceOption = { value: AuthChoice; label: string }; +export type AuthChoiceOption = { + value: AuthChoice; + label: string; + hint?: string; +}; + +function formatOAuthHint(expires?: number): string { + const rich = isRich(); + if (!expires) { + return colorize(rich, theme.muted, "token unavailable"); + } + const now = Date.now(); + const remaining = expires - now; + if (remaining <= 0) { + return colorize(rich, theme.error, "token expired"); + } + const minutes = Math.round(remaining / (60 * 1000)); + const duration = + minutes >= 120 + ? `${Math.round(minutes / 60)}h` + : minutes >= 60 + ? "1h" + : `${Math.max(minutes, 1)}m`; + const label = `token ok · expires in ${duration}`; + if (minutes <= 10) { + return colorize(rich, theme.warn, label); + } + return colorize(rich, theme.success, label); +} export function buildAuthChoiceOptions(params: { store: AuthProfileStore; @@ -13,24 +42,26 @@ export function buildAuthChoiceOptions(params: { }): AuthChoiceOption[] { const options: AuthChoiceOption[] = []; - const claudeCli = params.store.profiles[CLAUDE_CLI_PROFILE_ID]; - if (claudeCli?.type === "oauth") { - options.push({ - value: "claude-cli", - label: "Anthropic OAuth (Claude CLI)", - }); - } - - options.push({ value: "oauth", label: "Anthropic OAuth (Claude Pro/Max)" }); - const codexCli = params.store.profiles[CODEX_CLI_PROFILE_ID]; if (codexCli?.type === "oauth") { options.push({ value: "codex-cli", label: "OpenAI Codex OAuth (Codex CLI)", + hint: formatOAuthHint(codexCli.expires), }); } + const claudeCli = params.store.profiles[CLAUDE_CLI_PROFILE_ID]; + if (claudeCli?.type === "oauth") { + options.push({ + value: "claude-cli", + label: "Anthropic OAuth (Claude CLI)", + hint: formatOAuthHint(claudeCli.expires), + }); + } + + options.push({ value: "oauth", label: "Anthropic OAuth (Claude Pro/Max)" }); + options.push({ value: "openai-codex", label: "OpenAI Codex (ChatGPT OAuth)", diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index f9695fb11..7c04e0a3d 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -11,12 +11,18 @@ import { } from "../config/config.js"; import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js"; import { readLastGatewayErrorLine } from "../daemon/diagnostics.js"; +import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; import { resolveGatewayService } from "../daemon/service.js"; import { buildGatewayConnectionDetails } from "../gateway/call.js"; import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { resolveUserPath, sleep } from "../utils.js"; +import { + DEFAULT_GATEWAY_DAEMON_RUNTIME, + GATEWAY_DAEMON_RUNTIME_OPTIONS, + type GatewayDaemonRuntime, +} from "./daemon-runtime.js"; import { maybeRepairAnthropicOAuthProfileId } from "./doctor-auth.js"; import { buildGatewayRuntimeHints, @@ -56,13 +62,7 @@ import { DEFAULT_WORKSPACE, printWizardHeader, } from "./onboard-helpers.js"; -import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; import { ensureSystemdUserLingerInteractive } from "./systemd-linger.js"; -import { - DEFAULT_GATEWAY_DAEMON_RUNTIME, - GATEWAY_DAEMON_RUNTIME_OPTIONS, - type GatewayDaemonRuntime, -} from "./daemon-runtime.js"; function resolveMode(cfg: ClawdbotConfig): "local" | "remote" { return cfg.gateway?.mode === "remote" ? "remote" : "local"; diff --git a/src/commands/onboard-providers.ts b/src/commands/onboard-providers.ts index bb033a296..b1d63901c 100644 --- a/src/commands/onboard-providers.ts +++ b/src/commands/onboard-providers.ts @@ -470,10 +470,69 @@ async function maybeConfigureDmPolicies(params: { return cfg; } +async function promptTelegramAllowFrom(params: { + cfg: ClawdbotConfig; + prompter: WizardPrompter; + accountId: string; +}): Promise { + const { cfg, prompter, accountId } = params; + const resolved = resolveTelegramAccount({ cfg, accountId }); + const existingAllowFrom = resolved.config.allowFrom ?? []; + const entry = await prompter.text({ + message: "Telegram allowFrom (user id)", + placeholder: "123456789", + initialValue: existingAllowFrom[0] + ? String(existingAllowFrom[0]) + : undefined, + validate: (value) => { + const raw = String(value ?? "").trim(); + if (!raw) return "Required"; + if (!/^\d+$/.test(raw)) return "Use a numeric Telegram user id"; + return undefined; + }, + }); + const normalized = String(entry).trim(); + const merged = [ + ...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean), + normalized, + ]; + const unique = [...new Set(merged)]; + + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...cfg, + telegram: { + ...cfg.telegram, + enabled: true, + dmPolicy: "allowlist", + allowFrom: unique, + }, + }; + } + + return { + ...cfg, + telegram: { + ...cfg.telegram, + enabled: true, + accounts: { + ...cfg.telegram?.accounts, + [accountId]: { + ...cfg.telegram?.accounts?.[accountId], + enabled: cfg.telegram?.accounts?.[accountId]?.enabled ?? true, + dmPolicy: "allowlist", + allowFrom: unique, + }, + }, + }, + }; +} + async function promptWhatsAppAllowFrom( cfg: ClawdbotConfig, _runtime: RuntimeEnv, prompter: WizardPrompter, + options?: { forceAllowlist?: boolean }, ): Promise { const existingPolicy = cfg.whatsapp?.dmPolicy ?? "pairing"; const existingAllowFrom = cfg.whatsapp?.allowFrom ?? []; @@ -481,6 +540,47 @@ async function promptWhatsAppAllowFrom( existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset"; const existingResponsePrefix = cfg.messages?.responsePrefix; + if (options?.forceAllowlist) { + const entry = await prompter.text({ + message: "Your WhatsApp number (E.164)", + placeholder: "+15555550123", + initialValue: existingAllowFrom[0], + validate: (value) => { + const raw = String(value ?? "").trim(); + if (!raw) return "Required"; + const normalized = normalizeE164(raw); + if (!normalized) return `Invalid number: ${raw}`; + return undefined; + }, + }); + const normalized = normalizeE164(String(entry).trim()); + const merged = [ + ...existingAllowFrom + .filter((item) => item !== "*") + .map((item) => normalizeE164(item)) + .filter(Boolean), + normalized, + ]; + const unique = [...new Set(merged.filter(Boolean))]; + let next = setWhatsAppSelfChatMode(cfg, true); + next = setWhatsAppDmPolicy(next, "allowlist"); + next = setWhatsAppAllowFrom(next, unique); + if (existingResponsePrefix === undefined) { + next = setMessagesResponsePrefix(next, "[clawdbot]"); + } + await prompter.note( + [ + "Allowlist mode enabled.", + `- allowFrom includes ${normalized}`, + existingResponsePrefix === undefined + ? "- responsePrefix set to [clawdbot]" + : "- responsePrefix left unchanged", + ].join("\n"), + "WhatsApp allowlist", + ); + return next; + } + await prompter.note( [ "WhatsApp direct chats are gated by `whatsapp.dmPolicy` + `whatsapp.allowFrom`.", @@ -562,7 +662,7 @@ async function promptWhatsAppAllowFrom( } if (policy === "disabled") return next; - const options = + const allowOptions = existingAllowFrom.length > 0 ? ([ { value: "keep", label: "Keep current allowFrom" }, @@ -579,8 +679,11 @@ async function promptWhatsAppAllowFrom( const mode = (await prompter.select({ message: "WhatsApp allowFrom (optional pre-allowlist)", - options: options.map((opt) => ({ value: opt.value, label: opt.label })), - })) as (typeof options)[number]["value"]; + options: allowOptions.map((opt) => ({ + value: opt.value, + label: opt.label, + })), + })) as (typeof allowOptions)[number]["value"]; if (mode === "keep") { // Keep allowFrom as-is. @@ -631,6 +734,8 @@ type SetupProvidersOptions = { whatsappAccountId?: string; promptWhatsAppAccountId?: boolean; onWhatsAppAccountId?: (accountId: string) => void; + forceAllowFromProviders?: ProviderChoice[]; + skipDmPolicyPrompt?: boolean; }; export async function setupProviders( @@ -639,6 +744,12 @@ export async function setupProviders( prompter: WizardPrompter, options?: SetupProvidersOptions, ): Promise { + const forceAllowFromProviders = new Set( + options?.forceAllowFromProviders ?? [], + ); + const forceTelegramAllowFrom = forceAllowFromProviders.has("telegram"); + const forceWhatsAppAllowFrom = forceAllowFromProviders.has("whatsapp"); + let whatsappAccountId = options?.whatsappAccountId?.trim() || resolveDefaultWhatsAppAccountId(cfg); let whatsappLinked = await detectWhatsAppLinked(cfg, whatsappAccountId); @@ -854,7 +965,9 @@ export async function setupProviders( ); } - next = await promptWhatsAppAllowFrom(next, runtime, prompter); + next = await promptWhatsAppAllowFrom(next, runtime, prompter, { + forceAllowlist: forceWhatsAppAllowFrom, + }); } if (selection.includes("telegram")) { @@ -962,6 +1075,14 @@ export async function setupProviders( }; } } + + if (forceTelegramAllowFrom) { + next = await promptTelegramAllowFrom({ + cfg: next, + prompter, + accountId: telegramAccountId, + }); + } } if (selection.includes("discord")) { @@ -1414,7 +1535,9 @@ export async function setupProviders( ); } - next = await maybeConfigureDmPolicies({ cfg: next, selection, prompter }); + if (!options?.skipDmPolicyPrompt) { + next = await maybeConfigureDmPolicies({ cfg: next, selection, prompter }); + } if (options?.allowDisable) { if (!selection.includes("telegram") && telegramConfigured) { diff --git a/src/commands/providers/status.ts b/src/commands/providers/status.ts index 2912564d5..0d3a27f8e 100644 --- a/src/commands/providers/status.ts +++ b/src/commands/providers/status.ts @@ -1,31 +1,35 @@ import { withProgress } from "../../cli/progress.js"; -import { callGateway } from "../../gateway/call.js"; -import { listChatProviders } from "../../providers/registry.js"; -import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; -import { formatDocsLink } from "../../terminal/links.js"; -import { theme } from "../../terminal/theme.js"; import { - type ChatProvider, - formatProviderAccountLabel, - requireValidConfig, -} from "./shared.js"; + type ClawdbotConfig, + readConfigFileSnapshot, +} 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 { formatAge } from "../../infra/provider-summary.js"; +import { listChatProviders } from "../../providers/registry.js"; +import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; import { listSignalAccountIds, resolveSignalAccount, } from "../../signal/accounts.js"; -import { listSlackAccountIds, resolveSlackAccount } from "../../slack/accounts.js"; +import { + 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 { normalizeE164 } from "../../utils.js"; import { listWhatsAppAccountIds, resolveWhatsAppAccount, @@ -35,9 +39,11 @@ import { readWebSelfId, webAuthExists, } from "../../web/session.js"; -import { formatAge } from "../../infra/provider-summary.js"; -import { normalizeE164 } from "../../utils.js"; -import { readConfigFileSnapshot, type ClawdbotConfig } from "../../config/config.js"; +import { + type ChatProvider, + formatProviderAccountLabel, + requireValidConfig, +} from "./shared.js"; export type ProvidersStatusOptions = { json?: boolean; @@ -80,10 +86,16 @@ export function formatGatewayProvidersStatusLines( if (typeof account.tokenSource === "string" && account.tokenSource) { bits.push(`token:${account.tokenSource}`); } - if (typeof account.botTokenSource === "string" && account.botTokenSource) { + if ( + typeof account.botTokenSource === "string" && + account.botTokenSource + ) { bits.push(`bot:${account.botTokenSource}`); } - if (typeof account.appTokenSource === "string" && account.appTokenSource) { + if ( + typeof account.appTokenSource === "string" && + account.appTokenSource + ) { bits.push(`app:${account.appTokenSource}`); } if (typeof account.baseUrl === "string" && account.baseUrl) { @@ -176,10 +188,16 @@ async function formatConfigProvidersStatusLines( if (typeof account.tokenSource === "string" && account.tokenSource) { bits.push(`token:${account.tokenSource}`); } - if (typeof account.botTokenSource === "string" && account.botTokenSource) { + if ( + typeof account.botTokenSource === "string" && + account.botTokenSource + ) { bits.push(`bot:${account.botTokenSource}`); } - if (typeof account.appTokenSource === "string" && account.appTokenSource) { + if ( + typeof account.appTokenSource === "string" && + account.appTokenSource + ) { bits.push(`app:${account.appTokenSource}`); } if (typeof account.baseUrl === "string" && account.baseUrl) { @@ -242,7 +260,8 @@ async function formatConfigProvidersStatusLines( name: account.name, enabled: account.enabled, configured: - Boolean(account.botToken?.trim()) && Boolean(account.appToken?.trim()), + Boolean(account.botToken?.trim()) && + Boolean(account.appToken?.trim()), botTokenSource: account.botTokenSource, appTokenSource: account.appTokenSource, }; @@ -259,11 +278,18 @@ async function formatConfigProvidersStatusLines( }), imessage: listIMessageAccountIds(cfg).map((accountId) => { const account = resolveIMessageAccount({ cfg, accountId }); + const imsgConfigured = Boolean( + account.config.cliPath || + account.config.dbPath || + account.config.allowFrom || + account.config.service || + account.config.region, + ); return { accountId: account.accountId, name: account.name, enabled: account.enabled, - configured: account.configured, + configured: imsgConfigured, }; }), } satisfies Partial>>>; diff --git a/src/commands/status.ts b/src/commands/status.ts index 7b911d205..0b547fb65 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -12,6 +12,7 @@ import { resolveStorePath, type SessionEntry, } from "../config/sessions.js"; +import { resolveGatewayService } from "../daemon/service.js"; import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js"; import { info } from "../globals.js"; import { buildProviderSummary } from "../infra/provider-summary.js"; @@ -20,7 +21,6 @@ import { loadProviderUsageSummary, } from "../infra/provider-usage.js"; import { peekSystemEvents } from "../infra/system-events.js"; -import { resolveGatewayService } from "../daemon/service.js"; import type { RuntimeEnv } from "../runtime.js"; import { resolveWhatsAppAccount } from "../web/accounts.js"; import { resolveHeartbeatSeconds } from "../web/reconnect.js"; @@ -29,8 +29,8 @@ import { logWebSelfId, webAuthExists, } from "../web/session.js"; -import { resolveControlUiLinks } from "./onboard-helpers.js"; import type { HealthSummary } from "./health.js"; +import { resolveControlUiLinks } from "./onboard-helpers.js"; export type SessionStatus = { key: string; diff --git a/src/infra/provider-summary.ts b/src/infra/provider-summary.ts index d5255449f..cbb889643 100644 --- a/src/infra/provider-summary.ts +++ b/src/infra/provider-summary.ts @@ -1,18 +1,33 @@ import { type ClawdbotConfig, loadConfig } from "../config/config.js"; -import { resolveTelegramAccount, listTelegramAccountIds } from "../telegram/accounts.js"; -import { resolveDiscordAccount, listDiscordAccountIds } from "../discord/accounts.js"; -import { resolveSlackAccount, listSlackAccountIds } from "../slack/accounts.js"; -import { resolveSignalAccount, listSignalAccountIds } from "../signal/accounts.js"; -import { resolveIMessageAccount, listIMessageAccountIds } from "../imessage/accounts.js"; +import { + listDiscordAccountIds, + resolveDiscordAccount, +} from "../discord/accounts.js"; +import { + listIMessageAccountIds, + resolveIMessageAccount, +} from "../imessage/accounts.js"; +import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; +import { + listSignalAccountIds, + resolveSignalAccount, +} from "../signal/accounts.js"; +import { listSlackAccountIds, resolveSlackAccount } from "../slack/accounts.js"; +import { + listTelegramAccountIds, + resolveTelegramAccount, +} from "../telegram/accounts.js"; import { theme } from "../terminal/theme.js"; import { normalizeE164 } from "../utils.js"; +import { + listWhatsAppAccountIds, + resolveWhatsAppAccount, +} from "../web/accounts.js"; import { getWebAuthAgeMs, readWebSelfId, webAuthExists, } from "../web/session.js"; -import { listWhatsAppAccountIds, resolveWhatsAppAccount } from "../web/accounts.js"; -import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; export type ProviderSummaryOptions = { colorize?: boolean; @@ -66,7 +81,11 @@ export async function buildProviderSummary( const dmPolicy = account.dmPolicy ?? effective.whatsapp?.dmPolicy ?? "pairing"; details.push(`dm:${dmPolicy}`); - const allowFrom = (account.allowFrom ?? effective.whatsapp?.allowFrom ?? []) + const allowFrom = ( + account.allowFrom ?? + effective.whatsapp?.allowFrom ?? + [] + ) .map(normalizeE164) .filter(Boolean) .slice(0, 2); @@ -237,7 +256,15 @@ export async function buildProviderSummary( const accounts = listIMessageAccountIds(effective).map((accountId) => resolveIMessageAccount({ cfg: effective, accountId }), ); - const configuredAccounts = accounts.filter((account) => account.configured); + const configuredAccounts = accounts.filter((account) => + Boolean( + account.config.cliPath || + account.config.dbPath || + account.config.allowFrom || + account.config.service || + account.config.region, + ), + ); const imessageConfigured = configuredAccounts.length > 0; lines.push( imessageConfigured diff --git a/src/web/accounts.ts b/src/web/accounts.ts index 17d0c0618..e96dba12a 100644 --- a/src/web/accounts.ts +++ b/src/web/accounts.ts @@ -14,6 +14,7 @@ export type ResolvedWhatsAppAccount = { authDir: string; isLegacyAuthDir: boolean; selfChatMode?: boolean; + dmPolicy?: WhatsAppAccountConfig["dmPolicy"]; allowFrom?: string[]; groupAllowFrom?: string[]; groupPolicy?: GroupPolicy; @@ -107,6 +108,7 @@ export function resolveWhatsAppAccount(params: { authDir, isLegacyAuthDir: isLegacy, selfChatMode: accountCfg?.selfChatMode ?? params.cfg.whatsapp?.selfChatMode, + dmPolicy: accountCfg?.dmPolicy ?? params.cfg.whatsapp?.dmPolicy, allowFrom: accountCfg?.allowFrom ?? params.cfg.whatsapp?.allowFrom, groupAllowFrom: accountCfg?.groupAllowFrom ?? params.cfg.whatsapp?.groupAllowFrom, diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index 9f5eff39e..43b46b880 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -39,6 +39,7 @@ import { ensureSystemdUserLingerInteractive } from "../commands/systemd-linger.j import type { ClawdbotConfig } from "../config/config.js"; import { CONFIG_PATH_CLAWDBOT, + DEFAULT_GATEWAY_PORT, readConfigFileSnapshot, resolveGatewayPort, writeConfigFile, @@ -111,6 +112,38 @@ export async function runOnboardingWizard( } } + const flowHint = + "Configure anything via the Clawdbot configuration wizard anytime."; + let flow = (await prompter.select({ + message: "Onboarding mode", + options: [ + { value: "quickstart", label: "QuickStart", hint: flowHint }, + { value: "advanced", label: "Advanced", hint: flowHint }, + ], + initialValue: "quickstart", + })) as "quickstart" | "advanced"; + + if (opts.mode === "remote" && flow === "quickstart") { + await prompter.note( + "QuickStart only supports local gateways. Switching to Advanced mode.", + "QuickStart", + ); + flow = "advanced"; + } + + if (flow === "quickstart") { + await prompter.note( + [ + "Gateway port: 18789", + "Gateway bind: Loopback (127.0.0.1)", + "Gateway auth: Off (loopback only)", + "Tailscale exposure: Off", + "Direct to chat providers.", + ].join("\n"), + "QuickStart defaults", + ); + } + const localPort = resolveGatewayPort(baseConfig); const localUrl = `ws://127.0.0.1:${localPort}`; const localProbe = await probeGatewayReachable({ @@ -130,27 +163,29 @@ export async function runOnboardingWizard( const mode = opts.mode ?? - ((await prompter.select({ - message: "What do you want to set up?", - options: [ - { - value: "local", - label: "Local gateway (this machine)", - hint: localProbe.ok - ? `Gateway reachable (${localUrl})` - : `No gateway detected (${localUrl})`, - }, - { - value: "remote", - label: "Remote gateway (info-only)", - hint: !remoteUrl - ? "No remote URL configured yet" - : remoteProbe?.ok - ? `Gateway reachable (${remoteUrl})` - : `Configured but unreachable (${remoteUrl})`, - }, - ], - })) as OnboardMode); + (flow === "quickstart" + ? "local" + : ((await prompter.select({ + message: "What do you want to set up?", + options: [ + { + value: "local", + label: "Local gateway (this machine)", + hint: localProbe.ok + ? `Gateway reachable (${localUrl})` + : `No gateway detected (${localUrl})`, + }, + { + value: "remote", + label: "Remote gateway (info-only)", + hint: !remoteUrl + ? "No remote URL configured yet" + : remoteProbe?.ok + ? `Gateway reachable (${remoteUrl})` + : `Configured but unreachable (${remoteUrl})`, + }, + ], + })) as OnboardMode)); if (mode === "remote") { let nextConfig = await promptRemoteGatewayConfig(baseConfig, prompter); @@ -163,10 +198,12 @@ export async function runOnboardingWizard( const workspaceInput = opts.workspace ?? - (await prompter.text({ - message: "Workspace directory", - initialValue: baseConfig.agent?.workspace ?? DEFAULT_WORKSPACE, - })); + (flow === "quickstart" + ? (baseConfig.agent?.workspace ?? DEFAULT_WORKSPACE) + : await prompter.text({ + message: "Workspace directory", + initialValue: baseConfig.agent?.workspace ?? DEFAULT_WORKSPACE, + })); const workspaceDir = resolveUserPath( workspaceInput.trim() || DEFAULT_WORKSPACE, @@ -201,60 +238,79 @@ export async function runOnboardingWizard( await warnIfModelConfigLooksOff(nextConfig, prompter); - const portRaw = await prompter.text({ - message: "Gateway port", - initialValue: String(localPort), - validate: (value) => - Number.isFinite(Number(value)) ? undefined : "Invalid port", - }); - const port = Number.parseInt(String(portRaw), 10); + const port = + flow === "quickstart" + ? DEFAULT_GATEWAY_PORT + : Number.parseInt( + String( + await prompter.text({ + message: "Gateway port", + initialValue: String(localPort), + validate: (value) => + Number.isFinite(Number(value)) ? undefined : "Invalid port", + }), + ), + 10, + ); - let bind = (await prompter.select({ - message: "Gateway bind", - options: [ - { value: "loopback", label: "Loopback (127.0.0.1)" }, - { value: "lan", label: "LAN" }, - { value: "tailnet", label: "Tailnet" }, - { value: "auto", label: "Auto" }, - ], - })) as "loopback" | "lan" | "tailnet" | "auto"; + let bind = ( + flow === "quickstart" + ? "loopback" + : ((await prompter.select({ + message: "Gateway bind", + options: [ + { value: "loopback", label: "Loopback (127.0.0.1)" }, + { value: "lan", label: "LAN" }, + { value: "tailnet", label: "Tailnet" }, + { value: "auto", label: "Auto" }, + ], + })) as "loopback" | "lan" | "tailnet" | "auto") + ) as "loopback" | "lan" | "tailnet" | "auto"; - let authMode = (await prompter.select({ - message: "Gateway auth", - options: [ - { - value: "off", - label: "Off (loopback only)", - hint: "Recommended for single-machine setups", - }, - { - value: "token", - label: "Token", - hint: "Use for multi-machine access or non-loopback binds", - }, - { value: "password", label: "Password" }, - ], - })) as GatewayAuthChoice; + let authMode = ( + flow === "quickstart" + ? "off" + : ((await prompter.select({ + message: "Gateway auth", + options: [ + { + value: "off", + label: "Off (loopback only)", + hint: "Recommended for single-machine setups", + }, + { + value: "token", + label: "Token", + hint: "Use for multi-machine access or non-loopback binds", + }, + { value: "password", label: "Password" }, + ], + })) as GatewayAuthChoice) + ) as GatewayAuthChoice; - const tailscaleMode = (await prompter.select({ - message: "Tailscale exposure", - options: [ - { value: "off", label: "Off", hint: "No Tailscale exposure" }, - { - value: "serve", - label: "Serve", - hint: "Private HTTPS for your tailnet (devices on Tailscale)", - }, - { - value: "funnel", - label: "Funnel", - hint: "Public HTTPS via Tailscale Funnel (internet)", - }, - ], - })) as "off" | "serve" | "funnel"; + const tailscaleMode = ( + flow === "quickstart" + ? "off" + : ((await prompter.select({ + message: "Tailscale exposure", + options: [ + { value: "off", label: "Off", hint: "No Tailscale exposure" }, + { + value: "serve", + label: "Serve", + hint: "Private HTTPS for your tailnet (devices on Tailscale)", + }, + { + value: "funnel", + label: "Funnel", + hint: "Public HTTPS via Tailscale Funnel (internet)", + }, + ], + })) as "off" | "serve" | "funnel") + ) as "off" | "serve" | "funnel"; let tailscaleResetOnExit = false; - if (tailscaleMode !== "off") { + if (tailscaleMode !== "off" && flow !== "quickstart") { await prompter.note( [ "Docs:", @@ -348,6 +404,9 @@ export async function runOnboardingWizard( nextConfig = await setupProviders(nextConfig, runtime, prompter, { allowSignalInstall: true, + forceAllowFromProviders: + flow === "quickstart" ? ["telegram", "whatsapp"] : [], + skipDmPolicyPrompt: flow === "quickstart", }); await writeConfigFile(nextConfig);