diff --git a/docs/cli/crestodian.md b/docs/cli/crestodian.md index cd1b94f0a32..67ad6777b71 100644 --- a/docs/cli/crestodian.md +++ b/docs/cli/crestodian.md @@ -15,7 +15,7 @@ Crestodian is OpenClaw's local setup, repair, and configuration helper. It stays Running `openclaw` with no subcommand routes based on config state: -- Config missing, or exists with no authored settings (empty, or only `$schema`/`meta` keys): starts classic onboarding. +- Config missing, or exists with no authored settings (empty, or only `$schema`/`meta` keys): starts guided onboarding with live AI verification. - Config exists but fails validation: starts Crestodian. - Config exists and is valid: opens the normal agent TUI (against a reachable configured Gateway, or locally if none is reachable). Use `/crestodian` inside the TUI, or run `openclaw crestodian` directly, to reach Crestodian. @@ -23,7 +23,9 @@ Running `openclaw crestodian` always starts Crestodian explicitly, regardless of Noninteractive bare `openclaw` (no TTY) exits with a short message instead of printing root help: it points to non-interactive onboarding on a fresh install, to `openclaw crestodian --message "status"` when config is invalid, or to `openclaw agent --local ...` when config is valid. -`openclaw onboard --modern` starts Crestodian as the modern onboarding preview. Plain `openclaw onboard` keeps classic onboarding. +`openclaw onboard --modern` starts Crestodian directly. Plain `openclaw onboard` +starts guided onboarding; `openclaw onboard --classic` opens the full +step-by-step wizard. ## What Crestodian shows @@ -72,6 +74,12 @@ create agent work workspace ~/Projects/work models configure model provider set default model openai/gpt-5.5 +channels +channel info slack +connect slack +open setup wizard +open classic wizard +open channel wizard for slack plugins list plugins search slack plugin install clawhub:openclaw-codex-app-server @@ -96,10 +104,27 @@ Approval is given in your own words: unambiguous replies ("yes", "sure", "go ahe Applied writes are recorded in `~/.openclaw/audit/crestodian.jsonl`. Discovery is not audited; only applied operations and writes are. -Channel setup can run as a hosted conversation when the host supports masked -input. The local Crestodian TUI does not accept sensitive wizard answers; -instead it directs you to `openclaw channels add --channel `, whose -interactive prompts mask credentials. +Channel setup can run as a hosted conversation until it reaches a secret. The +local Crestodian TUI does not accept sensitive wizard answers because terminal +chat input is visible. It offers `open channel wizard` immediately, carrying +the selected channel into the masked terminal wizard; you can also run +`openclaw channels add --channel ` later. + +### Switching to the menu wizards + +The local chat can hand control back to any terminal menu flow: + +```text +open setup wizard +open classic wizard +open channel wizard for slack +channel info slack +``` + +`open setup wizard` opens guided onboarding. `open classic wizard` opens the +full classic setup. `open channel wizard for ` opens masked channel +setup after the chat TUI closes. Use `channel info ` first for the +channel label, setup state, prerequisites summary, and docs link. Model-provider setup uses the same provider/auth and default-model steps as `openclaw onboard`. In the local Crestodian TUI, approval exits the chat shell, diff --git a/docs/cli/onboard.md b/docs/cli/onboard.md index ee085a29e22..d5557f54c8c 100644 --- a/docs/cli/onboard.md +++ b/docs/cli/onboard.md @@ -7,7 +7,10 @@ title: "Onboard" # `openclaw onboard` -Guided setup for model auth, workspace, gateway, channels, skills, and health in one flow. `openclaw setup` is the same entry point; `openclaw setup --baseline` only writes the baseline config/workspace. +Guided setup that detects existing AI access, verifies it with a live completion, +and configures the workspace and local Gateway. `openclaw setup` is the same +entry point; `openclaw setup --baseline` only writes the baseline +config/workspace. @@ -31,6 +34,7 @@ Guided setup for model auth, workspace, gateway, channels, skills, and health in ```bash openclaw onboard +openclaw onboard --classic openclaw onboard --modern openclaw onboard --flow quickstart openclaw onboard --flow manual @@ -40,16 +44,52 @@ openclaw onboard --skip-bootstrap openclaw onboard --mode remote --remote-url wss://gateway-host:18789 ``` -- `--flow quickstart`: minimal prompts, auto-generates a gateway token. -- `--flow manual` (alias `advanced`): full prompts for port, bind, and auth. +- `--classic`: opens the full step-by-step wizard. +- `--flow quickstart`: opens the classic wizard with minimal prompts and + auto-generates a gateway token. +- `--flow manual` (alias `advanced`): opens the classic wizard with full prompts + for port, bind, and auth. - `--flow import`: runs a detected migration provider (for example Hermes via `--import-from hermes`), previews the plan, then applies after confirmation. Import only runs against a fresh OpenClaw setup - reset config, credentials, sessions, and workspace state first if any exist. Use [`openclaw migrate`](/cli/migrate) for dry-run plans, overwrite mode, reports, and exact mappings. -- `--modern` starts the Crestodian conversational setup/repair assistant instead of the classic flow. +- `--modern` starts the Crestodian conversational setup/repair assistant. + +## Guided flow + +Plain `openclaw onboard` starts the guided flow. It shows the security notice, +asks for a workspace, detects AI access already available through configured +models, API-key environment variables, and supported local CLIs, then tests the +recommended candidate with a real completion. If that candidate fails, +onboarding shows the reason and automatically tries the next usable candidate. + +If automatic detection is exhausted, choose another detected candidate, enter +a provider API key in a masked prompt, open Crestodian chat, switch to the +classic wizard, or skip AI setup for now. A manual key is tested through the +same live completion path. OpenClaw persists the selected model, workspace, and +QuickStart Gateway settings only after the test succeeds; a failed candidate +does not replace the configured model or save the attempted credential. + +Guided setup, the classic wizard, and Crestodian chat are interchangeable. The +guided flow offers chat and classic choices; inside Crestodian, use `open setup +wizard`, `open classic wizard`, or `open channel wizard for ` to switch +back. Channel credentials are always collected in a masked terminal wizard. + +On a configured install, running `openclaw onboard` again verifies the current +default model first, so the same flow acts as a verification and repair pass. +If that check fails, the configured model is never replaced automatically — +onboarding stops and asks how to continue. The check runs outside your +workspace, so a model provided by a workspace plugin can fail here while still +working in the agent. +Use `openclaw onboard --classic` for provider-specific auth, channels, skills, +remote Gateway setup, imports, or full Gateway controls. For conversational +setup and repair, run `openclaw crestodian`; `openclaw onboard --modern` opens +the same chat for onboarding. After configuring model/auth, the classic wizard +can optionally verify the default model with a live completion; verification +failure never blocks completion. In an interactive terminal, bare `openclaw` (no subcommand) routes by config state: - If the active config file is missing or has no authored settings (empty or - metadata-only), it starts this classic onboarding flow. + metadata-only), it starts guided onboarding. - If the config file exists but fails validation, it starts [Crestodian](/cli/crestodian) for repair. - If the config file is valid, it opens the normal agent TUI, either locally diff --git a/docs/docs_map.md b/docs/docs_map.md index f326ec2efb8..b68982398e7 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -1372,6 +1372,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: What Crestodian shows - H2: Examples - H2: Operations and approval + - H3: Switching to the menu wizards - H2: Setup bootstrap - H2: AI conversation - H3: CLI harness trust model @@ -1730,6 +1731,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - Headings: - H1: openclaw onboard - H2: Examples + - H2: Guided flow - H2: Reset - H2: Locale - H2: Non-interactive setup @@ -8961,8 +8963,9 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - Route: /start/wizard - Headings: - H2: Locale - - H2: QuickStart vs Advanced - - H2: What onboarding configures + - H2: Guided default + - H2: Classic wizard: QuickStart vs Advanced + - H2: What classic onboarding configures - H2: Add another agent - H2: Full reference - H2: Related docs diff --git a/docs/start/onboarding-overview.md b/docs/start/onboarding-overview.md index 0eb21965aa1..b25a83e7d4b 100644 --- a/docs/start/onboarding-overview.md +++ b/docs/start/onboarding-overview.md @@ -7,15 +7,16 @@ title: "Onboarding overview" sidebarTitle: "Onboarding Overview" --- -OpenClaw has two onboarding paths. Both configure auth, the Gateway, and -optional chat channels — they just differ in how you interact with the setup. +OpenClaw has terminal and macOS app onboarding. Both can detect existing AI +access, verify it with a live completion, and configure a workspace and Gateway. +The terminal flow also offers the full classic wizard for detailed setup. ## Which path should I use? | | CLI onboarding | macOS app onboarding | | -------------- | -------------------------------------- | --------------------------- | | **Platforms** | macOS, Linux, Windows (native or WSL2) | macOS only | -| **Interface** | Terminal wizard | Guided UI + Crestodian chat | +| **Interface** | Guided, classic, and Crestodian chat | Guided UI + Crestodian chat | | **Best for** | Servers, headless, full control | Desktop Mac, visual setup | | **Automation** | `--non-interactive` for scripts | Manual only | | **Command** | `openclaw onboard` | Launch the app | @@ -25,15 +26,19 @@ you the most control. ## What onboarding configures -Regardless of which path you choose, onboarding sets up: +Guided onboarding sets up: -1. **Model provider and auth** — API key, OAuth, or setup token for your chosen provider +1. **Model provider and auth** — detected access or a verified API key 2. **Workspace** — directory for agent files, bootstrap templates, and memory 3. **Gateway** — port, bind address, auth mode -4. **Channels** (optional) — built-in and bundled chat channels such as +4. **Gateway service** — installs, starts, and probes the local Gateway + +The classic CLI wizard can additionally configure: + +1. **Channels** (optional) — built-in and bundled chat channels such as Discord, Feishu, Google Chat, iMessage, Mattermost, Microsoft Teams, Telegram, WhatsApp, and more -5. **Daemon** (optional) — background service so the Gateway starts automatically +2. **Advanced Gateway controls** — remote mode, network settings, and daemon choices ## CLI onboarding @@ -43,7 +48,20 @@ Run in any terminal: openclaw onboard ``` -Add `--install-daemon` to also install the background service in one step. +The guided flow detects existing AI access, live-tests candidates in order, +falls through on failure, and offers masked manual key entry. It saves the +model and credential only after a passing completion. From the same flow you +can open Crestodian chat, switch to `openclaw onboard --classic`, or skip AI +setup for now. + +These CLI interfaces switch both ways: guided onboarding offers Crestodian and +the classic wizard, while Crestodian can open guided setup, classic setup, or a +masked channel wizard without making you restart the command manually. + +Use `openclaw onboard --classic` for detailed model/auth, channel, skill, +remote Gateway, or import setup. Adding `--install-daemon` also selects the +classic flow and installs the background service in one step. Use `openclaw +onboard --modern` or `openclaw crestodian` for conversational setup and repair. Full reference: [Onboarding (CLI)](/start/wizard) CLI command docs: [`openclaw onboard`](/cli/onboard) @@ -62,8 +80,8 @@ Full reference: [Onboarding (macOS App)](/start/onboarding) ## Custom or unlisted providers -If your provider is not listed in onboarding, choose **Custom Provider** and -enter: +If your provider is not listed, open the classic wizard, choose **Custom +Provider**, and enter: - Endpoint compatibility: OpenAI-compatible (`/chat/completions`), OpenAI Responses-compatible (`/responses`), Anthropic-compatible (`/messages`), or unknown (probes all three and auto-detects) - Base URL and API key (API key is optional if the endpoint does not require one) diff --git a/docs/start/wizard.md b/docs/start/wizard.md index 9050c497d54..14d6bc6f025 100644 --- a/docs/start/wizard.md +++ b/docs/start/wizard.md @@ -12,19 +12,25 @@ openclaw onboard ``` CLI onboarding is the recommended terminal setup path on macOS, Linux, and -Windows (native or WSL2). It configures a local Gateway (or a connection to a -remote Gateway), plus channels, skills, and workspace defaults in one guided -flow. `openclaw setup` runs the same flow ([Setup](/cli/setup) covers the -`--baseline` config-only variant). Windows desktop users can also start from -[Windows Hub](/platforms/windows). +Windows (native or WSL2). By default it detects AI access already available on +the machine, verifies it with a real completion, and configures a workspace and +local Gateway. `openclaw setup` runs the same flow ([Setup](/cli/setup) covers +the `--baseline` config-only variant). Windows desktop users can also start +from [Windows Hub](/platforms/windows). -Provider sign-in, channel pairing, daemon install, and skill downloads can -extend a quick setup; optional steps can be skipped and revisited later with -`openclaw configure`. +The guided flow offers the classic wizard for provider sign-in, remote Gateway +setup, channel pairing, daemon controls, skills, and imports. You can also open +Crestodian chat or skip AI setup and return later. + +Guided setup, the classic wizard, and Crestodian chat are interchangeable. The +guided flow offers chat and classic choices; inside Crestodian, use `open setup +wizard`, `open classic wizard`, or `open channel wizard for ` to switch +back. Channel setup that needs secrets always continues in a masked terminal +wizard. -Fastest first chat: skip channel setup entirely. Run `openclaw dashboard` and -chat in the browser through the Control UI. Docs: [Dashboard](/web/dashboard). +Fastest first chat: finish guided setup, run `openclaw dashboard`, and chat in +the browser through the Control UI. Docs: [Dashboard](/web/dashboard). ## Locale @@ -52,18 +58,41 @@ openclaw agents add -Onboarding includes a web search step where you can pick a provider: Brave, +The classic wizard includes a web search step where you can pick a provider: Brave, DuckDuckGo, Exa, Firecrawl, Gemini, Grok, Kimi, MiniMax Search, Ollama Web Search, Perplexity, SearXNG, or Tavily. Some need an API key; others are key-free. Configure this later with `openclaw configure --section web`. Docs: [Web tools](/tools/web). -## QuickStart vs Advanced +## Guided default -Onboarding opens with a choice between **QuickStart** (defaults) and -**Advanced** (full control). Pass `--flow quickstart` or `--flow advanced` -(alias `manual`) to skip the prompt. +Plain `openclaw onboard` follows this path: + +1. Accept the security notice and choose the workspace. +2. Detect configured models, API-key environment variables, and supported local + AI CLIs. +3. Test the recommended candidate with a real completion. On failure, show the + reason and continue to the next usable candidate. +4. If detection is exhausted, try another detected candidate, enter a provider + API key in a masked prompt, open Crestodian chat, use the classic wizard, or + skip AI setup. +5. Persist the model, credential, workspace, and QuickStart Gateway settings + only after a passing test. Then install/start the Gateway service and probe + it for reachability. + +Re-running the command on a configured installation tests the current default +model first, making the guided flow a verification and repair pass. A failing +check never replaces the configured model automatically; onboarding stops and +asks how to continue. Run `openclaw channels add` or `openclaw configure` for +later additions. + +## Classic wizard: QuickStart vs Advanced + +Run `openclaw onboard --classic` to open the full wizard. It starts with a +choice between **QuickStart** (defaults) and **Advanced** (full control). Pass +`--flow quickstart` or `--flow advanced` (alias `manual`) to select the classic +flow and skip that prompt. @@ -87,7 +116,7 @@ Remote mode (`--mode remote`) always uses the advanced flow; it only configures this machine to connect to a Gateway elsewhere and never installs or changes anything on the remote host. -## What onboarding configures +## What classic onboarding configures Local mode (default) walks through these steps: @@ -102,7 +131,9 @@ Local mode (default) walks through these steps: instead of plaintext API key values; the referenced env var must already be set, or onboarding fails fast. Interactive secret reference mode can point at an environment variable or a configured provider ref (`file` or - `exec`), with a fast preflight check before saving. + `exec`), with a fast preflight check before saving. After model/auth setup, + the wizard offers an optional live completion test; a failure can return to + model/auth setup once or be ignored without blocking the rest of onboarding. 2. **Workspace** - directory for agent files (default `~/.openclaw/workspace`). Seeds bootstrap files. 3. **Gateway** - port, bind address, auth mode, Tailscale exposure. In interactive token mode, choose plaintext token storage (default) or opt @@ -130,11 +161,11 @@ config is invalid or contains legacy keys, onboarding asks you to run `openclaw doctor` first. -`--flow import` runs a detected migration flow (for example Hermes) instead of -fresh setup; see [Migrate](/cli/migrate) and the migration guides under +`--flow import` runs a detected migration flow (for example Hermes) in the +classic wizard instead of fresh setup; see [Migrate](/cli/migrate) and the migration guides under [Install](/install/migrating-hermes). `openclaw onboard --modern` starts -[Crestodian](/cli/crestodian), a conversational setup/repair assistant, in -place of the classic wizard. +[Crestodian](/cli/crestodian), a conversational setup/repair assistant. +`openclaw crestodian` opens the same assistant directly. ## Add another agent diff --git a/src/agents/tools/crestodian-tool.test.ts b/src/agents/tools/crestodian-tool.test.ts index 03531a14f85..7c80946d5a3 100644 --- a/src/agents/tools/crestodian-tool.test.ts +++ b/src/agents/tools/crestodian-tool.test.ts @@ -56,6 +56,13 @@ describe("crestodian tool", () => { expect.anything(), expect.objectContaining({ approved: false }), ); + + await tool.execute("t1b", { action: "channel_info", channel: "Slack" }); + expect(mocks.executeCrestodianOperation).toHaveBeenCalledWith( + { kind: "channel-info", channel: "slack" }, + expect.anything(), + expect.objectContaining({ approved: false }), + ); }); it("refuses mutating actions without the approved assertion", async () => { @@ -246,6 +253,18 @@ describe("crestodian tool", () => { expect(toolText(open)).toContain("directive:"); expect(directiveRef.current).toEqual({ kind: "open-tui", agentId: "work" }); + const setup = await tool.execute("t7", { + action: "open_setup", + target: "channels", + channel: "Slack", + }); + expect(toolText(setup)).toContain("directive:"); + expect(directiveRef.current).toEqual({ + kind: "open-setup", + target: "channels", + channel: "slack", + }); + // Directives are host handoffs, never operation executions. expect(mocks.executeCrestodianOperation).not.toHaveBeenCalled(); }); @@ -269,6 +288,12 @@ describe("crestodian tool", () => { resultText: "directive: the host now starts masked model-provider setup.", }), ).toEqual({ kind: "model-setup", workspace: "/tmp/work" }); + expect( + resolveCrestodianDirectiveTransition({ + args: { action: "open_setup", target: "classic" }, + resultText: "directive: the host now opens the classic setup wizard.", + }), + ).toEqual({ kind: "open-setup", target: "classic" }); // Non-directive results and other actions never mirror. expect( resolveCrestodianDirectiveTransition({ args: { action: "status" }, resultText: "ok" }), diff --git a/src/agents/tools/crestodian-tool.ts b/src/agents/tools/crestodian-tool.ts index c2cf6a42dcb..4b627c76a7c 100644 --- a/src/agents/tools/crestodian-tool.ts +++ b/src/agents/tools/crestodian-tool.ts @@ -42,7 +42,8 @@ export type CrestodianToolOptions = { export type CrestodianToolDirective = | { kind: "channel-setup"; channel: string } | { kind: "model-setup"; workspace?: string } - | { kind: "open-tui"; agentId?: string; workspace?: string }; + | { kind: "open-tui"; agentId?: string; workspace?: string } + | Extract; /** Canonical operation fingerprint used to bind "yes" to one exact mutation. */ export function hashCrestodianOperation(operation: CrestodianOperation): string { @@ -90,6 +91,9 @@ function directiveForOperation(operation: CrestodianOperation): CrestodianToolDi ...(operation.workspace ? { workspace: operation.workspace } : {}), }; } + if (operation.kind === "open-setup") { + return operation; + } return null; } @@ -127,6 +131,7 @@ const CRESTODIAN_TOOL_ACTIONS = [ "models", "agents", "channels", + "channel_info", "audit", "validate_config", "doctor", @@ -138,6 +143,7 @@ const CRESTODIAN_TOOL_ACTIONS = [ "connect_channel", "configure_model_provider", "open_agent", + "open_setup", // Mutating actions below require approved=true. "setup", "set_default_model", @@ -161,7 +167,14 @@ const CrestodianToolSchema = Type.Object({ workspace: Type.Optional(Type.String({ description: "Workspace directory" })), agentId: Type.Optional(Type.String({ description: "Agent id for create_agent/open_agent" })), channel: Type.Optional( - Type.String({ description: "Channel id for connect_channel (e.g. telegram)" }), + Type.String({ + description: "Channel id for connect_channel, channel_info, or open_setup channels", + }), + ), + target: Type.Optional( + stringEnum(["guided", "classic", "channels"], { + description: "Setup wizard target for open_setup (defaults to guided)", + }), ), query: Type.Optional(Type.String({ description: "Search query for plugin_search" })), spec: Type.Optional(Type.String({ description: "npm/clawhub spec for plugin_install" })), @@ -194,6 +207,14 @@ function requireParam(params: Record, name: string): string { return value.trim(); } +function readSetupTarget(params: Record): "guided" | "classic" | "channels" { + const target = readStringParam(params, "target")?.trim() ?? "guided"; + if (target === "guided" || target === "classic" || target === "channels") { + return target; + } + throw new ToolInputError(`crestodian: unknown setup target "${target}"`); +} + function operationForAction(params: Record): CrestodianOperation { const action = readStringParam(params, "action", { required: true }); switch (action) { @@ -205,6 +226,8 @@ function operationForAction(params: Record): CrestodianOperatio return { kind: "agents" }; case "channels": return { kind: "channel-list" }; + case "channel_info": + return { kind: "channel-info", channel: requireParam(params, "channel").toLowerCase() }; case "audit": return { kind: "audit" }; case "validate_config": @@ -236,6 +259,15 @@ function operationForAction(params: Record): CrestodianOperatio ...(workspace ? { workspace } : {}), }; } + case "open_setup": { + const target = readSetupTarget(params); + const channel = readStringParam(params, "channel")?.trim().toLowerCase(); + return { + kind: "open-setup", + target, + ...(channel ? { channel } : {}), + }; + } case "gateway_start": return { kind: "gateway-start" }; case "gateway_stop": @@ -313,8 +345,8 @@ export function createCrestodianTool(options: CrestodianToolOptions): AnyAgentTo name: "crestodian", label: "Crestodian", description: [ - "Ring-zero OpenClaw setup and repair. Read actions (status/models/agents/channels/config_get/config_schema/gateway_status/plugin_search/validate_config/doctor/audit) run immediately.", - "connect_channel(channel) starts guided channel setup; configure_model_provider starts masked provider/default-model setup; open_agent hands the user to their normal agent. These interactive handoffs run immediately.", + "Ring-zero OpenClaw setup and repair. Read actions (status/models/agents/channels/channel_info/config_get/config_schema/gateway_status/plugin_search/validate_config/doctor/audit) run immediately.", + "connect_channel(channel) starts guided channel setup in this chat; configure_model_provider starts masked provider/default-model setup; open_agent hands off to the normal agent; open_setup hands off to a menu wizard. All run immediately.", "Mutating actions (setup/set_default_model/config_set/config_set_ref/create_agent/gateway_*/plugin_install/plugin_uninstall/doctor_fix) REQUIRE approved=true, which you may only set after the user clearly agreed to that exact change in this conversation.", "Before writing an unfamiliar config path, call config_schema for it — the schema is the source of truth. Secrets go through config_set_ref (env var), never plaintext echoes.", "Every applied write is validated; if the result reports CONFIG INVALID, fix it immediately. All writes are audited.", @@ -335,7 +367,9 @@ export function createCrestodianTool(options: CrestodianToolOptions): AnyAgentTo ? `${CRESTODIAN_DIRECTIVE_PREFIX} the host chat now starts the guided ${directive.channel} setup with the user. Tell the user the setup questions come next; do not describe steps yourself.` : directive.kind === "model-setup" ? `${CRESTODIAN_DIRECTIVE_PREFIX} the host now starts masked model-provider setup. Tell the user the provider questions come next; do not ask for credentials yourself.` - : `${CRESTODIAN_DIRECTIVE_PREFIX} the host now hands the user over to their normal agent. Say goodbye briefly.`, + : directive.kind === "open-tui" + ? `${CRESTODIAN_DIRECTIVE_PREFIX} the host now hands the user over to their normal agent. Say goodbye briefly.` + : `${CRESTODIAN_DIRECTIVE_PREFIX} the host now opens the ${directive.target} setup wizard. Tell the user the menu wizard comes next.`, {}, ); } diff --git a/src/channels/plugins/setup-wizard-helpers.test.ts b/src/channels/plugins/setup-wizard-helpers.test.ts index 2c6a7155dc3..e45ec4772fd 100644 --- a/src/channels/plugins/setup-wizard-helpers.test.ts +++ b/src/channels/plugins/setup-wizard-helpers.test.ts @@ -158,7 +158,9 @@ function createTokenPrompter(params: { confirms: boolean[]; texts: string[] }) { const texts = [...params.texts]; return { confirm: vi.fn(async () => confirms.shift() ?? true), - text: vi.fn(async () => texts.shift() ?? ""), + text: vi.fn<(textParams: { sensitive?: boolean }) => Promise>( + async () => texts.shift() ?? "", + ), }; } @@ -519,6 +521,11 @@ describe("promptSingleChannelToken", () => { }); expect(result).toEqual(expected); expect(prompter.text).toHaveBeenCalledTimes(expectTextCalls); + // Token entry is a credential: masked in terminals, and the Crestodian + // chat bridge refuses plain-text secrets based on this flag. + for (const call of prompter.text.mock.calls) { + expect(call[0]).toMatchObject({ sensitive: true }); + } }); }); diff --git a/src/channels/plugins/setup-wizard-helpers.ts b/src/channels/plugins/setup-wizard-helpers.ts index adf04cf7a51..09917dd2352 100644 --- a/src/channels/plugins/setup-wizard-helpers.ts +++ b/src/channels/plugins/setup-wizard-helpers.ts @@ -982,6 +982,9 @@ export async function promptSingleChannelToken(params: { ( await params.prompter.text({ message: params.inputPrompt, + // Credential input: masked in terminal prompts, and the Crestodian + // chat bridge relies on this flag to refuse plain-text secret entry. + sensitive: true, validate: (value) => (value?.trim() ? undefined : "Required"), }) ).trim(); diff --git a/src/cli/program/register.onboard.ts b/src/cli/program/register.onboard.ts index e53010eb50f..5bab0f35722 100644 --- a/src/cli/program/register.onboard.ts +++ b/src/cli/program/register.onboard.ts @@ -171,11 +171,7 @@ export function registerOnboardCommand(program: Command): void { ) .option("--reset-scope ", "Reset scope: config|config+creds+sessions|full") .option("--non-interactive", "Run without prompts", false) - .option( - "--modern", - "Alias for the default bootstrap onboarding (kept for compatibility)", - false, - ) + .option("--modern", "Open the Crestodian setup chat (kept for compatibility)", false) .option("--classic", "Use the classic multi-step setup wizard", false) .option( "--accept-risk", diff --git a/src/commands/channels.add.test.ts b/src/commands/channels.add.test.ts index 9cb6d48a076..a786658aad6 100644 --- a/src/commands/channels.add.test.ts +++ b/src/commands/channels.add.test.ts @@ -69,9 +69,15 @@ vi.mock("../channels/plugins/catalog.js", () => ({ listRawChannelPluginCatalogEntries: catalogMocks.listChannelPluginCatalogEntries, })); -vi.mock("./channel-setup/discovery.js", () => ({ - isCatalogChannelInstalled: discoveryMocks.isCatalogChannelInstalled, -})); +vi.mock("./channel-setup/discovery.js", async () => { + const actual = await vi.importActual( + "./channel-setup/discovery.js", + ); + return { + ...actual, + isCatalogChannelInstalled: discoveryMocks.isCatalogChannelInstalled, + }; +}); vi.mock("../channels/plugins/bundled.js", async () => { const actual = await vi.importActual( @@ -470,6 +476,22 @@ describe("channelsAddCommand", () => { expect(channelWizardMocks.prompter.outro).toHaveBeenCalledWith("No channel changes made."); }); + it("preselects an installable catalog channel in guided setup", async () => { + const config: OpenClawConfig = { channels: {} }; + configMocks.readConfigFileSnapshot.mockResolvedValue({ + ...baseConfigSnapshot, + sourceConfig: config, + config, + }); + catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([ + { ...createExternalChatCatalogEntry(), origin: "workspace" }, + ]); + + await channelsAddCommand({ channel: "external-chat" }, runtime, { hasFlags: false }); + + expect(setupOptions().initialSelection).toEqual(["external-chat"]); + }); + it("exits quietly when guided channel setup is cancelled", async () => { const { WizardCancelledError } = await import("../wizard/prompts.js"); configMocks.readConfigFileSnapshot.mockResolvedValue({ diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index 9031b4c1fc5..ffa4e13de1d 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -81,6 +81,32 @@ async function resolveCatalogChannelEntry(raw: string, cfg: OpenClawConfig | nul }); } +async function resolveInitialWizardChannel( + raw: string, + cfg: OpenClawConfig, +): Promise { + const normalized = normalizeOptionalLowercaseString(raw); + if (!normalized) { + return undefined; + } + const [{ listActiveChannelSetupPlugins }, { resolveChannelSetupEntries }] = await Promise.all([ + import("../../channels/plugins/setup-registry.js"), + import("../channel-setup/discovery.js"), + ]); + const resolved = resolveChannelSetupEntries({ + cfg, + installedPlugins: listActiveChannelSetupPlugins(), + workspaceDir: resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)), + }); + return resolved.entries.find( + (entry) => + normalizeOptionalLowercaseString(entry.id) === normalized || + (entry.meta.aliases ?? []).some( + (alias) => normalizeOptionalLowercaseString(alias) === normalized, + ), + )?.id; +} + function parseOptionalInt(value: unknown, flag: string): number | undefined { if (value === undefined || value === null || value === "") { return undefined; @@ -167,8 +193,10 @@ async function channelsAddCommandImpl( let selection: ChannelChoice[] = []; const accountIds: Partial> = {}; const resolvedPlugins = new Map(); + const initialChannel = await resolveInitialWizardChannel(opts.channel ?? "", cfg); await prompter.intro("Channel setup"); let nextConfigLocal = await onboardChannels.setupChannels(cfg, runtime, prompter, { + ...(initialChannel ? { initialSelection: [initialChannel] } : {}), allowDisable: false, allowIMessageInstall: true, allowSignalInstall: true, diff --git a/src/commands/onboard-guided.test.ts b/src/commands/onboard-guided.test.ts new file mode 100644 index 00000000000..114ed6bca47 --- /dev/null +++ b/src/commands/onboard-guided.test.ts @@ -0,0 +1,427 @@ +import fs from "node:fs"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { createWizardPrompter } from "../../test/helpers/wizard-prompter.js"; +import { createSuiteLogPathTracker } from "../logging/log-test-helpers.js"; +import { resetLogger, setLoggerOverride } from "../logging/logger.js"; +import { loggingState } from "../logging/state.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; +import type { RuntimeEnv } from "../runtime.js"; +import type { WizardPrompter, WizardSelectParams } from "../wizard/prompts.js"; +import { runGuidedOnboarding, type GuidedOnboardingDeps } from "./onboard-guided.js"; + +vi.mock("../../packages/terminal-core/src/restore.js", () => ({ + restoreTerminalState: vi.fn(), +})); + +vi.mock("./onboard-interactive-runner.js", async (importActual) => { + const actual = await importActual(); + return { ...actual, hasInteractiveOnboardingTty: () => true }; +}); + +const readConfigFileSnapshot = vi.hoisted(() => + vi.fn(async () => ({ exists: false, valid: true, config: {} })), +); + +const logPathTracker = createSuiteLogPathTracker("openclaw-guided-onboard-log-"); + +vi.mock("../config/config.js", () => ({ readConfigFileSnapshot })); + +vi.mock("./onboard-helpers.js", () => ({ + DEFAULT_WORKSPACE: "/tmp/openclaw-workspace", + printWizardHeader: vi.fn(), +})); + +function makeRuntime(): RuntimeEnv { + return { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn() as unknown as RuntimeEnv["exit"], + }; +} + +function candidate(kind: "claude-cli" | "codex-cli", label: string) { + return { + kind, + label, + detail: "logged in", + modelRef: kind === "claude-cli" ? "claude-cli/opus" : "openai/gpt-5.5", + recommended: kind === "claude-cli", + credentials: true, + } as const; +} + +function detection( + overrides: Partial>>> = {}, +) { + return { + candidates: [candidate("claude-cli", "Claude Code")], + manualProviders: [], + workspace: "/tmp/openclaw-workspace", + setupComplete: false, + ...overrides, + }; +} + +function setupDeps(params: { + prompter: WizardPrompter; + detect?: GuidedOnboardingDeps["detect"]; + activate?: GuidedOnboardingDeps["activate"]; + applySetup?: GuidedOnboardingDeps["applySetup"]; + runClassicSetup?: GuidedOnboardingDeps["runClassicSetup"]; + runCrestodianChat?: GuidedOnboardingDeps["runCrestodianChat"]; +}) { + return { + createPrompter: () => params.prompter, + detect: params.detect ?? vi.fn(async () => detection()), + activate: + params.activate ?? + vi.fn(async () => ({ + ok: true as const, + modelRef: "claude-cli/opus", + latencyMs: 1250, + lines: ["Workspace: /tmp/work", "Gateway: running"], + })), + applySetup: params.applySetup, + runClassicSetup: params.runClassicSetup, + runCrestodianChat: params.runCrestodianChat, + launchTui: vi.fn(async () => {}), + } satisfies GuidedOnboardingDeps; +} + +describe("runGuidedOnboarding", () => { + beforeAll(async () => { + await logPathTracker.setup(); + }); + + beforeEach(() => { + readConfigFileSnapshot.mockReset(); + readConfigFileSnapshot.mockResolvedValue({ exists: false, valid: true, config: {} }); + }); + + afterEach(() => { + loggingState.rawConsole = null; + resetLogger(); + }); + + afterAll(async () => { + await logPathTracker.cleanup(); + }); + + it("auto-connects one credentialed candidate and completes without manual setup", async () => { + const select = vi.fn(async () => "unexpected") as unknown as WizardPrompter["select"]; + const prompter = createWizardPrompter({ + text: vi.fn(async () => "/tmp/work"), + select, + confirm: vi.fn(async () => false), + }); + const deps = setupDeps({ prompter }); + + await runGuidedOnboarding({ acceptRisk: true }, makeRuntime(), deps); + + expect(deps.activate).toHaveBeenCalledWith( + expect.objectContaining({ kind: "claude-cli", workspace: "/tmp/work", surface: "cli" }), + ); + expect(select).not.toHaveBeenCalled(); + expect(prompter.outro).toHaveBeenCalledWith("OpenClaw is ready."); + }); + + it("suppresses activation subsystem output and restores it when activation throws", async () => { + const file = logPathTracker.nextPath(); + setLoggerOverride({ level: "info", consoleLevel: "info", file }); + const consoleLog = vi.fn(); + loggingState.rawConsole = { + log: consoleLog, + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + const transportLog = createSubsystemLogger("provider-transport-fetch"); + const activationError = new Error("activation failed"); + const activate = vi.fn(async () => { + transportLog.info("[model-fetch] response status=401"); + expect(consoleLog).not.toHaveBeenCalled(); + throw activationError; + }) as GuidedOnboardingDeps["activate"]; + const prompter = createWizardPrompter({ text: vi.fn(async () => "/tmp/work") }); + + await expect( + runGuidedOnboarding({ acceptRisk: true }, makeRuntime(), setupDeps({ prompter, activate })), + ).rejects.toBe(activationError); + + transportLog.info("after activation"); + expect(consoleLog).toHaveBeenCalledOnce(); + const fileLog = fs.readFileSync(file, "utf8"); + expect(fileLog).toContain("[model-fetch] response status=401"); + expect(fileLog).toContain("after activation"); + }); + + it("never replaces a configured model by fallthrough when its check fails", async () => { + const existingModel = { + kind: "existing-model", + label: "Current model", + detail: "already configured", + modelRef: "acme/workspace-model", + recommended: true, + credentials: true, + } as const; + const select = vi.fn(async () => "action:skip") as unknown as WizardPrompter["select"]; + const prompter = createWizardPrompter({ + text: vi.fn(async () => "/tmp/work"), + select, + confirm: vi.fn(async () => false), + }); + const activate = vi.fn(async () => ({ + ok: false as const, + status: "unavailable" as const, + error: "provider not loaded", + })) as GuidedOnboardingDeps["activate"]; + const applySetup = vi.fn>(async () => ({ + configPath: "/tmp/config", + lines: ["Workspace"], + })); + const deps = setupDeps({ + prompter, + detect: vi.fn(async () => + detection({ + candidates: [existingModel, candidate("claude-cli", "Claude Code")], + }), + ), + activate, + applySetup, + }); + + await runGuidedOnboarding({ acceptRisk: true }, makeRuntime(), deps); + + // Only the existing model was auto-tested; the other credentialed candidate + // must not run (and persist) without the user choosing it. + expect(activate).toHaveBeenCalledTimes(1); + expect(activate).toHaveBeenCalledWith(expect.objectContaining({ kind: "existing-model" })); + const notes = JSON.stringify((prompter.note as ReturnType).mock.calls); + expect(notes).toContain("kept unchanged"); + expect(select).toHaveBeenCalled(); + }); + + it("falls through after an auth failure and surfaces both outcomes", async () => { + const prompter = createWizardPrompter({ + text: vi.fn(async () => "/tmp/work"), + confirm: vi.fn(async () => false), + }); + const activate = vi + .fn() + .mockResolvedValueOnce({ ok: false, status: "auth", error: "login expired" }) + .mockResolvedValueOnce({ + ok: true, + modelRef: "openai/gpt-5.5", + latencyMs: 900, + lines: ["Gateway: running"], + }) as GuidedOnboardingDeps["activate"]; + const deps = setupDeps({ + prompter, + detect: vi.fn(async () => + detection({ + candidates: [candidate("claude-cli", "Claude Code"), candidate("codex-cli", "Codex")], + }), + ), + activate, + }); + + await runGuidedOnboarding({ acceptRisk: true }, makeRuntime(), deps); + + expect(activate).toHaveBeenCalledTimes(2); + const notes = JSON.stringify((prompter.note as ReturnType).mock.calls); + expect(notes).toContain("Claude Code"); + expect(notes).toContain("Authentication failed"); + expect(notes).toContain("Gateway: running"); + }); + + it("offers an auto-attempted transient failure for manual retry", async () => { + const select = vi.fn(async (params: WizardSelectParams) => { + expect(params.options).toContainEqual( + expect.objectContaining({ + value: "candidate:claude-cli", + label: "Retry Claude Code (logged in)", + }), + ); + return "candidate:claude-cli"; + }) as unknown as WizardPrompter["select"]; + const prompter = createWizardPrompter({ + text: vi.fn(async () => "/tmp/work"), + select, + confirm: vi.fn(async () => false), + }); + const activate = vi + .fn() + .mockResolvedValueOnce({ ok: false, status: "rate_limit", error: "try later" }) + .mockResolvedValueOnce({ + ok: true, + modelRef: "claude-cli/opus", + latencyMs: 700, + lines: ["Gateway: running"], + }) as GuidedOnboardingDeps["activate"]; + const deps = setupDeps({ prompter, activate }); + + await runGuidedOnboarding({ acceptRisk: true }, makeRuntime(), deps); + + expect(activate).toHaveBeenCalledTimes(2); + expect(select).toHaveBeenCalledOnce(); + expect(prompter.outro).toHaveBeenCalledWith("OpenClaw is ready."); + }); + + it("accepts and verifies a manual provider key without displaying it", async () => { + const enteredValue = "synthetic-value"; + const text = vi.fn().mockResolvedValueOnce("/tmp/work").mockResolvedValueOnce(enteredValue); + const select = vi.fn( + async () => "manual:openai-api-key", + ) as unknown as WizardPrompter["select"]; + const prompter = createWizardPrompter({ + text: text as WizardPrompter["text"], + select, + confirm: vi.fn(async () => false), + }); + const activate = vi.fn(async () => ({ + ok: true as const, + modelRef: "openai/gpt-5.5", + latencyMs: 500, + lines: ["Default model: openai/gpt-5.5"], + })) as GuidedOnboardingDeps["activate"]; + const deps = setupDeps({ + prompter, + detect: vi.fn(async () => + detection({ + candidates: [], + manualProviders: [{ id: "openai-api-key", label: "OpenAI", hint: "API key" }], + }), + ), + activate, + }); + const runtime = makeRuntime(); + + await runGuidedOnboarding({ acceptRisk: true }, runtime, deps); + + expect(activate).toHaveBeenCalledWith( + expect.objectContaining({ + kind: "api-key", + authChoice: "openai-api-key", + apiKey: enteredValue, + }), + ); + expect(text).toHaveBeenLastCalledWith(expect.objectContaining({ sensitive: true })); + expect(JSON.stringify((prompter.note as ReturnType).mock.calls)).not.toContain( + enteredValue, + ); + expect(JSON.stringify([runtime.log, runtime.error])).not.toContain(enteredValue); + }); + + it("can skip AI after a manual key fails", async () => { + const text = vi.fn().mockResolvedValueOnce("/tmp/work").mockResolvedValueOnce("bad-key"); + const select = vi + .fn() + .mockResolvedValueOnce("manual:openai-api-key") + .mockResolvedValueOnce("action:skip") as unknown as WizardPrompter["select"]; + const prompter = createWizardPrompter({ + text: text as WizardPrompter["text"], + select, + confirm: vi.fn(async () => false), + }); + const applySetup = vi.fn>(async () => ({ + configPath: "/tmp/config", + lines: ["Workspace"], + })); + const deps = setupDeps({ + prompter, + detect: vi.fn(async () => + detection({ + candidates: [], + manualProviders: [{ id: "openai-api-key", label: "OpenAI" }], + }), + ), + activate: vi.fn(async () => ({ + ok: false as const, + status: "auth" as const, + error: "bad key", + })), + applySetup, + }); + const runtime = makeRuntime(); + + await runGuidedOnboarding({ acceptRisk: true }, runtime, deps); + + expect(applySetup).toHaveBeenCalledWith({ + workspace: "/tmp/work", + surface: "cli", + runtime, + }); + expect(applySetup.mock.calls[0]?.[0]).not.toHaveProperty("model"); + }); + + it("hands options to the classic escape with the collected risk acknowledgement", async () => { + const opts = { workspace: "/tmp/original" }; + const select = vi.fn(async () => "action:classic") as unknown as WizardPrompter["select"]; + const prompter = createWizardPrompter({ + text: vi.fn(async () => "/tmp/work"), + select, + confirm: vi.fn(async () => true), + }); + const runClassicSetup = vi.fn(async () => {}); + const deps = setupDeps({ + prompter, + detect: vi.fn(async () => detection({ candidates: [] })), + runClassicSetup, + }); + const runtime = makeRuntime(); + + await runGuidedOnboarding(opts, runtime, deps); + + // Guided already collected the risk acknowledgement; classic must not re-ask. + expect(runClassicSetup).toHaveBeenCalledWith( + { workspace: "/tmp/original", acceptRisk: true }, + runtime, + ); + }); + + it("opens Crestodian chat with the selected workspace", async () => { + const select = vi.fn(async () => "action:crestodian") as unknown as WizardPrompter["select"]; + const prompter = createWizardPrompter({ text: vi.fn(async () => "/tmp/work"), select }); + const runCrestodianChat = vi.fn(async () => {}); + const deps = setupDeps({ + prompter, + detect: vi.fn(async () => detection({ candidates: [] })), + runCrestodianChat, + }); + const runtime = makeRuntime(); + + await runGuidedOnboarding({ acceptRisk: true }, runtime, deps); + + expect(runCrestodianChat).toHaveBeenCalledWith("/tmp/work", runtime, true); + }); + + it("cancels before detection or activation when risk is declined", async () => { + const prompter = createWizardPrompter({ confirm: vi.fn(async () => false) }); + const deps = setupDeps({ prompter }); + const runtime = makeRuntime(); + + await runGuidedOnboarding({}, runtime, deps); + + expect(runtime.exit).toHaveBeenCalledWith(1); + expect(deps.detect).not.toHaveBeenCalled(); + expect(deps.activate).not.toHaveBeenCalled(); + }); + + it("opens Crestodian without writing when existing config is invalid", async () => { + readConfigFileSnapshot.mockResolvedValueOnce({ + exists: true, + valid: false, + config: {}, + }); + const prompter = createWizardPrompter(); + const runCrestodianChat = vi.fn(async () => {}); + const deps = setupDeps({ prompter, runCrestodianChat }); + const runtime = makeRuntime(); + + await runGuidedOnboarding({ workspace: "/tmp/repair" }, runtime, deps); + + expect(runCrestodianChat).toHaveBeenCalledWith("/tmp/repair", runtime, false); + expect(deps.detect).not.toHaveBeenCalled(); + expect(deps.activate).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/onboard-guided.ts b/src/commands/onboard-guided.ts new file mode 100644 index 00000000000..2cc7c9de254 --- /dev/null +++ b/src/commands/onboard-guided.ts @@ -0,0 +1,412 @@ +// Guided onboarding: detect AI access, live-test it, then persist only a working route. +import type { + CrestodianSetupApplyParams, + CrestodianSetupApplyResult, +} from "../crestodian/setup-apply.js"; +import type { + ActivateSetupInferenceResult, + SetupInferenceCandidate, + SetupInferenceDetection, + SetupInferenceStatus, +} from "../crestodian/setup-inference.js"; +import { withConsoleSubsystemsSuppressed } from "../logging/console.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { resolveUserPath, shortenHomePath } from "../utils.js"; +import { t } from "../wizard/i18n/index.js"; +import type { WizardPrompter } from "../wizard/prompts.js"; +import { requireRiskAcknowledgement } from "../wizard/setup.shared.js"; +import { + hasInteractiveOnboardingTty, + runInteractiveOnboarding, +} from "./onboard-interactive-runner.js"; +import type { OnboardOptions } from "./onboard-types.js"; + +type ActivateSetupInference = + typeof import("../crestodian/setup-inference.js").activateSetupInference; +type DetectSetupInference = typeof import("../crestodian/setup-inference.js").detectSetupInference; + +export type GuidedOnboardingDeps = { + detect?: DetectSetupInference; + activate?: ActivateSetupInference; + runClassicSetup?: (opts: OnboardOptions, runtime: RuntimeEnv) => Promise; + runCrestodianChat?: ( + workspace: string, + runtime: RuntimeEnv, + acceptRisk: boolean, + ) => Promise; + applySetup?: (params: CrestodianSetupApplyParams) => Promise; + createPrompter?: () => WizardPrompter | Promise; + launchTui?: () => Promise; +}; + +type GuidedSetupResult = { kind: "complete"; lines: string[] } | { kind: "delegated" }; + +type CandidateAttempt = + | { kind: "success"; result: Extract } + | { kind: "failure" }; + +const MANUAL_CLASSIC = "action:classic"; +const MANUAL_CRESTODIAN = "action:crestodian"; +const MANUAL_SKIP = "action:skip"; + +async function openCrestodianChat( + deps: GuidedOnboardingDeps, + workspace: string, + runtime: RuntimeEnv, + acceptRisk: boolean, +): Promise { + const runChat = + deps.runCrestodianChat ?? + (async (setupWorkspace: string, chatRuntime: RuntimeEnv, riskAccepted: boolean) => { + const { runConversationalOnboarding } = await import("./onboard-interactive.js"); + await runConversationalOnboarding( + { + workspace: setupWorkspace, + ...(riskAccepted ? { acceptRisk: true } : {}), + }, + chatRuntime, + ); + }); + await runChat(workspace, runtime, acceptRisk); +} + +const SETUP_FAILURE_REASON_KEYS: Record = { + auth: "wizard.guided.failureAuth", + rate_limit: "wizard.guided.failureRateLimit", + billing: "wizard.guided.failureBilling", + timeout: "wizard.guided.failureTimeout", + format: "wizard.guided.failureFormat", + unavailable: "wizard.guided.failureUnavailable", + ok: "wizard.guided.failureUnknown", + unknown: "wizard.guided.failureUnknown", +}; + +function setupFailureReason(status: SetupInferenceStatus): string { + return t(SETUP_FAILURE_REASON_KEYS[status]); +} + +async function noteActivationFailure(params: { + prompter: WizardPrompter; + label: string; + result: Extract; +}): Promise { + await params.prompter.note( + t("wizard.guided.testFailure", { + label: params.label, + reason: setupFailureReason(params.result.status), + detail: params.result.error, + }), + t("wizard.guided.aiAccessTitle"), + ); +} + +async function tryCandidate(params: { + candidate: SetupInferenceCandidate; + workspace: string; + runtime: RuntimeEnv; + prompter: WizardPrompter; + activate: ActivateSetupInference; +}): Promise { + const progress = params.prompter.progress( + t("wizard.guided.testingCandidate", { + label: params.candidate.label, + modelRef: params.candidate.modelRef, + }), + ); + const result = await withConsoleSubsystemsSuppressed(() => + params.activate({ + kind: params.candidate.kind, + workspace: params.workspace, + surface: "cli", + runtime: params.runtime, + }), + ); + progress.stop(result.ok ? t("wizard.guided.testPassed") : t("wizard.guided.testFailed")); + if (result.ok) { + return { kind: "success", result }; + } + await noteActivationFailure({ + prompter: params.prompter, + label: params.candidate.label, + result, + }); + return { kind: "failure" }; +} + +async function runManualStage(params: { + detection: SetupInferenceDetection; + autoAttemptedKinds: ReadonlySet; + opts: OnboardOptions; + workspace: string; + runtime: RuntimeEnv; + prompter: WizardPrompter; + deps: GuidedOnboardingDeps; + activate: ActivateSetupInference; +}): Promise { + while (true) { + const choice = await params.prompter.select({ + message: t("wizard.guided.manualChoice"), + options: [ + ...params.detection.candidates.map((candidate) => ({ + value: `candidate:${candidate.kind}`, + label: t( + params.autoAttemptedKinds.has(candidate.kind) + ? "wizard.guided.retryCandidate" + : "wizard.guided.tryCandidate", + { + label: candidate.label, + detail: candidate.detail, + }, + ), + })), + ...params.detection.manualProviders.map((provider) => ({ + value: `manual:${provider.id}`, + label: t("wizard.guided.enterApiKey", { label: provider.label }), + ...(provider.hint ? { hint: provider.hint } : {}), + })), + { + value: MANUAL_CRESTODIAN, + label: t("wizard.guided.openCrestodian"), + }, + { + value: MANUAL_CLASSIC, + label: t("wizard.guided.useClassic"), + }, + { + value: MANUAL_SKIP, + label: t("wizard.guided.skipAi"), + }, + ], + }); + + if (choice === MANUAL_CRESTODIAN) { + await openCrestodianChat(params.deps, params.workspace, params.runtime, true); + return { kind: "delegated" }; + } + if (choice === MANUAL_CLASSIC) { + const runClassic = + params.deps.runClassicSetup ?? + (async (opts: OnboardOptions, runtime: RuntimeEnv) => { + const { runInteractiveSetup } = await import("./onboard-interactive.js"); + await runInteractiveSetup(opts, runtime); + }); + // The classic escape owns its workspace/default handling. The risk + // acknowledgement was already collected by the guided flow, so pass it + // through — re-prompting the same session twice reads as a bug. + await runClassic({ ...params.opts, acceptRisk: true }, params.runtime); + return { kind: "delegated" }; + } + if (choice === MANUAL_SKIP) { + const applySetup = + params.deps.applySetup ?? + (await import("../crestodian/setup-apply.js")).applyCrestodianSetup; + const applied = await applySetup({ + workspace: params.workspace, + surface: "cli", + runtime: params.runtime, + }); + await params.prompter.note(t("wizard.guided.skipAiLater"), t("wizard.guided.aiAccessTitle")); + return { kind: "complete", lines: applied.lines }; + } + if (choice.startsWith("candidate:")) { + const kind = choice.slice("candidate:".length); + const candidate = params.detection.candidates.find((item) => item.kind === kind); + if (!candidate) { + continue; + } + const attempt = await tryCandidate({ + candidate, + workspace: params.workspace, + runtime: params.runtime, + prompter: params.prompter, + activate: params.activate, + }); + if (attempt.kind === "success") { + return { kind: "complete", lines: activationLines(attempt.result) }; + } + continue; + } + + const providerId = choice.slice("manual:".length); + const provider = params.detection.manualProviders.find((item) => item.id === providerId); + if (!provider) { + continue; + } + const apiKey = await params.prompter.text({ + message: t("wizard.guided.apiKeyPrompt", { label: provider.label }), + sensitive: true, + validate: (value) => (value.trim() ? undefined : t("common.required")), + }); + const progress = params.prompter.progress( + t("wizard.guided.testingManualProvider", { label: provider.label }), + ); + const result = await withConsoleSubsystemsSuppressed(() => + params.activate({ + kind: "api-key", + authChoice: provider.id, + apiKey, + workspace: params.workspace, + surface: "cli", + runtime: params.runtime, + }), + ); + progress.stop(result.ok ? t("wizard.guided.testPassed") : t("wizard.guided.testFailed")); + if (result.ok) { + return { kind: "complete", lines: activationLines(result) }; + } + await noteActivationFailure({ prompter: params.prompter, label: provider.label, result }); + } +} + +function activationLines(result: Extract): string[] { + return [ + ...result.lines, + t("wizard.guided.repliedIn", { seconds: (result.latencyMs / 1000).toFixed(1) }), + ]; +} + +async function runGuidedOnboardingFlow( + opts: OnboardOptions, + runtime: RuntimeEnv, + deps: GuidedOnboardingDeps, +): Promise { + const onboardHelpers = await import("./onboard-helpers.js"); + const prompter = await (deps.createPrompter?.() ?? + import("../wizard/clack-prompter.js").then(({ createClackPrompter }) => createClackPrompter())); + onboardHelpers.printWizardHeader(runtime); + await prompter.intro(t("wizard.guided.intro")); + await prompter.note(t("wizard.guided.escapeHatches"), t("wizard.guided.welcomeTitle")); + + const { readConfigFileSnapshot } = await import("../config/config.js"); + const snapshot = await readConfigFileSnapshot(); + if (snapshot.exists && !snapshot.valid) { + await prompter.note( + t("wizard.guided.invalidConfigCrestodian"), + t("wizard.setup.invalidConfigTitle"), + ); + await openCrestodianChat( + deps, + opts.workspace?.trim() || onboardHelpers.DEFAULT_WORKSPACE, + runtime, + false, + ); + return; + } + const existingConfig = + snapshot.exists && snapshot.valid ? (snapshot.sourceConfig ?? snapshot.config) : {}; + await requireRiskAcknowledgement({ opts, prompter, config: existingConfig }); + + const initialWorkspace = + opts.workspace?.trim() || + existingConfig.agents?.defaults?.workspace?.trim() || + onboardHelpers.DEFAULT_WORKSPACE; + const workspaceInput = await prompter.text({ + message: t("wizard.guided.workspace"), + initialValue: initialWorkspace, + }); + const workspace = resolveUserPath(workspaceInput.trim() || initialWorkspace); + + const detect = + deps.detect ?? (await import("../crestodian/setup-inference.js")).detectSetupInference; + const detectionProgress = prompter.progress(t("wizard.guided.detecting")); + const detection = await detect(); + detectionProgress.stop(t("wizard.guided.detected")); + if (detection.candidates.length === 0) { + await prompter.note(t("wizard.guided.foundNothing"), t("wizard.guided.detectedTitle")); + } else { + const orderedCandidates = [ + ...detection.candidates.filter((candidate) => candidate.recommended), + ...detection.candidates.filter((candidate) => !candidate.recommended), + ]; + const candidates = orderedCandidates.map((candidate) => + t("wizard.guided.detectedCandidate", { + label: candidate.label, + detail: candidate.detail, + recommended: candidate.recommended ? t("wizard.guided.recommendedSuffix") : "", + }), + ); + await prompter.note(candidates.join("\n"), t("wizard.guided.detectedTitle")); + } + + const activate = + deps.activate ?? (await import("../crestodian/setup-inference.js")).activateSetupInference; + const autoAttemptedKinds = new Set(); + let result: GuidedSetupResult | undefined; + // Logged-out CLIs stay visible as manual choices, but auto-testing them would + // only produce predictable auth failures and slow the fallback ladder. + for (const candidate of detection.candidates.filter((item) => item.credentials !== false)) { + autoAttemptedKinds.add(candidate.kind); + const attempt = await tryCandidate({ candidate, workspace, runtime, prompter, activate }); + if (attempt.kind === "success") { + result = { kind: "complete", lines: activationLines(attempt.result) }; + break; + } + // The verification probe runs outside the configured workspace (setup never + // executes workspace plugins), so a failing current model can be a false + // negative. Never let the ladder silently replace a configured default — + // stop and let the user decide in the manual stage. + if (candidate.kind === "existing-model") { + await prompter.note(t("wizard.guided.existingModelKept"), t("wizard.guided.aiAccessTitle")); + break; + } + } + result ??= await runManualStage({ + detection, + autoAttemptedKinds, + opts, + workspace, + runtime, + prompter, + deps, + activate, + }); + if (result.kind === "delegated") { + return; + } + + await prompter.note(result.lines.join("\n"), t("wizard.guided.appliedTitle")); + await prompter.note( + t("wizard.guided.nextSteps", { workspace: shortenHomePath(workspace) }), + t("wizard.guided.nextStepsTitle"), + ); + const openChat = await prompter.confirm({ + message: t("wizard.guided.openChatNow"), + initialValue: true, + }); + if (openChat) { + const launchTui = + deps.launchTui ?? + (async () => { + const { launchTuiCli } = await import("../tui/tui-launch.js"); + const { restoreTerminalState } = + await import("../../packages/terminal-core/src/restore.js"); + // Mirror the classic finalize handoff (setup.finalize.ts): the TUI must + // not inherit the wizard prompter's raw/paused terminal state. + restoreTerminalState("pre-setup tui", { resumeStdinIfPaused: false }); + try { + await launchTuiCli({ deliver: false }); + } finally { + restoreTerminalState("post-setup tui", { resumeStdinIfPaused: false }); + } + }); + await launchTui(); + return; + } + await prompter.outro(t("wizard.guided.complete")); +} + +export async function runGuidedOnboarding( + opts: OnboardOptions, + runtime: RuntimeEnv, + deps: GuidedOnboardingDeps = {}, +): Promise { + if (!hasInteractiveOnboardingTty()) { + runtime.error(t("wizard.guided.ttyRequired")); + runtime.exit(1); + return; + } + await runInteractiveOnboarding( + async () => await runGuidedOnboardingFlow(opts, runtime, deps), + runtime, + ); +} diff --git a/src/commands/onboard-interactive-runner.ts b/src/commands/onboard-interactive-runner.ts new file mode 100644 index 00000000000..122eb0a4370 --- /dev/null +++ b/src/commands/onboard-interactive-runner.ts @@ -0,0 +1,30 @@ +// Shared lifecycle handling for interactive onboarding entrypoints. +import { restoreTerminalState } from "../../packages/terminal-core/src/restore.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { WizardCancelledError } from "../wizard/prompts.js"; + +export function hasInteractiveOnboardingTty(): boolean { + return process.stdin.isTTY && process.stdout.isTTY; +} + +export async function runInteractiveOnboarding( + action: () => Promise, + runtime: RuntimeEnv, +): Promise { + let exitCode: number | null = null; + try { + await action(); + } catch (error) { + if (error instanceof WizardCancelledError) { + exitCode = 1; + return; + } + throw error; + } finally { + // Keep stdin paused so non-daemon runs can exit cleanly (e.g. Docker setup). + restoreTerminalState("setup finish", { resumeStdinIfPaused: false }); + if (exitCode !== null) { + runtime.exit(exitCode); + } + } +} diff --git a/src/commands/onboard-interactive.ts b/src/commands/onboard-interactive.ts index 08f10c67b9f..95a58bb7d58 100644 --- a/src/commands/onboard-interactive.ts +++ b/src/commands/onboard-interactive.ts @@ -4,12 +4,14 @@ * It wires the Clack prompter to the setup wizard and restores terminal state * on every exit path so canceled setup cannot leave stdin paused. */ -import { restoreTerminalState } from "../../packages/terminal-core/src/restore.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { createClackPrompter } from "../wizard/clack-prompter.js"; -import { WizardCancelledError } from "../wizard/prompts.js"; import { runSetupWizard } from "../wizard/setup.js"; +import { + hasInteractiveOnboardingTty, + runInteractiveOnboarding, +} from "./onboard-interactive-runner.js"; import type { OnboardOptions } from "./onboard-types.js"; /** Runs the interactive setup wizard and maps user cancellation to exit code 1. */ @@ -18,36 +20,22 @@ export async function runInteractiveSetup( runtime: RuntimeEnv = defaultRuntime, ) { const prompter = createClackPrompter(); - let exitCode: number | null = null; - try { - await runSetupWizard(opts, runtime, prompter); - } catch (err) { - if (err instanceof WizardCancelledError) { - // Best practice: cancellation is not a successful completion. - exitCode = 1; - return; - } - throw err; - } finally { - // Keep stdin paused so non-daemon runs can exit cleanly (e.g. Docker setup). - restoreTerminalState("setup finish", { resumeStdinIfPaused: false }); - if (exitCode !== null) { - runtime.exit(exitCode); - } - } + await runInteractiveOnboarding( + async () => await runSetupWizard(opts, runtime, prompter), + runtime, + ); } /** - * Default interactive onboarding: no step wizard, just the Crestodian - * conversation. The first-run greeting proposes a full setup plan (detected - * inference, workspace, gateway) and a plain "yes" applies it; channels and - * the agent handoff continue in the same conversation. + * Opens the Crestodian onboarding conversation used by the guided escape hatch. + * The first-run greeting proposes a setup plan and keeps subsequent setup and + * agent handoff in the same conversation. */ export async function runConversationalOnboarding( opts: OnboardOptions, runtime: RuntimeEnv = defaultRuntime, ) { - if (!process.stdin.isTTY || !process.stdout.isTTY) { + if (!hasInteractiveOnboardingTty()) { runtime.error( "Onboarding needs an interactive TTY. Use `openclaw onboard --non-interactive --accept-risk ...` for automation.", ); @@ -59,6 +47,7 @@ export async function runConversationalOnboarding( { welcomeVariant: "onboarding", ...(opts.workspace ? { setupWorkspace: opts.workspace } : {}), + ...(opts.acceptRisk === true ? { setupAcceptRisk: true } : {}), }, runtime, ); diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index 5cecf166960..cbded3d82fc 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -42,7 +42,7 @@ export type OnboardOptions = OnboardDynamicProviderOptions & { mode?: OnboardMode; /** "manual" is an alias for "advanced". */ flow?: "quickstart" | "advanced" | "manual" | "import"; - /** Force the classic multi-step interactive wizard instead of the bootstrap flow. */ + /** Force the classic multi-step interactive wizard instead of guided setup. */ classic?: boolean; workspace?: string; nonInteractive?: boolean; diff --git a/src/commands/onboard.test.ts b/src/commands/onboard.test.ts index 9538686f466..908e813a7c3 100644 --- a/src/commands/onboard.test.ts +++ b/src/commands/onboard.test.ts @@ -7,7 +7,7 @@ import { onboardCommand, setupWizardCommand } from "./onboard.js"; const mocks = vi.hoisted(() => ({ runInteractiveSetup: vi.fn(async () => {}), - runConversationalOnboarding: vi.fn(async () => {}), + runGuidedOnboarding: vi.fn(async () => {}), runNonInteractiveSetup: vi.fn(async () => {}), readConfigFileSnapshot: vi.fn(async () => ({ exists: false, valid: false, config: {} })), handleReset: vi.fn(async () => {}), @@ -15,7 +15,10 @@ const mocks = vi.hoisted(() => ({ vi.mock("./onboard-interactive.js", () => ({ runInteractiveSetup: mocks.runInteractiveSetup, - runConversationalOnboarding: mocks.runConversationalOnboarding, +})); + +vi.mock("./onboard-guided.js", () => ({ + runGuidedOnboarding: mocks.runGuidedOnboarding, })); vi.mock("./onboard-non-interactive.js", () => ({ @@ -179,7 +182,7 @@ describe("setupWizardCommand", () => { expect(onboardCommand).toBe(setupWizardCommand); }); - it("routes flagless interactive onboarding to the bootstrap flow", async () => { + it("routes flagless interactive onboarding to the guided flow", async () => { const runtime = makeRuntime(); // Unset Commander booleans arrive as false and must not force classic. @@ -188,7 +191,7 @@ describe("setupWizardCommand", () => { runtime, ); - expect(mocks.runConversationalOnboarding).toHaveBeenCalledOnce(); + expect(mocks.runGuidedOnboarding).toHaveBeenCalledOnce(); expect(mocks.runInteractiveSetup).not.toHaveBeenCalled(); expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled(); }); @@ -211,7 +214,7 @@ describe("setupWizardCommand", () => { await setupWizardCommand(opts, runtime); expect(mocks.runInteractiveSetup).toHaveBeenCalledOnce(); - expect(mocks.runConversationalOnboarding).not.toHaveBeenCalled(); + expect(mocks.runGuidedOnboarding).not.toHaveBeenCalled(); }); it("keeps non-interactive routing unchanged", async () => { @@ -220,7 +223,7 @@ describe("setupWizardCommand", () => { await setupWizardCommand({ nonInteractive: true, acceptRisk: true }, runtime); expect(mocks.runNonInteractiveSetup).toHaveBeenCalledOnce(); - expect(mocks.runConversationalOnboarding).not.toHaveBeenCalled(); + expect(mocks.runGuidedOnboarding).not.toHaveBeenCalled(); expect(mocks.runInteractiveSetup).not.toHaveBeenCalled(); }); }); diff --git a/src/commands/onboard.ts b/src/commands/onboard.ts index 703cfc33966..9b22977e59a 100644 --- a/src/commands/onboard.ts +++ b/src/commands/onboard.ts @@ -16,23 +16,24 @@ import { normalizeLegacyOnboardAuthChoice, resolveDeprecatedAuthChoiceReplacement, } from "./auth-choice-legacy.js"; +import { runGuidedOnboarding } from "./onboard-guided.js"; import { DEFAULT_WORKSPACE, handleReset } from "./onboard-helpers.js"; -import { runConversationalOnboarding, runInteractiveSetup } from "./onboard-interactive.js"; +import { runInteractiveSetup } from "./onboard-interactive.js"; import { runNonInteractiveSetup } from "./onboard-non-interactive.js"; import type { OnboardOptions, ResetScope } from "./onboard-types.js"; const VALID_RESET_SCOPES = new Set(["config", "config+creds+sessions", "full"]); /** - * Interactive onboarding defaults to the Crestodian conversation. Any explicit + * Interactive onboarding defaults to guided setup. Any explicit * setup flag beyond this allowlist keeps the classic wizard — those flags are - * a public automation contract and the conversation does not honor them. + * a public automation contract and guided setup does not honor them. * Boolean false and undefined mean "not passed" (Commander coerces unset * booleans to false); explicit `--no-install-daemon` arrives as `false` via * resolveInstallDaemonFlag and is special-cased. `--modern` never reaches this * dispatch; it routes straight to Crestodian in the command layer. */ -const CONVERSATIONAL_SAFE_ONBOARD_KEYS = new Set([ +const GUIDED_SAFE_ONBOARD_KEYS = new Set([ "workspace", "acceptRisk", "reset", @@ -49,7 +50,7 @@ function wantsClassicInteractiveSetup(opts: OnboardOptions): boolean { return true; } for (const [key, value] of Object.entries(opts)) { - if (CONVERSATIONAL_SAFE_ONBOARD_KEYS.has(key) || key === "installDaemon") { + if (GUIDED_SAFE_ONBOARD_KEYS.has(key) || key === "installDaemon") { continue; } if (value === undefined || value === false) { @@ -157,7 +158,7 @@ export async function setupWizardCommand( return; } - await runConversationalOnboarding(normalizedOpts, runtime); + await runGuidedOnboarding(normalizedOpts, runtime); } export const onboardCommand = setupWizardCommand; diff --git a/src/crestodian/assistant-prompts.ts b/src/crestodian/assistant-prompts.ts index d7f51dc055c..0d18f83358e 100644 --- a/src/crestodian/assistant-prompts.ts +++ b/src/crestodian/assistant-prompts.ts @@ -32,6 +32,8 @@ export const CRESTODIAN_ASSISTANT_SYSTEM_PROMPT = [ "Secrets (tokens, API keys, passwords) must not be written as plaintext when the user prefers env storage: use `config set-ref env `. Never echo secret values back.", "Values for `config set` are parsed as JSON5 when they look like objects/arrays/booleans/numbers, otherwise as strings. One write per turn; after risky writes suggest `validate config`.", "Every applied write is validated automatically; if validation fails you will see the exact issues — propose a corrective command, do not apologize twice.", + "Switching: If the user prefers menus, a step-by-step wizard, or masked secret prompts, hand off: `open setup wizard` (guided), `open classic wizard`, or `open channel wizard for `.", + "Channel guidance: when the user asks ABOUT a channel or its prerequisites (bot tokens, app creation, e.g. Slack or Telegram), run `channel info ` and use its docs link; never guess credentials or steps. When they ask to CONNECT a channel, run `connect ` right away — do not detour through channel info.", "", "Allowed commands:", "- setup", @@ -49,6 +51,10 @@ export const CRESTODIAN_ASSISTANT_SYSTEM_PROMPT = [ "- configure model provider", "- channels", "- connect ", + "- channel info ", + "- open setup wizard", + "- open classic wizard", + "- open channel wizard for ", "- plugins list", "- plugins search ", "- plugin install ", @@ -78,7 +84,9 @@ export const CRESTODIAN_AGENT_SYSTEM_PROMPT = [ "Mutating actions (setup, set_default_model, config_set, config_set_ref, create_agent, gateway_start/stop/restart, plugin_install, plugin_uninstall, doctor_fix) change the user's machine. Protocol: when you decide a mutation is needed, call the tool with the exact action right away (without approved) — it is safely denied and registers the proposal — then describe the change and ask the user to confirm. Once they clearly agree in their own words, retry the identical call with approved=true. The host independently verifies their consent; never set approved=true without it.", "The config file is ~/.openclaw/openclaw.json (JSON5). Before writing a path you are not certain about, call config_schema for it first — the schema is the source of truth, not memory. Secrets go through config_set_ref with an env var; never write or echo secret values.", "If a tool result reports CONFIG INVALID, fix it immediately before anything else.", - "To configure inference access, call configure_model_provider — the host starts masked provider and default-model setup. To connect a chat channel, call connect_channel with the channel id (for example telegram). To hand the user off to their normal agent, call open_agent.", + "To configure inference access, call configure_model_provider — the host starts masked provider and default-model setup. To connect a chat channel, call connect_channel with the channel id (for example telegram) — the guided setup then runs right here in the chat. To hand the user off to their normal agent, call open_agent.", + "Switching: If the user prefers menus, a step-by-step wizard, or masked secret prompts, hand off with open_setup using target guided, classic, or channels (and channel for channel setup).", + "Channel guidance: when the user asks ABOUT a channel or its prerequisites (bot tokens, app creation, e.g. Slack or Telegram), call channel_info and use its docs link; never guess credentials or steps. When they ask to CONNECT a channel, call connect_channel right away — do not detour through channel_info.", "Keep replies under 120 words. Ask one question at a time. Never claim something was done unless the tool result confirms it.", ].join("\n"); diff --git a/src/crestodian/chat-engine.test.ts b/src/crestodian/chat-engine.test.ts index ef5bb2b8cd9..931050ccd9d 100644 --- a/src/crestodian/chat-engine.test.ts +++ b/src/crestodian/chat-engine.test.ts @@ -252,6 +252,44 @@ describe("CrestodianChatEngine", () => { expect(reply.text).toContain("Sensitive input is not accepted"); expect(reply.text).toContain("openclaw channels add --channel telegram"); expect(reply.sensitive).toBeUndefined(); + + const handoff = await engine.handle("open channel wizard"); + expect(handoff.action).toBe("open-setup"); + expect(handoff.handoff).toEqual({ + kind: "open-setup", + target: "channels", + channel: "telegram", + }); + + const channelRequired = await engine.handle("open channel wizard"); + expect(channelRequired.action).toBe("none"); + expect(channelRequired.text).toContain("Which channel"); + + const selectedChannel = await engine.handle("slack"); + expect(selectedChannel.action).toBe("open-setup"); + expect(selectedChannel.handoff).toEqual({ + kind: "open-setup", + target: "channels", + channel: "slack", + }); + }); + + it("hands typed setup switches to CLI hosts but not gateway hosts", async () => { + const common = { + runAgentTurn: async () => null, + planWithAssistant: async () => null, + deps: { loadOverview: fakeOverviewLoader() }, + }; + const cli = new CrestodianChatEngine({ ...common, surface: "cli" }); + const cliReply = await cli.handle("open classic wizard"); + expect(cliReply.action).toBe("open-setup"); + expect(cliReply.handoff).toEqual({ kind: "open-setup", target: "classic" }); + + const gateway = new CrestodianChatEngine({ ...common, surface: "gateway" }); + const gatewayReply = await gateway.handle("open setup wizard"); + expect(gatewayReply.action).toBe("none"); + expect(gatewayReply.handoff).toBeUndefined(); + expect(gatewayReply.text).toContain("The app owns the setup screens here"); }); it("keeps hosted-wizard validation errors on the current prompt", async () => { @@ -320,6 +358,21 @@ describe("CrestodianChatEngine", () => { expect(reply.text).toContain("Handing you over"); }); + it("executes an open-setup directive from the agent loop", async () => { + const engine = new CrestodianChatEngine({ + surface: "cli", + runAgentTurn: async () => ({ + text: "Opening the menu wizard.", + directive: { kind: "open-setup" as const, target: "guided" as const }, + }), + deps: { loadOverview: fakeOverviewLoader() }, + }); + const reply = await engine.handle("I would rather use menus"); + expect(reply.action).toBe("open-setup"); + expect(reply.handoff).toEqual({ kind: "open-setup", target: "guided" }); + expect(reply.text).toContain("Opening the menu wizard"); + }); + it("starts the channel wizard from an agent-loop directive", async () => { useTempStateDir(); const engine = new CrestodianChatEngine({ diff --git a/src/crestodian/chat-engine.ts b/src/crestodian/chat-engine.ts index 6689a23d020..b620c1b0bbd 100644 --- a/src/crestodian/chat-engine.ts +++ b/src/crestodian/chat-engine.ts @@ -61,7 +61,7 @@ export type CrestodianChatEngineOptions = { ) => Promise; }; -export type CrestodianChatReplyAction = "none" | "exit" | "open-tui"; +export type CrestodianChatReplyAction = "none" | "exit" | "open-tui" | "open-setup"; export type CrestodianChatReply = { text: string; @@ -304,6 +304,8 @@ async function withDeadline(work: Promise, fallback: T, deadlineMs: number export class CrestodianChatEngine { private pending: CrestodianOperation | null = null; private wizardBridge: ActiveWizardBridge | null = null; + private lastSensitiveChannel: string | undefined; + private awaitingSetupChannel = false; private readonly history: CrestodianAssistantTurn[] = []; private readonly agentSession: CrestodianAgentSession = createCrestodianAgentSession(); /** Turns run strictly one at a time; interleaved handles corrupt wizard/pending state. */ @@ -334,6 +336,8 @@ export class CrestodianChatEngine { async dispose(): Promise { this.wizardBridge?.session.cancel(); this.wizardBridge = null; + this.lastSensitiveChannel = undefined; + this.awaitingSetupChannel = false; await cleanupCrestodianAgentSession(this.agentSession); } @@ -378,6 +382,23 @@ export class CrestodianChatEngine { // Leaving the process is a host action, not a conversation the AI owns. return { text: "Crestodian retracts into shell. Bye.", action: "exit" }; } + if (this.awaitingSetupChannel) { + if (/^(cancel|abort|stop)$/i.test(trimmed)) { + this.awaitingSetupChannel = false; + return { text: "Channel wizard handoff cancelled.", action: "none" }; + } + if (!/^[a-z0-9_-]+$/i.test(trimmed)) { + return { + text: "Reply with one channel id, such as `slack` or `telegram`, or say `cancel`.", + action: "none", + }; + } + this.awaitingSetupChannel = false; + return await this.runOperation( + { kind: "open-setup", target: "channels", channel: trimmed.toLowerCase() }, + undefined, + ); + } // Secret hygiene: an exact `config set` on a sensitive path carries a raw // token and must never reach a model. It runs on the deterministic path @@ -568,6 +589,13 @@ export class CrestodianChatEngine { handoff: loopReply.directive, }; } + if (loopReply.directive?.kind === "open-setup") { + const handoff = await this.runOperation(loopReply.directive, undefined); + return { + ...handoff, + text: [loopReply.text, handoff.text].filter(Boolean).join("\n\n"), + }; + } return { text: loopReply.text, action: "none" }; } @@ -602,6 +630,40 @@ export class CrestodianChatEngine { }; } + if (operation.kind === "open-setup") { + if (this.opts.surface === "gateway") { + return { + text: "The app owns the setup screens here — use Settings, or run `openclaw onboard` in a terminal.", + action: "none", + }; + } + let handoff = operation; + if (handoff.target === "channels" && !handoff.channel) { + const channel = this.lastSensitiveChannel; + if (!channel) { + this.awaitingSetupChannel = true; + return { + text: "Which channel should I open in the masked terminal wizard?", + action: "none", + }; + } + this.lastSensitiveChannel = undefined; + handoff = { ...handoff, channel }; + } + this.awaitingSetupChannel = false; + const label = + handoff.target === "guided" + ? "guided setup" + : handoff.target === "classic" + ? "classic setup" + : `${handoff.channel ?? "channel"} setup`; + return { + text: `Opening the ${label} wizard.`, + action: "open-setup", + handoff, + }; + } + if (operation.kind === "channel-setup") { // Starting the wizard is not a write; the wizard collects explicit // answers and commits only at the end. @@ -712,6 +774,7 @@ export class CrestodianChatEngine { } private async startChannelSetupWizard(channel: string): Promise { + this.lastSensitiveChannel = undefined; const runWizard = this.opts.runChannelSetupWizard ?? ((ch: string, prompter: WizardPrompterLike) => defaultChannelSetupWizardRunner(ch)(prompter)); @@ -826,15 +889,17 @@ export class CrestodianChatEngine { if (this.opts.surface === "cli" && bridge.step.sensitive === true) { bridge.session.cancel(); this.wizardBridge = null; - return bridge.kind === "model" - ? [ - "Sensitive input is not accepted in the Crestodian TUI because terminal input is visible.", - "Run `openclaw configure --section model` to finish setup with masked prompts.", - ].join("\n") - : [ - "Sensitive input is not accepted in the Crestodian TUI because terminal input is visible.", - `Run \`openclaw channels add --channel ${bridge.label}\` to finish setup with masked prompts.`, - ].join("\n"); + if (bridge.kind === "model") { + return [ + "Sensitive input is not accepted in the Crestodian chat because terminal input is visible.", + "Run `openclaw configure --section model` to finish setup with masked prompts.", + ].join("\n"); + } + this.lastSensitiveChannel = bridge.label; + return [ + "Sensitive input is not accepted in the Crestodian chat because terminal input is visible.", + `Say \`open channel wizard\` and I'll hand you to the masked terminal wizard for ${bridge.label}, or run \`openclaw channels add --channel ${bridge.label}\` yourself later.`, + ].join("\n"); } if (bridge.step.type === "note" || bridge.step.type === "progress") { const step = bridge.step; diff --git a/src/crestodian/crestodian.ts b/src/crestodian/crestodian.ts index 04895dbdc4a..ea0c1b8425c 100644 --- a/src/crestodian/crestodian.ts +++ b/src/crestodian/crestodian.ts @@ -36,6 +36,8 @@ export type RunCrestodianOptions = { welcomeVariant?: "onboarding"; /** Workspace override for the proposed first-run setup (from --workspace). */ setupWorkspace?: string; + /** Risk acknowledgement already collected by the calling onboarding flow. */ + setupAcceptRisk?: boolean; onReady?: () => void; deps?: CrestodianCommandDeps; formatOverview?: (overview: CrestodianOverview) => string; diff --git a/src/crestodian/operations.test.ts b/src/crestodian/operations.test.ts index 9dc7e6d8dae..b83907039a0 100644 --- a/src/crestodian/operations.test.ts +++ b/src/crestodian/operations.test.ts @@ -373,6 +373,108 @@ describe("parseCrestodianOperation", () => { expect(isPersistentCrestodianOperation({ kind: "channel-list" })).toBe(false); }); + it("parses anchored setup switches and channel info", () => { + for (const input of [ + "open setup wizard", + "setup wizard", + "menu setup", + "use the setup wizard", + "use the wizard", + ]) { + expect(parseCrestodianOperation(input)).toEqual({ kind: "open-setup", target: "guided" }); + } + for (const input of ["open classic wizard", "open classic setup wizard", "classic setup"]) { + expect(parseCrestodianOperation(input)).toEqual({ kind: "open-setup", target: "classic" }); + } + expect(parseCrestodianOperation("open channel wizard")).toEqual({ + kind: "open-setup", + target: "channels", + }); + expect(parseCrestodianOperation("open channel wizard for Slack")).toEqual({ + kind: "open-setup", + target: "channels", + channel: "slack", + }); + expect(parseCrestodianOperation("channel info Slack")).toEqual({ + kind: "channel-info", + channel: "slack", + }); + expect(parseCrestodianOperation("about Telegram channel")).toEqual({ + kind: "channel-info", + channel: "telegram", + }); + expect(parseCrestodianOperation("please open the setup wizard soon").kind).toBe("none"); + expect(parseCrestodianOperation("channel info slack please").kind).toBe("none"); + }); + + it("prints one-shot setup pointers", async () => { + const { runtime, lines } = createCrestodianTestRuntime(); + + for (const operation of [ + { kind: "open-setup", target: "guided" } as const, + { kind: "open-setup", target: "classic" } as const, + { kind: "open-setup", target: "channels", channel: "slack" } as const, + ]) { + const result = await executeCrestodianOperation(operation, runtime); + expect(result.applied).toBe(false); + } + + const output = lines.join("\n"); + expect(output).toContain("openclaw onboard`"); + expect(output).toContain("openclaw onboard --classic"); + expect(output).toContain("openclaw channels add --channel slack"); + }); + + it("prints discovered channel metadata and sorted unknown-channel choices", async () => { + const { runtime, lines } = createCrestodianTestRuntime(); + const entries = [ + { + id: "telegram", + meta: { + label: "Telegram", + blurb: "Telegram bot messaging.", + docsPath: "/channels/telegram", + }, + }, + { + id: "slack", + meta: { + label: "Slack", + blurb: "Slack app messaging.", + docsPath: "/channels/slack", + }, + }, + ]; + const deps = { + listChannelSetupPlugins: () => [{ id: "slack" }], + resolveChannelSetupEntries: () => ({ + entries, + installedCatalogEntries: [], + installableCatalogEntries: [], + installedCatalogById: new Map(), + installableCatalogById: new Map(), + }), + isChannelConfigured: (_cfg: unknown, channel: string) => channel === "slack", + } as never; + + await executeCrestodianOperation({ kind: "channel-info", channel: "slack" }, runtime, { + deps, + }); + const knownOutput = lines.join("\n"); + expect(knownOutput).toContain("Slack (slack)"); + expect(knownOutput).toContain("Slack app messaging."); + expect(knownOutput).toContain("Configured: yes"); + expect(knownOutput).toContain("Installed: yes"); + expect(knownOutput).toContain("https://docs.openclaw.ai/channels/slack"); + expect(knownOutput).toContain("open channel wizard for slack"); + + lines.length = 0; + await executeCrestodianOperation({ kind: "channel-info", channel: "matrix" }, runtime, { + deps, + }); + expect(lines.join("\n")).toContain("Known channels: slack, telegram"); + }); + it("parses agent creation requests", () => { expect( parseCrestodianOperation("create agent Work workspace /tmp/work model openai/gpt-5.2"), diff --git a/src/crestodian/operations.ts b/src/crestodian/operations.ts index 815e255ba3a..9b94a67b0fa 100644 --- a/src/crestodian/operations.ts +++ b/src/crestodian/operations.ts @@ -56,7 +56,13 @@ export type CrestodianOperation = | { kind: "setup"; workspace?: string; model?: string } | { kind: "model-setup"; workspace?: string } | { kind: "channel-list" } + | { kind: "channel-info"; channel: string } | { kind: "channel-setup"; channel: string } + | { + kind: "open-setup"; + target: "guided" | "classic" | "channels"; + channel?: string; + } | { kind: "gateway-status" } | { kind: "gateway-start" } | { kind: "gateway-stop" } @@ -119,6 +125,9 @@ export type CrestodianCommandDeps = { /** Where setup side effects run; the gateway surface never manages its own daemon. */ setupSurface?: "cli" | "gateway"; applySetup?: typeof import("./setup-apply.js").applyCrestodianSetup; + listChannelSetupPlugins?: typeof import("../channels/plugins/setup-registry.js").listChannelSetupPlugins; + resolveChannelSetupEntries?: typeof import("../commands/channel-setup/discovery.js").resolveChannelSetupEntries; + isChannelConfigured?: typeof import("../config/channel-configured-shared.js").isStaticallyChannelConfigured; }; // Grammar tokens. Workspace/path tokens accept quoted strings so paths with @@ -172,9 +181,15 @@ const PLUGIN_UNINSTALL_RE = const CHANNEL_LIST_RE = /^(?:channels|list\s+channels|show\s+channels)$/i; const CHANNEL_CONNECT_RE = /^(?:connect|link)\s+(?:channel\s+)?(?:to\s+)?(?[a-z0-9_-]+)(?:\s+channel)?$/i; +const CHANNEL_INFO_RE = + /^(?:channel\s+info\s+(?[a-z0-9_-]+)|about\s+(?[a-z0-9_-]+)\s+channel)$/i; +const OPEN_GUIDED_SETUP_RE = + /^(?:open\s+setup\s+wizard|setup\s+wizard|menu\s+setup|use\s+the\s+(?:setup\s+)?wizard)$/i; +const OPEN_CLASSIC_SETUP_RE = /^(?:open\s+classic(?:\s+setup)?\s+wizard|classic\s+setup)$/i; +const OPEN_CHANNEL_SETUP_RE = /^open\s+channel\s+wizard(?:\s+for\s+(?[a-z0-9_-]+))?$/i; const NO_MATCH_MESSAGE = - "I can run doctor/status/health, check or restart Gateway, list agents/models, configure a model provider, set default model, connect channels (`connect telegram`), show audit, or switch to your agent TUI."; + "I can run doctor/status/health, check or restart Gateway, list agents/models, configure a model provider, set default model, connect channels (`connect telegram`), show `channel info `, open the setup wizard, show audit, or switch to your agent TUI."; /** Audit/source labels for detected inference backends (docs-visible contract). */ const INFERENCE_SOURCE_LABELS: Record = { @@ -289,6 +304,11 @@ export function parseCrestodianOperation(input: string): CrestodianOperation { if (CHANNEL_LIST_RE.test(trimmed)) { return { kind: "channel-list" }; } + const channelInfoMatch = trimmed.match(CHANNEL_INFO_RE); + const channelInfo = channelInfoMatch?.groups?.channel ?? channelInfoMatch?.groups?.aboutChannel; + if (channelInfo) { + return { kind: "channel-info", channel: channelInfo.toLowerCase() }; + } const channelConnectMatch = trimmed.match(CHANNEL_CONNECT_RE); if (channelConnectMatch?.groups?.channel) { return { kind: "channel-setup", channel: channelConnectMatch.groups.channel.toLowerCase() }; @@ -301,6 +321,21 @@ export function parseCrestodianOperation(input: string): CrestodianOperation { ...(workspace ? { workspace } : {}), }; } + if (OPEN_GUIDED_SETUP_RE.test(trimmed)) { + return { kind: "open-setup", target: "guided" }; + } + if (OPEN_CLASSIC_SETUP_RE.test(trimmed)) { + return { kind: "open-setup", target: "classic" }; + } + const openChannelSetupMatch = trimmed.match(OPEN_CHANNEL_SETUP_RE); + if (openChannelSetupMatch) { + const channel = openChannelSetupMatch.groups?.channel?.toLowerCase(); + return { + kind: "open-setup", + target: "channels", + ...(channel ? { channel } : {}), + }; + } const setupMatch = trimmed.match(SETUP_RE); if (setupMatch) { const workspace = trimShellishToken(setupMatch.groups?.workspace); @@ -578,6 +613,38 @@ async function loadOverviewForOperation( return await loadCrestodianOverview(); } +async function resolveChannelSetupState(deps: CrestodianCommandDeps | undefined) { + const listPlugins = + deps?.listChannelSetupPlugins ?? + (await import("../channels/plugins/setup-registry.js")).listChannelSetupPlugins; + const resolveEntries = + deps?.resolveChannelSetupEntries ?? + (await import("../commands/channel-setup/discovery.js")).resolveChannelSetupEntries; + const isConfigured = + deps?.isChannelConfigured ?? + (await import("../config/channel-configured-shared.js")).isStaticallyChannelConfigured; + const { shouldShowChannelInSetup } = await import("../commands/channel-setup/discovery.js"); + const snapshot = await readConfigFileSnapshotLazy(); + const cfg = snapshot.valid ? (snapshot.runtimeConfig ?? snapshot.config) : {}; + const installedPlugins = listPlugins(); + const resolved = resolveEntries({ cfg, installedPlugins }); + return { + cfg, + installedPlugins, + resolved: { + ...resolved, + // Match the connect/list surfaces: setup-hidden channels stay invisible + // to chat listings and channel info alike. + entries: resolved.entries.filter((entry) => shouldShowChannelInSetup(entry.meta)), + }, + isConfigured, + }; +} + +function formatChannelDocsUrl(docsPath: string): string { + return `https://docs.openclaw.ai${docsPath.startsWith("/") ? docsPath : `/${docsPath}`}`; +} + function formatConfigValidationLine(snapshot: ConfigFileSnapshot): string { if (!snapshot.exists) { return `Config missing: ${shortenHomePath(snapshot.path)}`; @@ -1018,22 +1085,8 @@ export async function executeCrestodianOperation( // Use the same discovery as channel setup (bundled plugins + trusted // catalog), so the listing matches what `connect ` can configure // even before any plugin registry is active. - const [ - { listChannelSetupPlugins }, - { resolveChannelSetupEntries, shouldShowChannelInSetup }, - ] = await Promise.all([ - import("../channels/plugins/setup-registry.js"), - import("../commands/channel-setup/discovery.js"), - ]); - const snapshot = await readConfigFileSnapshotLazy(); - const cfg = snapshot.valid ? (snapshot.runtimeConfig ?? snapshot.config) : {}; - const resolved = resolveChannelSetupEntries({ - cfg, - installedPlugins: listChannelSetupPlugins(), - }); - const entries = resolved.entries - .filter((entry) => shouldShowChannelInSetup(entry.meta)) - .toSorted((a, b) => a.id.localeCompare(b.id)); + const { resolved } = await resolveChannelSetupState(opts.deps); + const entries = resolved.entries.toSorted((a, b) => a.id.localeCompare(b.id)); runtime.log( [ "Channels:", @@ -1046,6 +1099,38 @@ export async function executeCrestodianOperation( ); return { applied: false }; } + case "channel-info": { + const { cfg, installedPlugins, resolved, isConfigured } = await resolveChannelSetupState( + opts.deps, + ); + const channel = operation.channel.toLowerCase(); + const entry = resolved.entries.find((candidate) => candidate.id === channel); + if (!entry) { + const knownIds = resolved.entries.map((candidate) => candidate.id).toSorted(); + runtime.log( + [ + `Unknown channel: ${channel}`, + `Known channels: ${knownIds.length > 0 ? knownIds.join(", ") : "none"}`, + ].join("\n"), + ); + return { applied: false }; + } + const installed = + installedPlugins.some((plugin) => plugin.id === entry.id) || + resolved.installedCatalogById.has(entry.id); + runtime.log( + [ + `${entry.meta.label} (${entry.id})`, + entry.meta.blurb, + `Configured: ${isConfigured(cfg, entry.id) ? "yes" : "no"}`, + `Installed: ${installed ? "yes" : "no"}`, + `Docs: ${formatChannelDocsUrl(entry.meta.docsPath)}`, + "", + `Say \`connect ${entry.id}\` to set it up here, or \`open channel wizard for ${entry.id}\` for the masked terminal wizard.`, + ].join("\n"), + ); + return { applied: false }; + } case "channel-setup": // Channel setup is a multi-step wizard; only interactive Crestodian (TUI // chat bridge or the gateway chat) can host it. One-shot mode points at @@ -1067,6 +1152,18 @@ export async function executeCrestodianOperation( ].join("\n"), ); return { applied: false }; + case "open-setup": { + const command = + operation.target === "guided" + ? "openclaw onboard" + : operation.target === "classic" + ? "openclaw onboard --classic" + : `openclaw channels add${operation.channel ? ` --channel ${operation.channel}` : ""}`; + runtime.log( + `One-shot mode cannot open an interactive wizard. Run \`${command}\` in a terminal.`, + ); + return { applied: false }; + } case "setup": return await executeSetup(operation, runtime, opts); case "config-set": diff --git a/src/crestodian/setup-inference.test.ts b/src/crestodian/setup-inference.test.ts index 1446df95a0e..33dc5e338c2 100644 --- a/src/crestodian/setup-inference.test.ts +++ b/src/crestodian/setup-inference.test.ts @@ -14,6 +14,7 @@ import { activateSetupInference, detectSetupInference, listSetupInferenceManualProviders, + verifySetupInference, } from "./setup-inference.js"; vi.mock("../config/config.js", () => ({ @@ -208,6 +209,14 @@ describe("activateSetupInference", () => { }, }); expect(result).toMatchObject({ ok: false, status: "format" }); + expect(runEmbeddedAgent).toHaveBeenCalledWith( + expect.objectContaining({ + runId: expect.stringMatching(/^probe-setup-inference-/), + sessionId: expect.stringMatching(/^probe-setup-inference-.*-session$/), + sessionKey: expect.stringMatching(/^temp:setup-inference:probe-setup-inference-/), + lane: "session:probe-setup-inference:anthropic", + }), + ); expect(applySetup).not.toHaveBeenCalled(); }); @@ -582,3 +591,58 @@ describe("activateSetupInference", () => { }); }); }); + +describe("verifySetupInference", () => { + function configuredSnapshot() { + return { + exists: true, + valid: true, + config: { + agents: { defaults: { model: { primary: "openai/gpt-5.5" } } }, + }, + }; + } + + it("returns a passing live check without persisting setup", async () => { + const applySetup = vi.fn(); + const updateConfig = vi.fn(); + const result = await verifySetupInference({ + runtime, + deps: { + readConfigFileSnapshot: vi.fn(async () => configuredSnapshot()) as never, + runEmbeddedAgent: vi.fn(async () => ({ + meta: { finalAssistantVisibleText: "OK" }, + })) as never, + applySetup: applySetup as never, + updateConfig: updateConfig as never, + createTempDir: makeTempDir, + }, + }); + + expect(result).toMatchObject({ ok: true, modelRef: "openai/gpt-5.5" }); + expect(applySetup).not.toHaveBeenCalled(); + expect(updateConfig).not.toHaveBeenCalled(); + }); + + it("maps live-check failures without writing config or auth", async () => { + const applySetup = vi.fn(); + const updateConfig = vi.fn(); + const result = await verifySetupInference({ + runtime, + timeoutMs: 50, + deps: { + readConfigFileSnapshot: vi.fn(async () => configuredSnapshot()) as never, + runEmbeddedAgent: vi.fn(async () => { + throw new Error("401 invalid_api_key"); + }) as never, + applySetup: applySetup as never, + updateConfig: updateConfig as never, + createTempDir: makeTempDir, + }, + }); + + expect(result).toMatchObject({ ok: false, status: "auth" }); + expect(applySetup).not.toHaveBeenCalled(); + expect(updateConfig).not.toHaveBeenCalled(); + }); +}); diff --git a/src/crestodian/setup-inference.ts b/src/crestodian/setup-inference.ts index dd6feaf58d4..a9d06a10538 100644 --- a/src/crestodian/setup-inference.ts +++ b/src/crestodian/setup-inference.ts @@ -99,6 +99,10 @@ export type ActivateSetupInferenceResult = | { ok: true; modelRef: string; latencyMs: number; lines: string[] } | { ok: false; status: SetupInferenceStatus; error: string }; +export type VerifySetupInferenceResult = + | { ok: true; modelRef: string; latencyMs: number } + | { ok: false; status: SetupInferenceStatus; error: string }; + export type ActivateSetupInferenceParams = { kind: InferenceBackendKind | "api-key"; /** Manual step only: provider-auth choice returned by detection. */ @@ -653,6 +657,47 @@ export async function activateSetupInference( } } +/** Live-test the configured default model without changing config or auth state. */ +export async function verifySetupInference(params: { + kind?: "existing-model"; + runtime: RuntimeEnv; + timeoutMs?: number; + deps?: ActivateSetupInferenceDeps; +}): Promise { + const deps: ActivateSetupInferenceDeps = { + ...params.deps, + ...(params.timeoutMs !== undefined ? { timeoutMs: params.timeoutMs } : {}), + }; + const readSnapshot = + deps.readConfigFileSnapshot ?? (await import("../config/config.js")).readConfigFileSnapshot; + const snapshot = await readSnapshot(); + const cfg: OpenClawConfig = + snapshot.exists && snapshot.valid ? (snapshot.runtimeConfig ?? snapshot.config) : {}; + const tempDir = await ( + deps.createTempDir ?? (() => fs.mkdtemp(path.join(os.tmpdir(), "openclaw-setup-inference-"))) + )(); + try { + const plan = await buildTestPlan({ + kind: params.kind ?? "existing-model", + cfg, + workspaceDir: tempDir, + pluginWorkspaceDir: tempDir, + agentDir: path.join(tempDir, "agent"), + runtime: params.runtime, + deps, + }); + if ("error" in plan) { + return { ok: false, status: "unavailable", error: plan.error }; + } + const test = await runSetupInferenceTest({ plan, tempDir, deps }); + return test.ok ? { ...test, modelRef: plan.modelRef } : test; + } finally { + await (deps.removeTempDir ?? ((dir: string) => fs.rm(dir, { recursive: true, force: true })))( + tempDir, + ); + } +} + function applyManualAuthConfig( config: OpenClawConfig, manualAuth: NonNullable, @@ -693,7 +738,9 @@ async function runSetupInferenceTest(params: { { ok: true; latencyMs: number } | { ok: false; status: SetupInferenceStatus; error: string } > { const { plan, tempDir, deps } = params; - const runId = `setup-inference-${randomUUID()}`; + // Keep these probe prefixes aligned with logging/subsystem.ts and process/command-queue.ts + // so expected setup failures stay off the interactive TTY. + const runId = `probe-setup-inference-${randomUUID()}`; const sessionId = `${runId}-session`; const sessionFile = path.join(tempDir, "session.jsonl"); const timeoutMs = deps.timeoutMs ?? SETUP_INFERENCE_TEST_TIMEOUT_MS; @@ -743,7 +790,7 @@ async function runSetupInferenceTest(params: { : {}), timeoutMs, runId, - lane: `setup-inference:${plan.provider}`, + lane: `session:probe-setup-inference:${plan.provider}`, thinkLevel: "off", reasoningLevel: "off", verboseLevel: "off", diff --git a/src/crestodian/tui-backend.test.ts b/src/crestodian/tui-backend.test.ts index 30f2691e901..1e58e60db25 100644 --- a/src/crestodian/tui-backend.test.ts +++ b/src/crestodian/tui-backend.test.ts @@ -1,5 +1,6 @@ // Crestodian TUI backend tests cover rescue status integration with the TUI backend. import { describe, expect, it, vi } from "vitest"; +import type { CrestodianOperation } from "./operations.js"; import type { RuntimeEnv } from "../runtime.js"; import type { CrestodianOverview } from "./overview.js"; import { runCrestodianTui } from "./tui-backend.js"; @@ -300,4 +301,77 @@ describe("runCrestodianTui", () => { expect(runModelSetup).toHaveBeenCalledOnce(); expect(messages).toEqual([undefined, "configure model provider", undefined]); }); + it("launches setup handoffs after the chat TUI is disposed", async () => { + const cases: Array<{ + handoff: Extract; + expected: string; + }> = [ + { + handoff: { kind: "open-setup", target: "guided" }, + expected: "guided:/tmp/custom-workspace:true", + }, + { + handoff: { kind: "open-setup", target: "classic" }, + expected: "classic:true:/tmp/custom-workspace:true", + }, + { + handoff: { kind: "open-setup", target: "channels", channel: "slack" }, + expected: "channels:slack:false", + }, + ]; + + for (const { handoff, expected } of cases) { + const events: string[] = []; + await runCrestodianTui( + { + deps: { loadOverview: async () => overview }, + setupWorkspace: "/tmp/custom-workspace", + setupAcceptRisk: true, + runTui: async (opts) => { + const backend = opts.backend as unknown as { + sendChat: (opts: { message: string }) => Promise<{ runId: string }>; + setRequestExitHandler: (handler: () => void) => void; + engine: { + handle: () => Promise<{ + text: string; + action: "open-setup"; + handoff: CrestodianOperation; + }>; + dispose: () => Promise; + }; + }; + backend.engine.handle = async () => ({ + text: "Opening setup.", + action: "open-setup", + handoff, + }); + backend.engine.dispose = async () => { + events.push("disposed"); + }; + const requestedExit = new Promise((resolve) => { + backend.setRequestExitHandler(resolve); + }); + await backend.sendChat({ message: "open setup wizard" }); + await requestedExit; + return { exitReason: "exit" }; + }, + runGuidedSetup: async (opts) => { + events.push(`guided:${opts.workspace ?? "default"}:${String(opts.acceptRisk)}`); + }, + runClassicSetup: async (opts) => { + events.push( + `classic:${String(opts.classic)}:${opts.workspace ?? "default"}:${String(opts.acceptRisk)}`, + ); + }, + runChannelsAdd: async (opts, _runtime, params) => { + events.push(`channels:${opts.channel ?? "all"}:${String(params?.hasFlags)}`); + }, + }, + createRuntime(), + ); + + expect(events).toEqual(["disposed", expected]); + } + }); + }); diff --git a/src/crestodian/tui-backend.ts b/src/crestodian/tui-backend.ts index ca05dd26ad6..d887563ae3b 100644 --- a/src/crestodian/tui-backend.ts +++ b/src/crestodian/tui-backend.ts @@ -4,6 +4,8 @@ import type { SessionsPatchParams, SessionsPatchResult, } from "../../packages/gateway-protocol/src/index.js"; +import type { ChannelsAddOptions } from "../commands/channels/add.js"; +import type { OnboardOptions } from "../commands/onboard-types.js"; import { buildAgentMainSessionKey } from "../routing/session-key.js"; import type { RuntimeEnv } from "../runtime.js"; import { notifyListeners } from "../shared/listeners.js"; @@ -39,6 +41,8 @@ export type CrestodianTuiOptions = { welcomeVariant?: "onboarding"; /** Workspace override for the proposed first-run setup (from --workspace). */ setupWorkspace?: string; + /** Risk acknowledgement already collected by the calling onboarding flow. */ + setupAcceptRisk?: boolean; /** Test seam for the channel-setup wizard hosted by the chat bridge. */ runChannelSetupWizard?: CrestodianChatEngineOptions["runChannelSetupWizard"]; /** Test seam for masked terminal model setup after the TUI exits. */ @@ -47,6 +51,13 @@ export type CrestodianTuiOptions = { prompter: import("../wizard/prompts.js").WizardPrompter; runtime: RuntimeEnv; }) => Promise; + runGuidedSetup?: (opts: OnboardOptions, runtime: RuntimeEnv) => Promise; + runClassicSetup?: (opts: OnboardOptions, runtime: RuntimeEnv) => Promise; + runChannelsAdd?: ( + opts: ChannelsAddOptions, + runtime: RuntimeEnv, + params?: { hasFlags?: boolean }, + ) => Promise; }; type CrestodianHistoryMessage = { @@ -312,7 +323,7 @@ class CrestodianTuiBackend implements TuiBackend { private async respond(runId: string, sessionKey: string, text: string): Promise { try { const reply = await this.engine.handle(text); - if (reply.action === "open-tui" && reply.handoff) { + if ((reply.action === "open-tui" || reply.action === "open-setup") && reply.handoff) { // The outer loop owns interactive handoffs after the Crestodian TUI exits. this.handoff = reply.handoff; queueMicrotask(() => this.requestExit?.()); @@ -326,6 +337,44 @@ class CrestodianTuiBackend implements TuiBackend { } } +async function runSetupHandoff( + handoff: Extract, + opts: CrestodianTuiOptions, + runtime: RuntimeEnv, +): Promise { + if (handoff.target === "guided") { + const runGuided = + opts.runGuidedSetup ?? (await import("../commands/onboard-guided.js")).runGuidedOnboarding; + await runGuided( + { + ...(opts.setupWorkspace ? { workspace: opts.setupWorkspace } : {}), + ...(opts.setupAcceptRisk === true ? { acceptRisk: true } : {}), + }, + runtime, + ); + return; + } + if (handoff.target === "classic") { + const runClassic = + opts.runClassicSetup ?? + (await import("../commands/onboard-interactive.js")).runInteractiveSetup; + await runClassic( + { + classic: true, + ...(opts.setupWorkspace ? { workspace: opts.setupWorkspace } : {}), + ...(opts.setupAcceptRisk === true ? { acceptRisk: true } : {}), + }, + runtime, + ); + return; + } + const runChannelsAdd = + opts.runChannelsAdd ?? (await import("../commands/channels/add.js")).channelsAddCommand; + await runChannelsAdd(handoff.channel ? { channel: handoff.channel } : {}, runtime, { + hasFlags: false, + }); +} + export async function runCrestodianTui( opts: CrestodianTuiOptions, runtime: RuntimeEnv, @@ -398,6 +447,10 @@ export async function runCrestodianTui( } continue; } + if (handoff.kind === "open-setup") { + await runSetupHandoff(handoff, opts, runtime); + return; + } const result = await executeCrestodianOperation(handoff, runtime, { approved: true, deps: opts.deps, diff --git a/src/gateway/server-methods/crestodian.ts b/src/gateway/server-methods/crestodian.ts index 662692954d5..876ef11cbaa 100644 --- a/src/gateway/server-methods/crestodian.ts +++ b/src/gateway/server-methods/crestodian.ts @@ -136,7 +136,12 @@ export const crestodianHandlers: GatewayRequestHandlers = { const reply = await session.engine.handle(params.message); // The TUI-only "open-tui" handoff becomes a client-visible "open-agent" // signal: the app should move the user to their normal agent chat. - const action = reply.action === "open-tui" ? "open-agent" : reply.action; + const action = + reply.action === "open-tui" + ? "open-agent" + : reply.action === "open-setup" + ? "none" + : reply.action; respond( true, { diff --git a/src/logging/console.ts b/src/logging/console.ts index 881c84d8399..c6a4dee4252 100644 --- a/src/logging/console.ts +++ b/src/logging/console.ts @@ -115,6 +115,19 @@ export function setConsoleSubsystemFilter(filters?: string[] | null): void { loggingState.consoleSubsystemFilter = normalized.length > 0 ? normalized : null; } +/** Hides subsystem console lines for TTY-owned work while preserving file logging. */ +export async function withConsoleSubsystemsSuppressed(work: () => Promise): Promise { + const previousFilter = loggingState.consoleSubsystemFilter + ? [...loggingState.consoleSubsystemFilter] + : null; + setConsoleSubsystemFilter(["__openclaw_tui_quiet__"]); + try { + return await work(); + } finally { + setConsoleSubsystemFilter(previousFilter); + } +} + export function setConsoleTimestampPrefix(enabled: boolean): void { loggingState.consoleTimestampPrefix = enabled; } diff --git a/src/logging/subsystem.test.ts b/src/logging/subsystem.test.ts index 8a6bca3766c..356b98c0c4a 100644 --- a/src/logging/subsystem.test.ts +++ b/src/logging/subsystem.test.ts @@ -148,6 +148,30 @@ describe("createSubsystemLogger().isEnabled", () => { expect(warn).not.toHaveBeenCalled(); }); + it("keeps setup-inference probe warnings in the file log while suppressing console", () => { + const file = logPathTracker.nextPath(); + setLoggerOverride({ level: "warn", consoleLevel: "warn", file }); + const warn = installConsoleMethodSpy("warn"); + const log = createSubsystemLogger("agent/embedded"); + + log.warn("embedded run failover decision", { + runId: "probe-setup-inference-test-run", + provider: "openai", + consoleMessage: "embedded run failover decision: provider=openai error=Authentication failed", + }); + log.warn("embedded run agent end", { + runId: "probe-setup-inference-test-run", + provider: "openai", + consoleMessage: "embedded run agent end: provider=openai error=Authentication failed", + }); + + expect(warn).not.toHaveBeenCalled(); + const fileLog = fs.readFileSync(file, "utf8"); + expect(fileLog).toContain("embedded run failover decision"); + expect(fileLog).toContain("embedded run agent end"); + expect(fileLog).toContain('"provider":"openai"'); + }); + it("does not suppress probe errors for embedded subsystems", () => { setLoggerOverride({ level: "silent", consoleLevel: "error" }); const error = installConsoleMethodSpy("error"); diff --git a/src/process/command-queue.test.ts b/src/process/command-queue.test.ts index 9e54dc2c9ea..59565891c88 100644 --- a/src/process/command-queue.test.ts +++ b/src/process/command-queue.test.ts @@ -331,6 +331,26 @@ describe("command queue", () => { ).toBe(true); }); + it.each([ + "session:probe-setup-inference:openai", + "session:temp:setup-inference:probe-setup-inference-test-uuid", + ])("keeps setup-inference probe lane failures quiet: %s", async (lane) => { + const error = new Error("Authentication failed"); + + await expect( + enqueueCommandInLane(lane, async () => { + throw error; + }), + ).rejects.toBe(error); + + expect(diagnosticMocks.diag.error).not.toHaveBeenCalled(); + expect( + diagnosticDebugMessages().some((message) => + message.includes(`lane task interrupted: lane=${lane}`), + ), + ).toBe(false); + }); + it("getActiveTaskCount returns count of currently executing tasks", async () => { const { task, release } = enqueueBlockedMainTask(); diff --git a/src/process/command-queue.ts b/src/process/command-queue.ts index d8ac9114063..63ce5a09145 100644 --- a/src/process/command-queue.ts +++ b/src/process/command-queue.ts @@ -104,6 +104,16 @@ function isExpectedNonErrorLaneFailure(err: unknown): boolean { return err instanceof Error && err.name === "LiveSessionModelSwitchError"; } +function isQuietProbeLane(lane: string): boolean { + // setup-inference.ts retains its temp session key, so its derived session lane + // needs the same expected-failure treatment as the explicit probe lane. + return ( + lane.startsWith("auth-probe:") || + lane.startsWith("session:probe-") || + lane.startsWith("session:temp:setup-inference:probe-setup-inference-") + ); +} + /** * Keep queue runtime state on globalThis so every bundled entry/chunk shares * the same lanes, counters, and draining flag in production builds. @@ -423,7 +433,7 @@ function drainLane(lane: string) { entry.resolve(result); } catch (err) { const completedCurrentGeneration = completeTask(state, taskId, taskGeneration); - const isProbeLane = lane.startsWith("auth-probe:") || lane.startsWith("session:probe-"); + const isProbeLane = isQuietProbeLane(lane); if (!isProbeLane && !isExpectedNonErrorLaneFailure(err)) { diag.error( `lane task error: lane=${lane} durationMs=${Date.now() - startTime} error="${String(err)}"`, @@ -464,7 +474,7 @@ export function isGatewayDraining(): boolean { export function setCommandLaneConcurrency(lane: string, maxConcurrent: number) { const cleaned = normalizeLane(lane); const state = getLaneState(cleaned); - const isProbeLane = cleaned.startsWith("auth-probe:") || cleaned.startsWith("session:probe-"); + const isProbeLane = isQuietProbeLane(cleaned); const minConcurrent = isProbeLane ? 1 : 0; state.maxConcurrent = Math.max(minConcurrent, Math.floor(maxConcurrent)); if (state.maxConcurrent > 0) { diff --git a/src/wizard/i18n/locales/en.ts b/src/wizard/i18n/locales/en.ts index eb77cfa8718..4364b47fbb9 100644 --- a/src/wizard/i18n/locales/en.ts +++ b/src/wizard/i18n/locales/en.ts @@ -233,6 +233,55 @@ export const en = { validWebSocketUrl: "URL must start with ws:// or wss://", websocketUrl: "Gateway WebSocket URL", }, + guided: { + aiAccessTitle: "AI access", + apiKeyPrompt: "API key or token for {label}", + appliedTitle: "Setup applied", + complete: "OpenClaw is ready.", + detected: "AI detection complete.", + detectedCandidate: "{label} — {detail}{recommended}", + detectedTitle: "AI found", + detecting: "Looking for AI you already use…", + enterApiKey: "Enter API key — {label}", + existingModelKept: + "Your configured default model was kept unchanged. Choose how to continue below — retry it, connect another provider, or exit. The check runs outside your workspace, so a workspace-plugin model can fail here while still working in the agent.", + escapeHatches: + "For the full step-by-step wizard, run `openclaw onboard --classic`. You can open plain-language setup help anytime with `openclaw crestodian`.", + failureAuth: "Authentication failed. Sign in again or check the key.", + failureBilling: "Billing is not active for this model or account.", + failureFormat: "The model did not return a usable reply.", + failureRateLimit: "The provider is rate-limiting requests. Try again shortly.", + failureTimeout: "The completion timed out.", + failureUnavailable: "This AI route is not available right now.", + failureUnknown: "The completion failed for an unknown reason.", + foundNothing: "No existing AI access was detected on this machine.", + intro: "Connect your AI", + invalidConfigCrestodian: + "OpenClaw config is invalid. Opening Crestodian to inspect and repair it before onboarding writes anything.", + manualChoice: "How would you like to connect AI?", + nextSteps: + "Workspace: {workspace}\nAdd a channel: `openclaw channels add`\nPrefer chatting? Run `openclaw crestodian` and say `connect telegram` (or `connect slack`).\nOpen the dashboard: `openclaw dashboard`\nChat later: `openclaw`", + nextStepsTitle: "Next steps", + openChatNow: "Open the chat now?", + openCrestodian: "Open Crestodian chat (help in plain language)", + recommendedSuffix: " — recommended", + repliedIn: "AI check: replied in {seconds}s", + retryCandidate: "Retry {label} ({detail})", + skipAi: "Skip AI setup for now", + skipAiLater: + "To add AI later, set OPENAI_API_KEY or ANTHROPIC_API_KEY, or install and log into codex, claude, or gemini. Then re-run `openclaw onboard`.", + testFailed: "AI check failed.", + testFailure: "✗ {label}: {reason}\n{detail}", + testPassed: "AI check passed.", + testingCandidate: "Testing {label} ({modelRef}) — real completion, not a ping…", + testingManualProvider: "Testing {label} — real completion, not a ping…", + tryCandidate: "Try {label} ({detail})", + ttyRequired: + "Onboarding needs an interactive TTY. Use `openclaw onboard --non-interactive --accept-risk ...` for automation.", + useClassic: "Use the classic step-by-step wizard", + welcomeTitle: "Setup choices", + workspace: "Workspace directory", + }, setup: { authChoiceFailedRetry: "Pick another provider or auth method, or choose Skip for now.", authChoiceFailedTitle: "Provider setup failed", @@ -285,6 +334,14 @@ export const en = { skipChannels: "Skipping channel setup.", skipSearch: "Skipping search setup.", skipSkills: "Skipping skills setup.", + testAiAccess: "Test AI access now with a live completion?", + testAiContinue: "Continue anyway", + testAiFailure: "AI access test failed: {reason}", + testAiFailureChoice: "How would you like to continue?", + testAiFix: "Fix model/auth setup", + testAiProgress: "Testing AI access with a live completion…", + testAiSuccess: "AI access works. Replied in {seconds}s.", + testAiTitle: "AI access test", whatSetup: "What do you want to set up?", workspaceDirectory: "Workspace directory", }, diff --git a/src/wizard/i18n/locales/zh-CN.ts b/src/wizard/i18n/locales/zh-CN.ts index 79cba0bd555..56cce095c79 100644 --- a/src/wizard/i18n/locales/zh-CN.ts +++ b/src/wizard/i18n/locales/zh-CN.ts @@ -230,6 +230,55 @@ export const zh_CN = { validWebSocketUrl: "URL 必须以 ws:// 或 wss:// 开头", websocketUrl: "Gateway WebSocket URL", }, + guided: { + aiAccessTitle: "AI 访问", + apiKeyPrompt: "{label} 的 API key 或 token", + appliedTitle: "设置已应用", + complete: "OpenClaw 已准备就绪。", + detected: "AI 检测完成。", + detectedCandidate: "{label} — {detail}{recommended}", + detectedTitle: "找到的 AI", + detecting: "正在查找你已使用的 AI…", + enterApiKey: "输入 API key — {label}", + existingModelKept: + "已配置的默认模型保持不变。请在下方选择如何继续——重试、连接其他提供商,或退出。此检查在工作区之外运行,因此工作区插件提供的模型可能在这里失败,但在 agent 中仍可正常工作。", + escapeHatches: + "如需完整的分步向导,请运行 `openclaw onboard --classic`。你也可以随时运行 `openclaw crestodian`,用自然语言获得设置帮助。", + failureAuth: "认证失败。请重新登录或检查 key。", + failureBilling: "此模型或账号尚未启用计费。", + failureFormat: "模型没有返回可用的回复。", + failureRateLimit: "Provider 正在限流。请稍后重试。", + failureTimeout: "Completion 超时。", + failureUnavailable: "此 AI 路由目前不可用。", + failureUnknown: "Completion 因未知原因失败。", + foundNothing: "未在此机器上检测到现有 AI 访问方式。", + intro: "连接你的 AI", + invalidConfigCrestodian: + "OpenClaw 配置无效。正在打开 Crestodian,以便在 onboarding 写入任何内容前检查并修复配置。", + manualChoice: "你想如何连接 AI?", + nextSteps: + "工作区:{workspace}\n添加频道:`openclaw channels add`\n更喜欢聊天?运行 `openclaw crestodian`,然后说 `connect telegram`(或 `connect slack`)。\n打开 dashboard:`openclaw dashboard`\n稍后聊天:`openclaw`", + nextStepsTitle: "下一步", + openChatNow: "现在打开聊天?", + openCrestodian: "打开 Crestodian 聊天(用自然语言获得帮助)", + recommendedSuffix: " — 推荐", + repliedIn: "AI 检查:在 {seconds} 秒内回复", + retryCandidate: "重试 {label}({detail})", + skipAi: "暂时跳过 AI 设置", + skipAiLater: + "稍后添加 AI:设置 OPENAI_API_KEY 或 ANTHROPIC_API_KEY,或者安装并登录 codex、claude 或 gemini。然后重新运行 `openclaw onboard`。", + testFailed: "AI 检查失败。", + testFailure: "✗ {label}:{reason}\n{detail}", + testPassed: "AI 检查通过。", + testingCandidate: "正在测试 {label}({modelRef})— 真实 completion,并非 ping…", + testingManualProvider: "正在测试 {label} — 真实 completion,并非 ping…", + tryCandidate: "尝试 {label}({detail})", + ttyRequired: + "Onboarding 需要交互式 TTY。自动化请使用 `openclaw onboard --non-interactive --accept-risk ...`。", + useClassic: "使用经典分步向导", + welcomeTitle: "设置选项", + workspace: "工作区目录", + }, setup: { authChoiceFailedRetry: "请选择其他提供商或认证方式,或选择暂时跳过。", authChoiceFailedTitle: "提供商设置失败", @@ -282,6 +331,14 @@ export const zh_CN = { skipChannels: "跳过频道设置。", skipSearch: "跳过搜索设置。", skipSkills: "跳过技能设置。", + testAiAccess: "现在用一次真实 completion 测试 AI 访问?", + testAiContinue: "仍然继续", + testAiFailure: "AI 访问测试失败:{reason}", + testAiFailureChoice: "你想如何继续?", + testAiFix: "修复模型/认证设置", + testAiProgress: "正在用真实 completion 测试 AI 访问…", + testAiSuccess: "AI 访问正常,在 {seconds} 秒内回复。", + testAiTitle: "AI 访问测试", whatSetup: "你想设置什么?", workspaceDirectory: "工作区目录", }, diff --git a/src/wizard/i18n/locales/zh-TW.ts b/src/wizard/i18n/locales/zh-TW.ts index a463a7e9713..aa73b7fdc4c 100644 --- a/src/wizard/i18n/locales/zh-TW.ts +++ b/src/wizard/i18n/locales/zh-TW.ts @@ -230,6 +230,55 @@ export const zh_TW = { validWebSocketUrl: "URL 必須以 ws:// 或 wss:// 開頭", websocketUrl: "Gateway WebSocket URL", }, + guided: { + aiAccessTitle: "AI 存取", + apiKeyPrompt: "{label} 的 API key 或 token", + appliedTitle: "設定已套用", + complete: "OpenClaw 已準備就緒。", + detected: "AI 偵測完成。", + detectedCandidate: "{label} — {detail}{recommended}", + detectedTitle: "找到的 AI", + detecting: "正在尋找你已使用的 AI…", + enterApiKey: "輸入 API key — {label}", + existingModelKept: + "已設定的預設模型保持不變。請在下方選擇如何繼續——重試、連接其他提供商,或離開。此檢查在工作區之外執行,因此工作區外掛提供的模型可能在這裡失敗,但在 agent 中仍可正常運作。", + escapeHatches: + "如需完整的分步精靈,請執行 `openclaw onboard --classic`。你也可以隨時執行 `openclaw crestodian`,用自然語言取得設定協助。", + failureAuth: "認證失敗。請重新登入或檢查 key。", + failureBilling: "此模型或帳號尚未啟用計費。", + failureFormat: "模型沒有傳回可用的回覆。", + failureRateLimit: "Provider 正在限流。請稍後重試。", + failureTimeout: "Completion 逾時。", + failureUnavailable: "此 AI 路由目前不可用。", + failureUnknown: "Completion 因未知原因失敗。", + foundNothing: "未在此機器上偵測到既有 AI 存取方式。", + intro: "連接你的 AI", + invalidConfigCrestodian: + "OpenClaw 設定無效。正在開啟 Crestodian,以便在 onboarding 寫入任何內容前檢查並修復設定。", + manualChoice: "你想如何連接 AI?", + nextSteps: + "工作區:{workspace}\n新增頻道:`openclaw channels add`\n偏好聊天?執行 `openclaw crestodian`,然後說 `connect telegram`(或 `connect slack`)。\n開啟 dashboard:`openclaw dashboard`\n稍後聊天:`openclaw`", + nextStepsTitle: "下一步", + openChatNow: "現在開啟聊天?", + openCrestodian: "開啟 Crestodian 聊天(用自然語言取得協助)", + recommendedSuffix: " — 建議", + repliedIn: "AI 檢查:在 {seconds} 秒內回覆", + retryCandidate: "重試 {label}({detail})", + skipAi: "暫時略過 AI 設定", + skipAiLater: + "稍後新增 AI:設定 OPENAI_API_KEY 或 ANTHROPIC_API_KEY,或者安裝並登入 codex、claude 或 gemini。然後重新執行 `openclaw onboard`。", + testFailed: "AI 檢查失敗。", + testFailure: "✗ {label}:{reason}\n{detail}", + testPassed: "AI 檢查通過。", + testingCandidate: "正在測試 {label}({modelRef})— 真實 completion,並非 ping…", + testingManualProvider: "正在測試 {label} — 真實 completion,並非 ping…", + tryCandidate: "嘗試 {label}({detail})", + ttyRequired: + "Onboarding 需要互動式 TTY。自動化請使用 `openclaw onboard --non-interactive --accept-risk ...`。", + useClassic: "使用經典分步精靈", + welcomeTitle: "設定選項", + workspace: "工作區目錄", + }, setup: { authChoiceFailedRetry: "請選擇其他提供商或認證方式,或選擇暫時跳過。", authChoiceFailedTitle: "提供商設定失敗", @@ -282,6 +331,14 @@ export const zh_TW = { skipChannels: "略過頻道設定。", skipSearch: "略過搜尋設定。", skipSkills: "略過技能設定。", + testAiAccess: "現在用一次真實 completion 測試 AI 存取?", + testAiContinue: "仍然繼續", + testAiFailure: "AI 存取測試失敗:{reason}", + testAiFailureChoice: "你想如何繼續?", + testAiFix: "修復模型/認證設定", + testAiProgress: "正在用真實 completion 測試 AI 存取…", + testAiSuccess: "AI 存取正常,在 {seconds} 秒內回覆。", + testAiTitle: "AI 存取測試", whatSetup: "你想設定什麼?", workspaceDirectory: "工作區目錄", }, diff --git a/src/wizard/setup.test.ts b/src/wizard/setup.test.ts index 34d5fd33ea3..bd7cd9c5c75 100644 --- a/src/wizard/setup.test.ts +++ b/src/wizard/setup.test.ts @@ -110,6 +110,11 @@ const enableDefaultOnboardingInternalHooks = vi.hoisted(() => const detectSetupMigrationSources = vi.hoisted(() => vi.fn(async () => [])); const listSetupMigrationOptions = vi.hoisted(() => vi.fn(async () => [])); const runSetupMigrationImport = vi.hoisted(() => vi.fn(async () => {})); +const verifySetupInference = vi.hoisted(() => + vi.fn<() => Promise>( + async () => ({ ok: true, modelRef: "openai/gpt-5.5", latencyMs: 250 }), + ), +); const setupChannels = vi.hoisted(() => vi.fn( @@ -283,6 +288,10 @@ vi.mock("./setup.migration-import.js", () => ({ runSetupMigrationImport, })); +vi.mock("../crestodian/setup-inference.js", () => ({ + verifySetupInference, +})); + vi.mock("../config/config.js", () => ({ DEFAULT_GATEWAY_PORT: 18789, createConfigIO, @@ -447,6 +456,12 @@ describe("runSetupWizard", () => { warnIfModelConfigLooksOff.mockResolvedValue(undefined); buildPluginCompatibilitySnapshotNotices.mockReset(); buildPluginCompatibilitySnapshotNotices.mockReturnValue([]); + verifySetupInference.mockReset(); + verifySetupInference.mockResolvedValue({ + ok: true, + modelRef: "openai/gpt-5.5", + latencyMs: 250, + }); }); it("exits successfully after the auto-launched TUI returns", async () => { @@ -1715,4 +1730,101 @@ describe("runSetupWizard", () => { "default model prompt params", ); }); + + it("offers a live AI check after classic model setup", async () => { + applyAuthChoice.mockResolvedValueOnce({ + config: { agents: { defaults: { model: { primary: "openai/gpt-5.5" } } } }, + }); + const confirm = vi.fn(async () => true) as unknown as WizardPrompter["confirm"]; + const prompter = buildWizardPrompter({ confirm }); + + await runSetupWizard( + { + acceptRisk: true, + flow: "quickstart", + authChoice: "demo-provider", + installDaemon: false, + skipChannels: true, + skipSkills: true, + skipSearch: true, + skipHealth: true, + skipUi: true, + }, + createRuntime(), + prompter, + ); + + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ message: "Test AI access now with a live completion?" }), + ); + expect(verifySetupInference).toHaveBeenCalledOnce(); + }); + + it("continues classic setup when live AI verification fails", async () => { + applyAuthChoice.mockResolvedValueOnce({ + config: { agents: { defaults: { model: { primary: "openai/gpt-5.5" } } } }, + }); + verifySetupInference.mockResolvedValueOnce({ + ok: false, + status: "auth", + error: "login expired", + }); + const select = vi.fn(async () => "continue") as unknown as WizardPrompter["select"]; + const prompter = buildWizardPrompter({ confirm: vi.fn(async () => true), select }); + + await expect( + runSetupWizard( + { + acceptRisk: true, + flow: "quickstart", + authChoice: "demo-provider", + installDaemon: false, + skipChannels: true, + skipSkills: true, + skipSearch: true, + skipHealth: true, + skipUi: true, + }, + createRuntime(), + prompter, + ), + ).resolves.toBeUndefined(); + + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ message: "How would you like to continue?" }), + ); + expect(verifySetupInference).toHaveBeenCalledOnce(); + }); + + it("re-enters model/auth setup once and re-verifies after a failed AI check", async () => { + applyAuthChoice.mockResolvedValue({ + config: { agents: { defaults: { model: { primary: "openai/gpt-5.5" } } } }, + }); + promptAuthChoiceGrouped.mockResolvedValue("demo-provider"); + verifySetupInference + .mockResolvedValueOnce({ ok: false, status: "auth", error: "login expired" }) + .mockResolvedValueOnce({ ok: true, modelRef: "openai/gpt-5.5", latencyMs: 300 }); + const select = vi.fn(async () => "fix") as unknown as WizardPrompter["select"]; + const prompter = buildWizardPrompter({ confirm: vi.fn(async () => true), select }); + + await runSetupWizard( + { + acceptRisk: true, + flow: "quickstart", + authChoice: "demo-provider", + installDaemon: false, + skipChannels: true, + skipSkills: true, + skipSearch: true, + skipHealth: true, + skipUi: true, + }, + createRuntime(), + prompter, + ); + + expect(applyAuthChoice).toHaveBeenCalledTimes(2); + expect(promptAuthChoiceGrouped).toHaveBeenCalledOnce(); + expect(verifySetupInference).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/wizard/setup.ts b/src/wizard/setup.ts index 14c52cb7eba..28751fc0626 100644 --- a/src/wizard/setup.ts +++ b/src/wizard/setup.ts @@ -6,6 +6,7 @@ import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeSecretInputString } from "../config/types.secrets.js"; import { formatErrorMessage } from "../infra/errors.js"; +import { withConsoleSubsystemsSuppressed } from "../logging/console.js"; import { buildPluginCompatibilitySnapshotNotices, formatPluginCompatibilityNotice, @@ -44,6 +45,70 @@ function hasConfiguredDefaultModel(config: OpenClawConfig): boolean { return resolveAgentModelPrimaryValue(config.agents?.defaults?.model) !== undefined; } +async function offerLiveModelVerification(params: { + config: OpenClawConfig; + opts: OnboardOptions; + prompter: WizardPrompter; + runtime: RuntimeEnv; + workspaceDir: string; + writeConfig: (config: OpenClawConfig) => Promise; +}): Promise { + const shouldTest = await params.prompter.confirm({ + message: t("wizard.setup.testAiAccess"), + initialValue: true, + }); + if (!shouldTest) { + return params.config; + } + + const { verifySetupInference } = await import("../crestodian/setup-inference.js"); + const verify = async () => { + const progress = params.prompter.progress(t("wizard.setup.testAiProgress")); + const result = await withConsoleSubsystemsSuppressed(() => + verifySetupInference({ runtime: params.runtime }), + ); + progress.stop(); + if (result.ok) { + await params.prompter.note( + t("wizard.setup.testAiSuccess", { seconds: (result.latencyMs / 1000).toFixed(1) }), + t("wizard.setup.testAiTitle"), + ); + } else { + await params.prompter.note( + t("wizard.setup.testAiFailure", { reason: result.error }), + t("wizard.setup.testAiTitle"), + ); + } + return result; + }; + + const firstResult = await verify(); + if (firstResult.ok) { + return params.config; + } + const action = await params.prompter.select({ + message: t("wizard.setup.testAiFailureChoice"), + options: [ + { value: "fix", label: t("wizard.setup.testAiFix") }, + { value: "continue", label: t("wizard.setup.testAiContinue") }, + ], + }); + if (action === "continue") { + return params.config; + } + + const fixedConfig = await runSetupModelAuthStep({ + config: params.config, + opts: { ...params.opts, authChoice: undefined }, + prompter: params.prompter, + runtime: params.runtime, + workspaceDir: params.workspaceDir, + }); + const persistedConfig = await params.writeConfig(fixedConfig); + await verify(); + return persistedConfig; +} + function isSetupImportFlowChoice(flow: SetupFlowChoice): boolean { return flow === "import" || flow.startsWith("import:"); } @@ -217,7 +282,8 @@ async function runSetupWizardOnce( ); } - if (opts.importFrom || isSetupImportFlowChoice(flow)) { + const usedImportFlow = Boolean(opts.importFrom || isSetupImportFlowChoice(flow)); + if (usedImportFlow) { const importFrom = opts.importFrom ?? resolveImportProviderFromFlowChoice(flow); prompter.disableBackNavigation?.(); await runSetupMigrationImport({ @@ -475,6 +541,23 @@ async function runSetupWizardOnce( allowConfigSizeDrop: false, }); + if ( + opts.nonInteractive !== true && + opts.authChoice !== "skip" && + !usedImportFlow && + hasConfiguredDefaultModel(nextConfig) + ) { + nextConfig = await offerLiveModelVerification({ + config: nextConfig, + opts, + prompter, + runtime, + workspaceDir, + writeConfig: async (config) => + await writeSetupConfigFile(config, { allowConfigSizeDrop: false }), + }); + } + prompter.disableBackNavigation?.(); if (opts.skipChannels ?? opts.skipProviders) { await prompter.note(t("wizard.setup.skipChannels"), t("wizard.setup.channelsTitle"));