feat: add quickstart onboarding defaults

This commit is contained in:
Peter Steinberger
2026-01-08 11:54:40 +01:00
parent f24a4626e3
commit a483e58860
10 changed files with 431 additions and 130 deletions

View File

@@ -24,6 +24,20 @@ Followup reconfiguration:
clawdbot configure 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** (youll be prompted for a number)
**Advanced** exposes every step (mode, workspace, gateway, providers, daemon, skills).
## What the wizard does ## What the wizard does
**Local mode (default)** walks you through: **Local mode (default)** walks you through:

View File

@@ -5,6 +5,7 @@ import {
DEFAULT_GATEWAY_DAEMON_RUNTIME, DEFAULT_GATEWAY_DAEMON_RUNTIME,
isGatewayDaemonRuntime, isGatewayDaemonRuntime,
} from "../commands/daemon-runtime.js"; } from "../commands/daemon-runtime.js";
import { resolveControlUiLinks } from "../commands/onboard-helpers.js";
import { import {
createConfigIO, createConfigIO,
loadConfig, loadConfig,
@@ -30,7 +31,6 @@ import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
import { resolveGatewayService } from "../daemon/service.js"; import { resolveGatewayService } from "../daemon/service.js";
import { callGateway } from "../gateway/call.js"; import { callGateway } from "../gateway/call.js";
import { resolveGatewayBindHost } from "../gateway/net.js"; import { resolveGatewayBindHost } from "../gateway/net.js";
import { resolveControlUiLinks } from "../commands/onboard-helpers.js";
import { import {
formatPortDiagnostics, formatPortDiagnostics,
inspectPortUsage, inspectPortUsage,
@@ -48,10 +48,14 @@ type ConfigSummary = {
exists: boolean; exists: boolean;
valid: boolean; valid: boolean;
issues?: Array<{ path: string; message: string }>; issues?: Array<{ path: string; message: string }>;
controlUi?: {
enabled?: boolean;
basePath?: string;
};
}; };
type GatewayStatusSummary = { type GatewayStatusSummary = {
bindMode: string; bindMode: "auto" | "lan" | "tailnet" | "loopback";
bindHost: string | null; bindHost: string | null;
port: number; port: number;
portSource: "service args" | "env/config"; portSource: "service args" | "env/config";
@@ -372,6 +376,9 @@ async function gatherDaemonStatus(opts: {
exists: cliSnapshot?.exists ?? false, exists: cliSnapshot?.exists ?? false,
valid: cliSnapshot?.valid ?? true, valid: cliSnapshot?.valid ?? true,
...(cliSnapshot?.issues?.length ? { issues: cliSnapshot.issues } : {}), ...(cliSnapshot?.issues?.length ? { issues: cliSnapshot.issues } : {}),
...(cliCfg.gateway?.controlUi
? { controlUi: cliCfg.gateway.controlUi }
: {}),
}; };
const daemonConfigSummary: ConfigSummary = { const daemonConfigSummary: ConfigSummary = {
path: daemonSnapshot?.path ?? daemonConfigPath, path: daemonSnapshot?.path ?? daemonConfigPath,
@@ -380,6 +387,9 @@ async function gatherDaemonStatus(opts: {
...(daemonSnapshot?.issues?.length ...(daemonSnapshot?.issues?.length
? { issues: daemonSnapshot.issues } ? { issues: daemonSnapshot.issues }
: {}), : {}),
...(daemonCfg.gateway?.controlUi
? { controlUi: daemonCfg.gateway.controlUi }
: {}),
}; };
const configMismatch = cliConfigSummary.path !== daemonConfigSummary.path; const configMismatch = cliConfigSummary.path !== daemonConfigSummary.path;
@@ -390,7 +400,11 @@ async function gatherDaemonStatus(opts: {
? "service args" ? "service args"
: "env/config"; : "env/config";
const bindMode = daemonCfg.gateway?.bind ?? "loopback"; const bindMode = (daemonCfg.gateway?.bind ?? "loopback") as
| "auto"
| "lan"
| "tailnet"
| "loopback";
const bindHost = resolveGatewayBindHost(bindMode); const bindHost = resolveGatewayBindHost(bindMode);
const tailnetIPv4 = pickPrimaryTailnetIPv4(); const tailnetIPv4 = pickPrimaryTailnetIPv4();
const probeHost = pickProbeHostForBind(bindMode, tailnetIPv4); const probeHost = pickProbeHostForBind(bindMode, tailnetIPv4);
@@ -587,7 +601,12 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) {
if (runtimeLine) { if (runtimeLine) {
defaultRuntime.log(`Runtime: ${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( defaultRuntime.log(
"Warm-up: launch agents can take a few seconds. Try again shortly.", "Warm-up: launch agents can take a few seconds. Try again shortly.",
); );

View File

@@ -3,9 +3,38 @@ import {
CLAUDE_CLI_PROFILE_ID, CLAUDE_CLI_PROFILE_ID,
CODEX_CLI_PROFILE_ID, CODEX_CLI_PROFILE_ID,
} from "../agents/auth-profiles.js"; } from "../agents/auth-profiles.js";
import { colorize, isRich, theme } from "../terminal/theme.js";
import type { AuthChoice } from "./onboard-types.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: { export function buildAuthChoiceOptions(params: {
store: AuthProfileStore; store: AuthProfileStore;
@@ -13,24 +42,26 @@ export function buildAuthChoiceOptions(params: {
}): AuthChoiceOption[] { }): AuthChoiceOption[] {
const options: 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]; const codexCli = params.store.profiles[CODEX_CLI_PROFILE_ID];
if (codexCli?.type === "oauth") { if (codexCli?.type === "oauth") {
options.push({ options.push({
value: "codex-cli", value: "codex-cli",
label: "OpenAI Codex OAuth (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({ options.push({
value: "openai-codex", value: "openai-codex",
label: "OpenAI Codex (ChatGPT OAuth)", label: "OpenAI Codex (ChatGPT OAuth)",

View File

@@ -11,12 +11,18 @@ import {
} from "../config/config.js"; } from "../config/config.js";
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js"; import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js"; import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
import { resolveGatewayService } from "../daemon/service.js"; import { resolveGatewayService } from "../daemon/service.js";
import { buildGatewayConnectionDetails } from "../gateway/call.js"; import { buildGatewayConnectionDetails } from "../gateway/call.js";
import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js"; import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { resolveUserPath, sleep } from "../utils.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 { maybeRepairAnthropicOAuthProfileId } from "./doctor-auth.js";
import { import {
buildGatewayRuntimeHints, buildGatewayRuntimeHints,
@@ -56,13 +62,7 @@ import {
DEFAULT_WORKSPACE, DEFAULT_WORKSPACE,
printWizardHeader, printWizardHeader,
} from "./onboard-helpers.js"; } from "./onboard-helpers.js";
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
import { ensureSystemdUserLingerInteractive } from "./systemd-linger.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" { function resolveMode(cfg: ClawdbotConfig): "local" | "remote" {
return cfg.gateway?.mode === "remote" ? "remote" : "local"; return cfg.gateway?.mode === "remote" ? "remote" : "local";

View File

@@ -470,10 +470,69 @@ async function maybeConfigureDmPolicies(params: {
return cfg; return cfg;
} }
async function promptTelegramAllowFrom(params: {
cfg: ClawdbotConfig;
prompter: WizardPrompter;
accountId: string;
}): Promise<ClawdbotConfig> {
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( async function promptWhatsAppAllowFrom(
cfg: ClawdbotConfig, cfg: ClawdbotConfig,
_runtime: RuntimeEnv, _runtime: RuntimeEnv,
prompter: WizardPrompter, prompter: WizardPrompter,
options?: { forceAllowlist?: boolean },
): Promise<ClawdbotConfig> { ): Promise<ClawdbotConfig> {
const existingPolicy = cfg.whatsapp?.dmPolicy ?? "pairing"; const existingPolicy = cfg.whatsapp?.dmPolicy ?? "pairing";
const existingAllowFrom = cfg.whatsapp?.allowFrom ?? []; const existingAllowFrom = cfg.whatsapp?.allowFrom ?? [];
@@ -481,6 +540,47 @@ async function promptWhatsAppAllowFrom(
existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset"; existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset";
const existingResponsePrefix = cfg.messages?.responsePrefix; 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( await prompter.note(
[ [
"WhatsApp direct chats are gated by `whatsapp.dmPolicy` + `whatsapp.allowFrom`.", "WhatsApp direct chats are gated by `whatsapp.dmPolicy` + `whatsapp.allowFrom`.",
@@ -562,7 +662,7 @@ async function promptWhatsAppAllowFrom(
} }
if (policy === "disabled") return next; if (policy === "disabled") return next;
const options = const allowOptions =
existingAllowFrom.length > 0 existingAllowFrom.length > 0
? ([ ? ([
{ value: "keep", label: "Keep current allowFrom" }, { value: "keep", label: "Keep current allowFrom" },
@@ -579,8 +679,11 @@ async function promptWhatsAppAllowFrom(
const mode = (await prompter.select({ const mode = (await prompter.select({
message: "WhatsApp allowFrom (optional pre-allowlist)", message: "WhatsApp allowFrom (optional pre-allowlist)",
options: options.map((opt) => ({ value: opt.value, label: opt.label })), options: allowOptions.map((opt) => ({
})) as (typeof options)[number]["value"]; value: opt.value,
label: opt.label,
})),
})) as (typeof allowOptions)[number]["value"];
if (mode === "keep") { if (mode === "keep") {
// Keep allowFrom as-is. // Keep allowFrom as-is.
@@ -631,6 +734,8 @@ type SetupProvidersOptions = {
whatsappAccountId?: string; whatsappAccountId?: string;
promptWhatsAppAccountId?: boolean; promptWhatsAppAccountId?: boolean;
onWhatsAppAccountId?: (accountId: string) => void; onWhatsAppAccountId?: (accountId: string) => void;
forceAllowFromProviders?: ProviderChoice[];
skipDmPolicyPrompt?: boolean;
}; };
export async function setupProviders( export async function setupProviders(
@@ -639,6 +744,12 @@ export async function setupProviders(
prompter: WizardPrompter, prompter: WizardPrompter,
options?: SetupProvidersOptions, options?: SetupProvidersOptions,
): Promise<ClawdbotConfig> { ): Promise<ClawdbotConfig> {
const forceAllowFromProviders = new Set(
options?.forceAllowFromProviders ?? [],
);
const forceTelegramAllowFrom = forceAllowFromProviders.has("telegram");
const forceWhatsAppAllowFrom = forceAllowFromProviders.has("whatsapp");
let whatsappAccountId = let whatsappAccountId =
options?.whatsappAccountId?.trim() || resolveDefaultWhatsAppAccountId(cfg); options?.whatsappAccountId?.trim() || resolveDefaultWhatsAppAccountId(cfg);
let whatsappLinked = await detectWhatsAppLinked(cfg, whatsappAccountId); 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")) { 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")) { 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 (options?.allowDisable) {
if (!selection.includes("telegram") && telegramConfigured) { if (!selection.includes("telegram") && telegramConfigured) {

View File

@@ -1,31 +1,35 @@
import { withProgress } from "../../cli/progress.js"; 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 { import {
type ChatProvider, type ClawdbotConfig,
formatProviderAccountLabel, readConfigFileSnapshot,
requireValidConfig, } from "../../config/config.js";
} from "./shared.js";
import { import {
listDiscordAccountIds, listDiscordAccountIds,
resolveDiscordAccount, resolveDiscordAccount,
} from "../../discord/accounts.js"; } from "../../discord/accounts.js";
import { callGateway } from "../../gateway/call.js";
import { import {
listIMessageAccountIds, listIMessageAccountIds,
resolveIMessageAccount, resolveIMessageAccount,
} from "../../imessage/accounts.js"; } 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 { import {
listSignalAccountIds, listSignalAccountIds,
resolveSignalAccount, resolveSignalAccount,
} from "../../signal/accounts.js"; } from "../../signal/accounts.js";
import { listSlackAccountIds, resolveSlackAccount } from "../../slack/accounts.js"; import {
listSlackAccountIds,
resolveSlackAccount,
} from "../../slack/accounts.js";
import { import {
listTelegramAccountIds, listTelegramAccountIds,
resolveTelegramAccount, resolveTelegramAccount,
} from "../../telegram/accounts.js"; } from "../../telegram/accounts.js";
import { formatDocsLink } from "../../terminal/links.js";
import { theme } from "../../terminal/theme.js";
import { normalizeE164 } from "../../utils.js";
import { import {
listWhatsAppAccountIds, listWhatsAppAccountIds,
resolveWhatsAppAccount, resolveWhatsAppAccount,
@@ -35,9 +39,11 @@ import {
readWebSelfId, readWebSelfId,
webAuthExists, webAuthExists,
} from "../../web/session.js"; } from "../../web/session.js";
import { formatAge } from "../../infra/provider-summary.js"; import {
import { normalizeE164 } from "../../utils.js"; type ChatProvider,
import { readConfigFileSnapshot, type ClawdbotConfig } from "../../config/config.js"; formatProviderAccountLabel,
requireValidConfig,
} from "./shared.js";
export type ProvidersStatusOptions = { export type ProvidersStatusOptions = {
json?: boolean; json?: boolean;
@@ -80,10 +86,16 @@ export function formatGatewayProvidersStatusLines(
if (typeof account.tokenSource === "string" && account.tokenSource) { if (typeof account.tokenSource === "string" && account.tokenSource) {
bits.push(`token:${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}`); bits.push(`bot:${account.botTokenSource}`);
} }
if (typeof account.appTokenSource === "string" && account.appTokenSource) { if (
typeof account.appTokenSource === "string" &&
account.appTokenSource
) {
bits.push(`app:${account.appTokenSource}`); bits.push(`app:${account.appTokenSource}`);
} }
if (typeof account.baseUrl === "string" && account.baseUrl) { if (typeof account.baseUrl === "string" && account.baseUrl) {
@@ -176,10 +188,16 @@ async function formatConfigProvidersStatusLines(
if (typeof account.tokenSource === "string" && account.tokenSource) { if (typeof account.tokenSource === "string" && account.tokenSource) {
bits.push(`token:${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}`); bits.push(`bot:${account.botTokenSource}`);
} }
if (typeof account.appTokenSource === "string" && account.appTokenSource) { if (
typeof account.appTokenSource === "string" &&
account.appTokenSource
) {
bits.push(`app:${account.appTokenSource}`); bits.push(`app:${account.appTokenSource}`);
} }
if (typeof account.baseUrl === "string" && account.baseUrl) { if (typeof account.baseUrl === "string" && account.baseUrl) {
@@ -242,7 +260,8 @@ async function formatConfigProvidersStatusLines(
name: account.name, name: account.name,
enabled: account.enabled, enabled: account.enabled,
configured: configured:
Boolean(account.botToken?.trim()) && Boolean(account.appToken?.trim()), Boolean(account.botToken?.trim()) &&
Boolean(account.appToken?.trim()),
botTokenSource: account.botTokenSource, botTokenSource: account.botTokenSource,
appTokenSource: account.appTokenSource, appTokenSource: account.appTokenSource,
}; };
@@ -259,11 +278,18 @@ async function formatConfigProvidersStatusLines(
}), }),
imessage: listIMessageAccountIds(cfg).map((accountId) => { imessage: listIMessageAccountIds(cfg).map((accountId) => {
const account = resolveIMessageAccount({ cfg, 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 { return {
accountId: account.accountId, accountId: account.accountId,
name: account.name, name: account.name,
enabled: account.enabled, enabled: account.enabled,
configured: account.configured, configured: imsgConfigured,
}; };
}), }),
} satisfies Partial<Record<ChatProvider, Array<Record<string, unknown>>>>; } satisfies Partial<Record<ChatProvider, Array<Record<string, unknown>>>>;

View File

@@ -12,6 +12,7 @@ import {
resolveStorePath, resolveStorePath,
type SessionEntry, type SessionEntry,
} from "../config/sessions.js"; } from "../config/sessions.js";
import { resolveGatewayService } from "../daemon/service.js";
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js"; import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
import { info } from "../globals.js"; import { info } from "../globals.js";
import { buildProviderSummary } from "../infra/provider-summary.js"; import { buildProviderSummary } from "../infra/provider-summary.js";
@@ -20,7 +21,6 @@ import {
loadProviderUsageSummary, loadProviderUsageSummary,
} from "../infra/provider-usage.js"; } from "../infra/provider-usage.js";
import { peekSystemEvents } from "../infra/system-events.js"; import { peekSystemEvents } from "../infra/system-events.js";
import { resolveGatewayService } from "../daemon/service.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { resolveWhatsAppAccount } from "../web/accounts.js"; import { resolveWhatsAppAccount } from "../web/accounts.js";
import { resolveHeartbeatSeconds } from "../web/reconnect.js"; import { resolveHeartbeatSeconds } from "../web/reconnect.js";
@@ -29,8 +29,8 @@ import {
logWebSelfId, logWebSelfId,
webAuthExists, webAuthExists,
} from "../web/session.js"; } from "../web/session.js";
import { resolveControlUiLinks } from "./onboard-helpers.js";
import type { HealthSummary } from "./health.js"; import type { HealthSummary } from "./health.js";
import { resolveControlUiLinks } from "./onboard-helpers.js";
export type SessionStatus = { export type SessionStatus = {
key: string; key: string;

View File

@@ -1,18 +1,33 @@
import { type ClawdbotConfig, loadConfig } from "../config/config.js"; import { type ClawdbotConfig, loadConfig } from "../config/config.js";
import { resolveTelegramAccount, listTelegramAccountIds } from "../telegram/accounts.js"; import {
import { resolveDiscordAccount, listDiscordAccountIds } from "../discord/accounts.js"; listDiscordAccountIds,
import { resolveSlackAccount, listSlackAccountIds } from "../slack/accounts.js"; resolveDiscordAccount,
import { resolveSignalAccount, listSignalAccountIds } from "../signal/accounts.js"; } from "../discord/accounts.js";
import { resolveIMessageAccount, listIMessageAccountIds } from "../imessage/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 { theme } from "../terminal/theme.js";
import { normalizeE164 } from "../utils.js"; import { normalizeE164 } from "../utils.js";
import {
listWhatsAppAccountIds,
resolveWhatsAppAccount,
} from "../web/accounts.js";
import { import {
getWebAuthAgeMs, getWebAuthAgeMs,
readWebSelfId, readWebSelfId,
webAuthExists, webAuthExists,
} from "../web/session.js"; } from "../web/session.js";
import { listWhatsAppAccountIds, resolveWhatsAppAccount } from "../web/accounts.js";
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
export type ProviderSummaryOptions = { export type ProviderSummaryOptions = {
colorize?: boolean; colorize?: boolean;
@@ -66,7 +81,11 @@ export async function buildProviderSummary(
const dmPolicy = const dmPolicy =
account.dmPolicy ?? effective.whatsapp?.dmPolicy ?? "pairing"; account.dmPolicy ?? effective.whatsapp?.dmPolicy ?? "pairing";
details.push(`dm:${dmPolicy}`); details.push(`dm:${dmPolicy}`);
const allowFrom = (account.allowFrom ?? effective.whatsapp?.allowFrom ?? []) const allowFrom = (
account.allowFrom ??
effective.whatsapp?.allowFrom ??
[]
)
.map(normalizeE164) .map(normalizeE164)
.filter(Boolean) .filter(Boolean)
.slice(0, 2); .slice(0, 2);
@@ -237,7 +256,15 @@ export async function buildProviderSummary(
const accounts = listIMessageAccountIds(effective).map((accountId) => const accounts = listIMessageAccountIds(effective).map((accountId) =>
resolveIMessageAccount({ cfg: effective, 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; const imessageConfigured = configuredAccounts.length > 0;
lines.push( lines.push(
imessageConfigured imessageConfigured

View File

@@ -14,6 +14,7 @@ export type ResolvedWhatsAppAccount = {
authDir: string; authDir: string;
isLegacyAuthDir: boolean; isLegacyAuthDir: boolean;
selfChatMode?: boolean; selfChatMode?: boolean;
dmPolicy?: WhatsAppAccountConfig["dmPolicy"];
allowFrom?: string[]; allowFrom?: string[];
groupAllowFrom?: string[]; groupAllowFrom?: string[];
groupPolicy?: GroupPolicy; groupPolicy?: GroupPolicy;
@@ -107,6 +108,7 @@ export function resolveWhatsAppAccount(params: {
authDir, authDir,
isLegacyAuthDir: isLegacy, isLegacyAuthDir: isLegacy,
selfChatMode: accountCfg?.selfChatMode ?? params.cfg.whatsapp?.selfChatMode, selfChatMode: accountCfg?.selfChatMode ?? params.cfg.whatsapp?.selfChatMode,
dmPolicy: accountCfg?.dmPolicy ?? params.cfg.whatsapp?.dmPolicy,
allowFrom: accountCfg?.allowFrom ?? params.cfg.whatsapp?.allowFrom, allowFrom: accountCfg?.allowFrom ?? params.cfg.whatsapp?.allowFrom,
groupAllowFrom: groupAllowFrom:
accountCfg?.groupAllowFrom ?? params.cfg.whatsapp?.groupAllowFrom, accountCfg?.groupAllowFrom ?? params.cfg.whatsapp?.groupAllowFrom,

View File

@@ -39,6 +39,7 @@ import { ensureSystemdUserLingerInteractive } from "../commands/systemd-linger.j
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { import {
CONFIG_PATH_CLAWDBOT, CONFIG_PATH_CLAWDBOT,
DEFAULT_GATEWAY_PORT,
readConfigFileSnapshot, readConfigFileSnapshot,
resolveGatewayPort, resolveGatewayPort,
writeConfigFile, 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 localPort = resolveGatewayPort(baseConfig);
const localUrl = `ws://127.0.0.1:${localPort}`; const localUrl = `ws://127.0.0.1:${localPort}`;
const localProbe = await probeGatewayReachable({ const localProbe = await probeGatewayReachable({
@@ -130,27 +163,29 @@ export async function runOnboardingWizard(
const mode = const mode =
opts.mode ?? opts.mode ??
((await prompter.select({ (flow === "quickstart"
message: "What do you want to set up?", ? "local"
options: [ : ((await prompter.select({
{ message: "What do you want to set up?",
value: "local", options: [
label: "Local gateway (this machine)", {
hint: localProbe.ok value: "local",
? `Gateway reachable (${localUrl})` label: "Local gateway (this machine)",
: `No gateway detected (${localUrl})`, hint: localProbe.ok
}, ? `Gateway reachable (${localUrl})`
{ : `No gateway detected (${localUrl})`,
value: "remote", },
label: "Remote gateway (info-only)", {
hint: !remoteUrl value: "remote",
? "No remote URL configured yet" label: "Remote gateway (info-only)",
: remoteProbe?.ok hint: !remoteUrl
? `Gateway reachable (${remoteUrl})` ? "No remote URL configured yet"
: `Configured but unreachable (${remoteUrl})`, : remoteProbe?.ok
}, ? `Gateway reachable (${remoteUrl})`
], : `Configured but unreachable (${remoteUrl})`,
})) as OnboardMode); },
],
})) as OnboardMode));
if (mode === "remote") { if (mode === "remote") {
let nextConfig = await promptRemoteGatewayConfig(baseConfig, prompter); let nextConfig = await promptRemoteGatewayConfig(baseConfig, prompter);
@@ -163,10 +198,12 @@ export async function runOnboardingWizard(
const workspaceInput = const workspaceInput =
opts.workspace ?? opts.workspace ??
(await prompter.text({ (flow === "quickstart"
message: "Workspace directory", ? (baseConfig.agent?.workspace ?? DEFAULT_WORKSPACE)
initialValue: baseConfig.agent?.workspace ?? DEFAULT_WORKSPACE, : await prompter.text({
})); message: "Workspace directory",
initialValue: baseConfig.agent?.workspace ?? DEFAULT_WORKSPACE,
}));
const workspaceDir = resolveUserPath( const workspaceDir = resolveUserPath(
workspaceInput.trim() || DEFAULT_WORKSPACE, workspaceInput.trim() || DEFAULT_WORKSPACE,
@@ -201,60 +238,79 @@ export async function runOnboardingWizard(
await warnIfModelConfigLooksOff(nextConfig, prompter); await warnIfModelConfigLooksOff(nextConfig, prompter);
const portRaw = await prompter.text({ const port =
message: "Gateway port", flow === "quickstart"
initialValue: String(localPort), ? DEFAULT_GATEWAY_PORT
validate: (value) => : Number.parseInt(
Number.isFinite(Number(value)) ? undefined : "Invalid port", String(
}); await prompter.text({
const port = Number.parseInt(String(portRaw), 10); message: "Gateway port",
initialValue: String(localPort),
validate: (value) =>
Number.isFinite(Number(value)) ? undefined : "Invalid port",
}),
),
10,
);
let bind = (await prompter.select({ let bind = (
message: "Gateway bind", flow === "quickstart"
options: [ ? "loopback"
{ value: "loopback", label: "Loopback (127.0.0.1)" }, : ((await prompter.select({
{ value: "lan", label: "LAN" }, message: "Gateway bind",
{ value: "tailnet", label: "Tailnet" }, options: [
{ value: "auto", label: "Auto" }, { value: "loopback", label: "Loopback (127.0.0.1)" },
], { value: "lan", label: "LAN" },
})) as "loopback" | "lan" | "tailnet" | "auto"; { value: "tailnet", label: "Tailnet" },
{ value: "auto", label: "Auto" },
],
})) as "loopback" | "lan" | "tailnet" | "auto")
) as "loopback" | "lan" | "tailnet" | "auto";
let authMode = (await prompter.select({ let authMode = (
message: "Gateway auth", flow === "quickstart"
options: [ ? "off"
{ : ((await prompter.select({
value: "off", message: "Gateway auth",
label: "Off (loopback only)", options: [
hint: "Recommended for single-machine setups", {
}, value: "off",
{ label: "Off (loopback only)",
value: "token", hint: "Recommended for single-machine setups",
label: "Token", },
hint: "Use for multi-machine access or non-loopback binds", {
}, value: "token",
{ value: "password", label: "Password" }, label: "Token",
], hint: "Use for multi-machine access or non-loopback binds",
})) as GatewayAuthChoice; },
{ value: "password", label: "Password" },
],
})) as GatewayAuthChoice)
) as GatewayAuthChoice;
const tailscaleMode = (await prompter.select({ const tailscaleMode = (
message: "Tailscale exposure", flow === "quickstart"
options: [ ? "off"
{ value: "off", label: "Off", hint: "No Tailscale exposure" }, : ((await prompter.select({
{ message: "Tailscale exposure",
value: "serve", options: [
label: "Serve", { value: "off", label: "Off", hint: "No Tailscale exposure" },
hint: "Private HTTPS for your tailnet (devices on Tailscale)", {
}, value: "serve",
{ label: "Serve",
value: "funnel", hint: "Private HTTPS for your tailnet (devices on Tailscale)",
label: "Funnel", },
hint: "Public HTTPS via Tailscale Funnel (internet)", {
}, value: "funnel",
], label: "Funnel",
})) as "off" | "serve" | "funnel"; hint: "Public HTTPS via Tailscale Funnel (internet)",
},
],
})) as "off" | "serve" | "funnel")
) as "off" | "serve" | "funnel";
let tailscaleResetOnExit = false; let tailscaleResetOnExit = false;
if (tailscaleMode !== "off") { if (tailscaleMode !== "off" && flow !== "quickstart") {
await prompter.note( await prompter.note(
[ [
"Docs:", "Docs:",
@@ -348,6 +404,9 @@ export async function runOnboardingWizard(
nextConfig = await setupProviders(nextConfig, runtime, prompter, { nextConfig = await setupProviders(nextConfig, runtime, prompter, {
allowSignalInstall: true, allowSignalInstall: true,
forceAllowFromProviders:
flow === "quickstart" ? ["telegram", "whatsapp"] : [],
skipDmPolicyPrompt: flow === "quickstart",
}); });
await writeConfigFile(nextConfig); await writeConfigFile(nextConfig);