mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
feat(onboard): guided CLI onboarding with live AI verification and classic fallback (#101880)
* feat(onboard): guided CLI onboarding with live AI verification and classic fallback Interactive `openclaw onboard` (and bare `openclaw` on a fresh install) now runs a guided flow with macOS-app parity: detect existing AI access, live-test candidates with a real completion before persisting anything, walk down the ladder on failure with mapped reasons, and offer verified manual API-key entry from installed provider manifests (masked input). In-flow escapes: classic wizard, Crestodian chat, skip-AI. Classic wizard gains an optional post-auth live verification step. `--classic`, `--modern`, and `--non-interactive` contracts unchanged. Docs corrected for post-#99935 routing. Closes #101851 * improve(onboard): quiet probe diagnostics in wizard TTY, carry risk ack into classic escape Candidate live-tests during guided setup are probes: rename their run id and lane to the existing probe conventions (logging/subsystem.ts console suppression, command-queue quiet probe lanes) so expected failures stop leaking raw diagnostics into the Clack UI; file diagnostics unchanged. The classic-wizard escape now passes the already-collected risk acknowledgement through instead of re-prompting in the same session. * fix(onboard): quiet the session-derived setup-inference probe lane too The live-test run enqueues on two lanes: the explicit probe lane and one derived from its temp session key. Extend the shared quiet-probe predicate to cover the derived lane so a failing candidate cannot leak lane-task diagnostics into the wizard TTY. * improve(onboard): suppress subsystem console output during wizard live tests Provider-transport subsystem loggers (model-fetch start/response, transport errors) carry no run id, so probe suppression cannot catch them and a failing candidate printed raw log lines into the Clack TTY. Reuse the TUI console subsystem-filter seam via a finally-safe scoped helper around guided activation and the classic live-verify; file logging is unchanged and the gateway (macOS app) surface is unaffected. * fix(onboard): never auto-replace a configured model when its live check fails The re-run verification probe executes outside the configured workspace (setup never runs workspace plugins), so a workspace-backed current model can fail the check while working fine in the agent. Stop the auto ladder on an existing-model failure and hand the decision to the manual stage instead of silently persisting a different candidate as the default. Docs note the fail-safe and the workspace caveat. * feat(onboard): two-way switching between Crestodian chat and the menu wizards From the chat, `open setup wizard`, `open classic wizard`, and `open channel wizard for <channel>` hand off to the guided flow, the classic wizard, or the masked `channels add` wizard after the chat TUI tears down (mirrors the open-tui handoff; gateway surface gets a text pointer instead). The hosted channel wizard no longer dead-ends at sensitive steps — it offers the switch and remembers the channel. New read-only `channel info <channel>` operation and ring-zero action surface label, blurb, configured state, and the real docs URL from channel-setup discovery so the assistant can explain Slack or Telegram prerequisites instead of guessing; both prompts instruct it to use them. `channels add --channel <id>` now preselects the channel. Docs cover the interchangeable flows. * fix(onboard): avoid param reassignment in open-setup handoff * improve(onboard): separate ask-about vs connect intent in channel prompt guidance Live test showed the agent detouring an explicit connect request through channel_info because the guidance said to consult it first. Both prompts now distinguish asking about a channel (channel info + docs link) from asking to connect (connect right away). * fix(channels): mark channel token entry as sensitive input The shared single-token prompt lacked sensitive:true, so terminal wizards echoed pasted channel tokens and the Crestodian chat bridge (which refuses plain-text secrets based on this flag) hosted the Telegram token step in visible chat. Found live-testing the chat-to-wizard switch; pre-existing on main but load-bearing for the masked-wizard contract this PR documents. * fix(onboard): restore terminal state around the guided flow's TUI launch Mirror the classic finalize handoff so the chat TUI never inherits the wizard prompter's raw/paused terminal state on the default first-run path. * fix(channels): type the token prompter mock for the sensitive-flag assertion * fix(gateway): map the TUI-only open-setup action to none for app clients Engine-side surface gating already prevents open-setup replies on the gateway surface; this keeps the client-visible action enum stable even if that gate ever regresses. (Reviewed with the switching round; missed in its commit.) * docs: regenerate docs map for onboarding page changes
This commit is contained in:
parent
a0267273d0
commit
e2a112a556
39 changed files with 2227 additions and 130 deletions
|
|
@ -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:
|
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 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.
|
- 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.
|
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
|
## What Crestodian shows
|
||||||
|
|
||||||
|
|
@ -72,6 +74,12 @@ create agent work workspace ~/Projects/work
|
||||||
models
|
models
|
||||||
configure model provider
|
configure model provider
|
||||||
set default model openai/gpt-5.5
|
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 list
|
||||||
plugins search slack
|
plugins search slack
|
||||||
plugin install clawhub:openclaw-codex-app-server
|
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.
|
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
|
Channel setup can run as a hosted conversation until it reaches a secret. The
|
||||||
input. The local Crestodian TUI does not accept sensitive wizard answers;
|
local Crestodian TUI does not accept sensitive wizard answers because terminal
|
||||||
instead it directs you to `openclaw channels add --channel <channel>`, whose
|
chat input is visible. It offers `open channel wizard` immediately, carrying
|
||||||
interactive prompts mask credentials.
|
the selected channel into the masked terminal wizard; you can also run
|
||||||
|
`openclaw channels add --channel <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 <channel>` opens masked channel
|
||||||
|
setup after the chat TUI closes. Use `channel info <channel>` 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
|
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,
|
`openclaw onboard`. In the local Crestodian TUI, approval exits the chat shell,
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,10 @@ title: "Onboard"
|
||||||
|
|
||||||
# `openclaw 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.
|
||||||
|
|
||||||
<CardGroup cols={2}>
|
<CardGroup cols={2}>
|
||||||
<Card title="CLI onboarding hub" href="/start/wizard" icon="rocket">
|
<Card title="CLI onboarding hub" href="/start/wizard" icon="rocket">
|
||||||
|
|
@ -31,6 +34,7 @@ Guided setup for model auth, workspace, gateway, channels, skills, and health in
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
openclaw onboard
|
openclaw onboard
|
||||||
|
openclaw onboard --classic
|
||||||
openclaw onboard --modern
|
openclaw onboard --modern
|
||||||
openclaw onboard --flow quickstart
|
openclaw onboard --flow quickstart
|
||||||
openclaw onboard --flow manual
|
openclaw onboard --flow manual
|
||||||
|
|
@ -40,16 +44,52 @@ openclaw onboard --skip-bootstrap
|
||||||
openclaw onboard --mode remote --remote-url wss://gateway-host:18789
|
openclaw onboard --mode remote --remote-url wss://gateway-host:18789
|
||||||
```
|
```
|
||||||
|
|
||||||
- `--flow quickstart`: minimal prompts, auto-generates a gateway token.
|
- `--classic`: opens the full step-by-step wizard.
|
||||||
- `--flow manual` (alias `advanced`): full prompts for port, bind, and auth.
|
- `--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.
|
- `--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 <channel>` 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
|
In an interactive terminal, bare `openclaw` (no subcommand) routes by config
|
||||||
state:
|
state:
|
||||||
|
|
||||||
- If the active config file is missing or has no authored settings (empty or
|
- 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
|
- If the config file exists but fails validation, it starts
|
||||||
[Crestodian](/cli/crestodian) for repair.
|
[Crestodian](/cli/crestodian) for repair.
|
||||||
- If the config file is valid, it opens the normal agent TUI, either locally
|
- If the config file is valid, it opens the normal agent TUI, either locally
|
||||||
|
|
|
||||||
|
|
@ -1372,6 +1372,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
||||||
- H2: What Crestodian shows
|
- H2: What Crestodian shows
|
||||||
- H2: Examples
|
- H2: Examples
|
||||||
- H2: Operations and approval
|
- H2: Operations and approval
|
||||||
|
- H3: Switching to the menu wizards
|
||||||
- H2: Setup bootstrap
|
- H2: Setup bootstrap
|
||||||
- H2: AI conversation
|
- H2: AI conversation
|
||||||
- H3: CLI harness trust model
|
- H3: CLI harness trust model
|
||||||
|
|
@ -1730,6 +1731,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
||||||
- Headings:
|
- Headings:
|
||||||
- H1: openclaw onboard
|
- H1: openclaw onboard
|
||||||
- H2: Examples
|
- H2: Examples
|
||||||
|
- H2: Guided flow
|
||||||
- H2: Reset
|
- H2: Reset
|
||||||
- H2: Locale
|
- H2: Locale
|
||||||
- H2: Non-interactive setup
|
- H2: Non-interactive setup
|
||||||
|
|
@ -8961,8 +8963,9 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
||||||
- Route: /start/wizard
|
- Route: /start/wizard
|
||||||
- Headings:
|
- Headings:
|
||||||
- H2: Locale
|
- H2: Locale
|
||||||
- H2: QuickStart vs Advanced
|
- H2: Guided default
|
||||||
- H2: What onboarding configures
|
- H2: Classic wizard: QuickStart vs Advanced
|
||||||
|
- H2: What classic onboarding configures
|
||||||
- H2: Add another agent
|
- H2: Add another agent
|
||||||
- H2: Full reference
|
- H2: Full reference
|
||||||
- H2: Related docs
|
- H2: Related docs
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,16 @@ title: "Onboarding overview"
|
||||||
sidebarTitle: "Onboarding Overview"
|
sidebarTitle: "Onboarding Overview"
|
||||||
---
|
---
|
||||||
|
|
||||||
OpenClaw has two onboarding paths. Both configure auth, the Gateway, and
|
OpenClaw has terminal and macOS app onboarding. Both can detect existing AI
|
||||||
optional chat channels — they just differ in how you interact with the setup.
|
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?
|
## Which path should I use?
|
||||||
|
|
||||||
| | CLI onboarding | macOS app onboarding |
|
| | CLI onboarding | macOS app onboarding |
|
||||||
| -------------- | -------------------------------------- | --------------------------- |
|
| -------------- | -------------------------------------- | --------------------------- |
|
||||||
| **Platforms** | macOS, Linux, Windows (native or WSL2) | macOS only |
|
| **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 |
|
| **Best for** | Servers, headless, full control | Desktop Mac, visual setup |
|
||||||
| **Automation** | `--non-interactive` for scripts | Manual only |
|
| **Automation** | `--non-interactive` for scripts | Manual only |
|
||||||
| **Command** | `openclaw onboard` | Launch the app |
|
| **Command** | `openclaw onboard` | Launch the app |
|
||||||
|
|
@ -25,15 +26,19 @@ you the most control.
|
||||||
|
|
||||||
## What onboarding configures
|
## 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
|
2. **Workspace** — directory for agent files, bootstrap templates, and memory
|
||||||
3. **Gateway** — port, bind address, auth mode
|
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,
|
Discord, Feishu, Google Chat, iMessage, Mattermost, Microsoft Teams,
|
||||||
Telegram, WhatsApp, and more
|
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
|
## CLI onboarding
|
||||||
|
|
||||||
|
|
@ -43,7 +48,20 @@ Run in any terminal:
|
||||||
openclaw onboard
|
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)
|
Full reference: [Onboarding (CLI)](/start/wizard)
|
||||||
CLI command docs: [`openclaw onboard`](/cli/onboard)
|
CLI command docs: [`openclaw onboard`](/cli/onboard)
|
||||||
|
|
@ -62,8 +80,8 @@ Full reference: [Onboarding (macOS App)](/start/onboarding)
|
||||||
|
|
||||||
## Custom or unlisted providers
|
## Custom or unlisted providers
|
||||||
|
|
||||||
If your provider is not listed in onboarding, choose **Custom Provider** and
|
If your provider is not listed, open the classic wizard, choose **Custom
|
||||||
enter:
|
Provider**, and enter:
|
||||||
|
|
||||||
- Endpoint compatibility: OpenAI-compatible (`/chat/completions`), OpenAI Responses-compatible (`/responses`), Anthropic-compatible (`/messages`), or unknown (probes all three and auto-detects)
|
- 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)
|
- Base URL and API key (API key is optional if the endpoint does not require one)
|
||||||
|
|
|
||||||
|
|
@ -12,19 +12,25 @@ openclaw onboard
|
||||||
```
|
```
|
||||||
|
|
||||||
CLI onboarding is the recommended terminal setup path on macOS, Linux, and
|
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
|
Windows (native or WSL2). By default it detects AI access already available on
|
||||||
remote Gateway), plus channels, skills, and workspace defaults in one guided
|
the machine, verifies it with a real completion, and configures a workspace and
|
||||||
flow. `openclaw setup` runs the same flow ([Setup](/cli/setup) covers the
|
local Gateway. `openclaw setup` runs the same flow ([Setup](/cli/setup) covers
|
||||||
`--baseline` config-only variant). Windows desktop users can also start from
|
the `--baseline` config-only variant). Windows desktop users can also start
|
||||||
[Windows Hub](/platforms/windows).
|
from [Windows Hub](/platforms/windows).
|
||||||
|
|
||||||
Provider sign-in, channel pairing, daemon install, and skill downloads can
|
The guided flow offers the classic wizard for provider sign-in, remote Gateway
|
||||||
extend a quick setup; optional steps can be skipped and revisited later with
|
setup, channel pairing, daemon controls, skills, and imports. You can also open
|
||||||
`openclaw configure`.
|
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 <channel>` to switch
|
||||||
|
back. Channel setup that needs secrets always continues in a masked terminal
|
||||||
|
wizard.
|
||||||
|
|
||||||
<Info>
|
<Info>
|
||||||
Fastest first chat: skip channel setup entirely. Run `openclaw dashboard` and
|
Fastest first chat: finish guided setup, run `openclaw dashboard`, and chat in
|
||||||
chat in the browser through the Control UI. Docs: [Dashboard](/web/dashboard).
|
the browser through the Control UI. Docs: [Dashboard](/web/dashboard).
|
||||||
</Info>
|
</Info>
|
||||||
|
|
||||||
## Locale
|
## Locale
|
||||||
|
|
@ -52,18 +58,41 @@ openclaw agents add <name>
|
||||||
</Note>
|
</Note>
|
||||||
|
|
||||||
<Tip>
|
<Tip>
|
||||||
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
|
DuckDuckGo, Exa, Firecrawl, Gemini, Grok, Kimi, MiniMax Search, Ollama Web
|
||||||
Search, Perplexity, SearXNG, or Tavily. Some need an API key; others are
|
Search, Perplexity, SearXNG, or Tavily. Some need an API key; others are
|
||||||
key-free. Configure this later with `openclaw configure --section web`. Docs:
|
key-free. Configure this later with `openclaw configure --section web`. Docs:
|
||||||
[Web tools](/tools/web).
|
[Web tools](/tools/web).
|
||||||
</Tip>
|
</Tip>
|
||||||
|
|
||||||
## QuickStart vs Advanced
|
## Guided default
|
||||||
|
|
||||||
Onboarding opens with a choice between **QuickStart** (defaults) and
|
Plain `openclaw onboard` follows this path:
|
||||||
**Advanced** (full control). Pass `--flow quickstart` or `--flow advanced`
|
|
||||||
(alias `manual`) to skip the prompt.
|
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.
|
||||||
|
|
||||||
<Tabs>
|
<Tabs>
|
||||||
<Tab title="QuickStart (defaults)">
|
<Tab title="QuickStart (defaults)">
|
||||||
|
|
@ -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
|
configures this machine to connect to a Gateway elsewhere and never installs
|
||||||
or changes anything on the remote host.
|
or changes anything on the remote host.
|
||||||
|
|
||||||
## What onboarding configures
|
## What classic onboarding configures
|
||||||
|
|
||||||
Local mode (default) walks through these steps:
|
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
|
instead of plaintext API key values; the referenced env var must already
|
||||||
be set, or onboarding fails fast. Interactive secret reference mode can
|
be set, or onboarding fails fast. Interactive secret reference mode can
|
||||||
point at an environment variable or a configured provider ref (`file` or
|
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.
|
2. **Workspace** - directory for agent files (default `~/.openclaw/workspace`). Seeds bootstrap files.
|
||||||
3. **Gateway** - port, bind address, auth mode, Tailscale exposure. In
|
3. **Gateway** - port, bind address, auth mode, Tailscale exposure. In
|
||||||
interactive token mode, choose plaintext token storage (default) or opt
|
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.
|
`openclaw doctor` first.
|
||||||
</Note>
|
</Note>
|
||||||
|
|
||||||
`--flow import` runs a detected migration flow (for example Hermes) instead of
|
`--flow import` runs a detected migration flow (for example Hermes) in the
|
||||||
fresh setup; see [Migrate](/cli/migrate) and the migration guides under
|
classic wizard instead of fresh setup; see [Migrate](/cli/migrate) and the migration guides under
|
||||||
[Install](/install/migrating-hermes). `openclaw onboard --modern` starts
|
[Install](/install/migrating-hermes). `openclaw onboard --modern` starts
|
||||||
[Crestodian](/cli/crestodian), a conversational setup/repair assistant, in
|
[Crestodian](/cli/crestodian), a conversational setup/repair assistant.
|
||||||
place of the classic wizard.
|
`openclaw crestodian` opens the same assistant directly.
|
||||||
|
|
||||||
## Add another agent
|
## Add another agent
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,13 @@ describe("crestodian tool", () => {
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
expect.objectContaining({ approved: false }),
|
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 () => {
|
it("refuses mutating actions without the approved assertion", async () => {
|
||||||
|
|
@ -246,6 +253,18 @@ describe("crestodian tool", () => {
|
||||||
expect(toolText(open)).toContain("directive:");
|
expect(toolText(open)).toContain("directive:");
|
||||||
expect(directiveRef.current).toEqual({ kind: "open-tui", agentId: "work" });
|
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.
|
// Directives are host handoffs, never operation executions.
|
||||||
expect(mocks.executeCrestodianOperation).not.toHaveBeenCalled();
|
expect(mocks.executeCrestodianOperation).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
@ -269,6 +288,12 @@ describe("crestodian tool", () => {
|
||||||
resultText: "directive: the host now starts masked model-provider setup.",
|
resultText: "directive: the host now starts masked model-provider setup.",
|
||||||
}),
|
}),
|
||||||
).toEqual({ kind: "model-setup", workspace: "/tmp/work" });
|
).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.
|
// Non-directive results and other actions never mirror.
|
||||||
expect(
|
expect(
|
||||||
resolveCrestodianDirectiveTransition({ args: { action: "status" }, resultText: "ok" }),
|
resolveCrestodianDirectiveTransition({ args: { action: "status" }, resultText: "ok" }),
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,8 @@ export type CrestodianToolOptions = {
|
||||||
export type CrestodianToolDirective =
|
export type CrestodianToolDirective =
|
||||||
| { kind: "channel-setup"; channel: string }
|
| { kind: "channel-setup"; channel: string }
|
||||||
| { kind: "model-setup"; workspace?: string }
|
| { kind: "model-setup"; workspace?: string }
|
||||||
| { kind: "open-tui"; agentId?: string; workspace?: string };
|
| { kind: "open-tui"; agentId?: string; workspace?: string }
|
||||||
|
| Extract<CrestodianOperation, { kind: "open-setup" }>;
|
||||||
|
|
||||||
/** Canonical operation fingerprint used to bind "yes" to one exact mutation. */
|
/** Canonical operation fingerprint used to bind "yes" to one exact mutation. */
|
||||||
export function hashCrestodianOperation(operation: CrestodianOperation): string {
|
export function hashCrestodianOperation(operation: CrestodianOperation): string {
|
||||||
|
|
@ -90,6 +91,9 @@ function directiveForOperation(operation: CrestodianOperation): CrestodianToolDi
|
||||||
...(operation.workspace ? { workspace: operation.workspace } : {}),
|
...(operation.workspace ? { workspace: operation.workspace } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (operation.kind === "open-setup") {
|
||||||
|
return operation;
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -127,6 +131,7 @@ const CRESTODIAN_TOOL_ACTIONS = [
|
||||||
"models",
|
"models",
|
||||||
"agents",
|
"agents",
|
||||||
"channels",
|
"channels",
|
||||||
|
"channel_info",
|
||||||
"audit",
|
"audit",
|
||||||
"validate_config",
|
"validate_config",
|
||||||
"doctor",
|
"doctor",
|
||||||
|
|
@ -138,6 +143,7 @@ const CRESTODIAN_TOOL_ACTIONS = [
|
||||||
"connect_channel",
|
"connect_channel",
|
||||||
"configure_model_provider",
|
"configure_model_provider",
|
||||||
"open_agent",
|
"open_agent",
|
||||||
|
"open_setup",
|
||||||
// Mutating actions below require approved=true.
|
// Mutating actions below require approved=true.
|
||||||
"setup",
|
"setup",
|
||||||
"set_default_model",
|
"set_default_model",
|
||||||
|
|
@ -161,7 +167,14 @@ const CrestodianToolSchema = Type.Object({
|
||||||
workspace: Type.Optional(Type.String({ description: "Workspace directory" })),
|
workspace: Type.Optional(Type.String({ description: "Workspace directory" })),
|
||||||
agentId: Type.Optional(Type.String({ description: "Agent id for create_agent/open_agent" })),
|
agentId: Type.Optional(Type.String({ description: "Agent id for create_agent/open_agent" })),
|
||||||
channel: Type.Optional(
|
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" })),
|
query: Type.Optional(Type.String({ description: "Search query for plugin_search" })),
|
||||||
spec: Type.Optional(Type.String({ description: "npm/clawhub spec for plugin_install" })),
|
spec: Type.Optional(Type.String({ description: "npm/clawhub spec for plugin_install" })),
|
||||||
|
|
@ -194,6 +207,14 @@ function requireParam(params: Record<string, unknown>, name: string): string {
|
||||||
return value.trim();
|
return value.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readSetupTarget(params: Record<string, unknown>): "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<string, unknown>): CrestodianOperation {
|
function operationForAction(params: Record<string, unknown>): CrestodianOperation {
|
||||||
const action = readStringParam(params, "action", { required: true });
|
const action = readStringParam(params, "action", { required: true });
|
||||||
switch (action) {
|
switch (action) {
|
||||||
|
|
@ -205,6 +226,8 @@ function operationForAction(params: Record<string, unknown>): CrestodianOperatio
|
||||||
return { kind: "agents" };
|
return { kind: "agents" };
|
||||||
case "channels":
|
case "channels":
|
||||||
return { kind: "channel-list" };
|
return { kind: "channel-list" };
|
||||||
|
case "channel_info":
|
||||||
|
return { kind: "channel-info", channel: requireParam(params, "channel").toLowerCase() };
|
||||||
case "audit":
|
case "audit":
|
||||||
return { kind: "audit" };
|
return { kind: "audit" };
|
||||||
case "validate_config":
|
case "validate_config":
|
||||||
|
|
@ -236,6 +259,15 @@ function operationForAction(params: Record<string, unknown>): CrestodianOperatio
|
||||||
...(workspace ? { workspace } : {}),
|
...(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":
|
case "gateway_start":
|
||||||
return { kind: "gateway-start" };
|
return { kind: "gateway-start" };
|
||||||
case "gateway_stop":
|
case "gateway_stop":
|
||||||
|
|
@ -313,8 +345,8 @@ export function createCrestodianTool(options: CrestodianToolOptions): AnyAgentTo
|
||||||
name: "crestodian",
|
name: "crestodian",
|
||||||
label: "Crestodian",
|
label: "Crestodian",
|
||||||
description: [
|
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.",
|
"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; configure_model_provider starts masked provider/default-model setup; open_agent hands the user to their normal agent. These interactive handoffs 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.",
|
"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.",
|
"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.",
|
"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.`
|
? `${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"
|
: 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 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.`,
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -158,7 +158,9 @@ function createTokenPrompter(params: { confirms: boolean[]; texts: string[] }) {
|
||||||
const texts = [...params.texts];
|
const texts = [...params.texts];
|
||||||
return {
|
return {
|
||||||
confirm: vi.fn(async () => confirms.shift() ?? true),
|
confirm: vi.fn(async () => confirms.shift() ?? true),
|
||||||
text: vi.fn(async () => texts.shift() ?? ""),
|
text: vi.fn<(textParams: { sensitive?: boolean }) => Promise<string>>(
|
||||||
|
async () => texts.shift() ?? "",
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -519,6 +521,11 @@ describe("promptSingleChannelToken", () => {
|
||||||
});
|
});
|
||||||
expect(result).toEqual(expected);
|
expect(result).toEqual(expected);
|
||||||
expect(prompter.text).toHaveBeenCalledTimes(expectTextCalls);
|
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 });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -982,6 +982,9 @@ export async function promptSingleChannelToken(params: {
|
||||||
(
|
(
|
||||||
await params.prompter.text({
|
await params.prompter.text({
|
||||||
message: params.inputPrompt,
|
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"),
|
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||||
})
|
})
|
||||||
).trim();
|
).trim();
|
||||||
|
|
|
||||||
|
|
@ -171,11 +171,7 @@ export function registerOnboardCommand(program: Command): void {
|
||||||
)
|
)
|
||||||
.option("--reset-scope <scope>", "Reset scope: config|config+creds+sessions|full")
|
.option("--reset-scope <scope>", "Reset scope: config|config+creds+sessions|full")
|
||||||
.option("--non-interactive", "Run without prompts", false)
|
.option("--non-interactive", "Run without prompts", false)
|
||||||
.option(
|
.option("--modern", "Open the Crestodian setup chat (kept for compatibility)", false)
|
||||||
"--modern",
|
|
||||||
"Alias for the default bootstrap onboarding (kept for compatibility)",
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
.option("--classic", "Use the classic multi-step setup wizard", false)
|
.option("--classic", "Use the classic multi-step setup wizard", false)
|
||||||
.option(
|
.option(
|
||||||
"--accept-risk",
|
"--accept-risk",
|
||||||
|
|
|
||||||
|
|
@ -69,9 +69,15 @@ vi.mock("../channels/plugins/catalog.js", () => ({
|
||||||
listRawChannelPluginCatalogEntries: catalogMocks.listChannelPluginCatalogEntries,
|
listRawChannelPluginCatalogEntries: catalogMocks.listChannelPluginCatalogEntries,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./channel-setup/discovery.js", () => ({
|
vi.mock("./channel-setup/discovery.js", async () => {
|
||||||
isCatalogChannelInstalled: discoveryMocks.isCatalogChannelInstalled,
|
const actual = await vi.importActual<typeof import("./channel-setup/discovery.js")>(
|
||||||
}));
|
"./channel-setup/discovery.js",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
isCatalogChannelInstalled: discoveryMocks.isCatalogChannelInstalled,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock("../channels/plugins/bundled.js", async () => {
|
vi.mock("../channels/plugins/bundled.js", async () => {
|
||||||
const actual = await vi.importActual<typeof import("../channels/plugins/bundled.js")>(
|
const actual = await vi.importActual<typeof import("../channels/plugins/bundled.js")>(
|
||||||
|
|
@ -470,6 +476,22 @@ describe("channelsAddCommand", () => {
|
||||||
expect(channelWizardMocks.prompter.outro).toHaveBeenCalledWith("No channel changes made.");
|
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 () => {
|
it("exits quietly when guided channel setup is cancelled", async () => {
|
||||||
const { WizardCancelledError } = await import("../wizard/prompts.js");
|
const { WizardCancelledError } = await import("../wizard/prompts.js");
|
||||||
configMocks.readConfigFileSnapshot.mockResolvedValue({
|
configMocks.readConfigFileSnapshot.mockResolvedValue({
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,32 @@ async function resolveCatalogChannelEntry(raw: string, cfg: OpenClawConfig | nul
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveInitialWizardChannel(
|
||||||
|
raw: string,
|
||||||
|
cfg: OpenClawConfig,
|
||||||
|
): Promise<ChannelChoice | undefined> {
|
||||||
|
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 {
|
function parseOptionalInt(value: unknown, flag: string): number | undefined {
|
||||||
if (value === undefined || value === null || value === "") {
|
if (value === undefined || value === null || value === "") {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
@ -167,8 +193,10 @@ async function channelsAddCommandImpl(
|
||||||
let selection: ChannelChoice[] = [];
|
let selection: ChannelChoice[] = [];
|
||||||
const accountIds: Partial<Record<ChannelChoice, string>> = {};
|
const accountIds: Partial<Record<ChannelChoice, string>> = {};
|
||||||
const resolvedPlugins = new Map<ChannelChoice, ChannelSetupPlugin>();
|
const resolvedPlugins = new Map<ChannelChoice, ChannelSetupPlugin>();
|
||||||
|
const initialChannel = await resolveInitialWizardChannel(opts.channel ?? "", cfg);
|
||||||
await prompter.intro("Channel setup");
|
await prompter.intro("Channel setup");
|
||||||
let nextConfigLocal = await onboardChannels.setupChannels(cfg, runtime, prompter, {
|
let nextConfigLocal = await onboardChannels.setupChannels(cfg, runtime, prompter, {
|
||||||
|
...(initialChannel ? { initialSelection: [initialChannel] } : {}),
|
||||||
allowDisable: false,
|
allowDisable: false,
|
||||||
allowIMessageInstall: true,
|
allowIMessageInstall: true,
|
||||||
allowSignalInstall: true,
|
allowSignalInstall: true,
|
||||||
|
|
|
||||||
427
src/commands/onboard-guided.test.ts
Normal file
427
src/commands/onboard-guided.test.ts
Normal file
|
|
@ -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<typeof import("./onboard-interactive-runner.js")>();
|
||||||
|
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<Awaited<ReturnType<NonNullable<GuidedOnboardingDeps["detect"]>>>> = {},
|
||||||
|
) {
|
||||||
|
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<NonNullable<GuidedOnboardingDeps["applySetup"]>>(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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<NonNullable<GuidedOnboardingDeps["applySetup"]>>(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();
|
||||||
|
});
|
||||||
|
});
|
||||||
412
src/commands/onboard-guided.ts
Normal file
412
src/commands/onboard-guided.ts
Normal file
|
|
@ -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<void>;
|
||||||
|
runCrestodianChat?: (
|
||||||
|
workspace: string,
|
||||||
|
runtime: RuntimeEnv,
|
||||||
|
acceptRisk: boolean,
|
||||||
|
) => Promise<void>;
|
||||||
|
applySetup?: (params: CrestodianSetupApplyParams) => Promise<CrestodianSetupApplyResult>;
|
||||||
|
createPrompter?: () => WizardPrompter | Promise<WizardPrompter>;
|
||||||
|
launchTui?: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GuidedSetupResult = { kind: "complete"; lines: string[] } | { kind: "delegated" };
|
||||||
|
|
||||||
|
type CandidateAttempt =
|
||||||
|
| { kind: "success"; result: Extract<ActivateSetupInferenceResult, { ok: true }> }
|
||||||
|
| { 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<void> {
|
||||||
|
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<SetupInferenceStatus, string> = {
|
||||||
|
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<ActivateSetupInferenceResult, { ok: false }>;
|
||||||
|
}): Promise<void> {
|
||||||
|
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<CandidateAttempt> {
|
||||||
|
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<SetupInferenceCandidate["kind"]>;
|
||||||
|
opts: OnboardOptions;
|
||||||
|
workspace: string;
|
||||||
|
runtime: RuntimeEnv;
|
||||||
|
prompter: WizardPrompter;
|
||||||
|
deps: GuidedOnboardingDeps;
|
||||||
|
activate: ActivateSetupInference;
|
||||||
|
}): Promise<GuidedSetupResult> {
|
||||||
|
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<ActivateSetupInferenceResult, { ok: true }>): string[] {
|
||||||
|
return [
|
||||||
|
...result.lines,
|
||||||
|
t("wizard.guided.repliedIn", { seconds: (result.latencyMs / 1000).toFixed(1) }),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runGuidedOnboardingFlow(
|
||||||
|
opts: OnboardOptions,
|
||||||
|
runtime: RuntimeEnv,
|
||||||
|
deps: GuidedOnboardingDeps,
|
||||||
|
): Promise<void> {
|
||||||
|
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<SetupInferenceCandidate["kind"]>();
|
||||||
|
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<void> {
|
||||||
|
if (!hasInteractiveOnboardingTty()) {
|
||||||
|
runtime.error(t("wizard.guided.ttyRequired"));
|
||||||
|
runtime.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await runInteractiveOnboarding(
|
||||||
|
async () => await runGuidedOnboardingFlow(opts, runtime, deps),
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
}
|
||||||
30
src/commands/onboard-interactive-runner.ts
Normal file
30
src/commands/onboard-interactive-runner.ts
Normal file
|
|
@ -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<void>,
|
||||||
|
runtime: RuntimeEnv,
|
||||||
|
): Promise<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,12 +4,14 @@
|
||||||
* It wires the Clack prompter to the setup wizard and restores terminal state
|
* It wires the Clack prompter to the setup wizard and restores terminal state
|
||||||
* on every exit path so canceled setup cannot leave stdin paused.
|
* 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 type { RuntimeEnv } from "../runtime.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { createClackPrompter } from "../wizard/clack-prompter.js";
|
import { createClackPrompter } from "../wizard/clack-prompter.js";
|
||||||
import { WizardCancelledError } from "../wizard/prompts.js";
|
|
||||||
import { runSetupWizard } from "../wizard/setup.js";
|
import { runSetupWizard } from "../wizard/setup.js";
|
||||||
|
import {
|
||||||
|
hasInteractiveOnboardingTty,
|
||||||
|
runInteractiveOnboarding,
|
||||||
|
} from "./onboard-interactive-runner.js";
|
||||||
import type { OnboardOptions } from "./onboard-types.js";
|
import type { OnboardOptions } from "./onboard-types.js";
|
||||||
|
|
||||||
/** Runs the interactive setup wizard and maps user cancellation to exit code 1. */
|
/** Runs the interactive setup wizard and maps user cancellation to exit code 1. */
|
||||||
|
|
@ -18,36 +20,22 @@ export async function runInteractiveSetup(
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
) {
|
) {
|
||||||
const prompter = createClackPrompter();
|
const prompter = createClackPrompter();
|
||||||
let exitCode: number | null = null;
|
await runInteractiveOnboarding(
|
||||||
try {
|
async () => await runSetupWizard(opts, runtime, prompter),
|
||||||
await runSetupWizard(opts, runtime, prompter);
|
runtime,
|
||||||
} 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default interactive onboarding: no step wizard, just the Crestodian
|
* Opens the Crestodian onboarding conversation used by the guided escape hatch.
|
||||||
* conversation. The first-run greeting proposes a full setup plan (detected
|
* The first-run greeting proposes a setup plan and keeps subsequent setup and
|
||||||
* inference, workspace, gateway) and a plain "yes" applies it; channels and
|
* agent handoff in the same conversation.
|
||||||
* the agent handoff continue in the same conversation.
|
|
||||||
*/
|
*/
|
||||||
export async function runConversationalOnboarding(
|
export async function runConversationalOnboarding(
|
||||||
opts: OnboardOptions,
|
opts: OnboardOptions,
|
||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
) {
|
) {
|
||||||
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
if (!hasInteractiveOnboardingTty()) {
|
||||||
runtime.error(
|
runtime.error(
|
||||||
"Onboarding needs an interactive TTY. Use `openclaw onboard --non-interactive --accept-risk ...` for automation.",
|
"Onboarding needs an interactive TTY. Use `openclaw onboard --non-interactive --accept-risk ...` for automation.",
|
||||||
);
|
);
|
||||||
|
|
@ -59,6 +47,7 @@ export async function runConversationalOnboarding(
|
||||||
{
|
{
|
||||||
welcomeVariant: "onboarding",
|
welcomeVariant: "onboarding",
|
||||||
...(opts.workspace ? { setupWorkspace: opts.workspace } : {}),
|
...(opts.workspace ? { setupWorkspace: opts.workspace } : {}),
|
||||||
|
...(opts.acceptRisk === true ? { setupAcceptRisk: true } : {}),
|
||||||
},
|
},
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ export type OnboardOptions = OnboardDynamicProviderOptions & {
|
||||||
mode?: OnboardMode;
|
mode?: OnboardMode;
|
||||||
/** "manual" is an alias for "advanced". */
|
/** "manual" is an alias for "advanced". */
|
||||||
flow?: "quickstart" | "advanced" | "manual" | "import";
|
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;
|
classic?: boolean;
|
||||||
workspace?: string;
|
workspace?: string;
|
||||||
nonInteractive?: boolean;
|
nonInteractive?: boolean;
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { onboardCommand, setupWizardCommand } from "./onboard.js";
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
runInteractiveSetup: vi.fn(async () => {}),
|
runInteractiveSetup: vi.fn(async () => {}),
|
||||||
runConversationalOnboarding: vi.fn(async () => {}),
|
runGuidedOnboarding: vi.fn(async () => {}),
|
||||||
runNonInteractiveSetup: vi.fn(async () => {}),
|
runNonInteractiveSetup: vi.fn(async () => {}),
|
||||||
readConfigFileSnapshot: vi.fn(async () => ({ exists: false, valid: false, config: {} })),
|
readConfigFileSnapshot: vi.fn(async () => ({ exists: false, valid: false, config: {} })),
|
||||||
handleReset: vi.fn(async () => {}),
|
handleReset: vi.fn(async () => {}),
|
||||||
|
|
@ -15,7 +15,10 @@ const mocks = vi.hoisted(() => ({
|
||||||
|
|
||||||
vi.mock("./onboard-interactive.js", () => ({
|
vi.mock("./onboard-interactive.js", () => ({
|
||||||
runInteractiveSetup: mocks.runInteractiveSetup,
|
runInteractiveSetup: mocks.runInteractiveSetup,
|
||||||
runConversationalOnboarding: mocks.runConversationalOnboarding,
|
}));
|
||||||
|
|
||||||
|
vi.mock("./onboard-guided.js", () => ({
|
||||||
|
runGuidedOnboarding: mocks.runGuidedOnboarding,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./onboard-non-interactive.js", () => ({
|
vi.mock("./onboard-non-interactive.js", () => ({
|
||||||
|
|
@ -179,7 +182,7 @@ describe("setupWizardCommand", () => {
|
||||||
expect(onboardCommand).toBe(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();
|
const runtime = makeRuntime();
|
||||||
|
|
||||||
// Unset Commander booleans arrive as false and must not force classic.
|
// Unset Commander booleans arrive as false and must not force classic.
|
||||||
|
|
@ -188,7 +191,7 @@ describe("setupWizardCommand", () => {
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mocks.runConversationalOnboarding).toHaveBeenCalledOnce();
|
expect(mocks.runGuidedOnboarding).toHaveBeenCalledOnce();
|
||||||
expect(mocks.runInteractiveSetup).not.toHaveBeenCalled();
|
expect(mocks.runInteractiveSetup).not.toHaveBeenCalled();
|
||||||
expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled();
|
expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
@ -211,7 +214,7 @@ describe("setupWizardCommand", () => {
|
||||||
await setupWizardCommand(opts, runtime);
|
await setupWizardCommand(opts, runtime);
|
||||||
|
|
||||||
expect(mocks.runInteractiveSetup).toHaveBeenCalledOnce();
|
expect(mocks.runInteractiveSetup).toHaveBeenCalledOnce();
|
||||||
expect(mocks.runConversationalOnboarding).not.toHaveBeenCalled();
|
expect(mocks.runGuidedOnboarding).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps non-interactive routing unchanged", async () => {
|
it("keeps non-interactive routing unchanged", async () => {
|
||||||
|
|
@ -220,7 +223,7 @@ describe("setupWizardCommand", () => {
|
||||||
await setupWizardCommand({ nonInteractive: true, acceptRisk: true }, runtime);
|
await setupWizardCommand({ nonInteractive: true, acceptRisk: true }, runtime);
|
||||||
|
|
||||||
expect(mocks.runNonInteractiveSetup).toHaveBeenCalledOnce();
|
expect(mocks.runNonInteractiveSetup).toHaveBeenCalledOnce();
|
||||||
expect(mocks.runConversationalOnboarding).not.toHaveBeenCalled();
|
expect(mocks.runGuidedOnboarding).not.toHaveBeenCalled();
|
||||||
expect(mocks.runInteractiveSetup).not.toHaveBeenCalled();
|
expect(mocks.runInteractiveSetup).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -16,23 +16,24 @@ import {
|
||||||
normalizeLegacyOnboardAuthChoice,
|
normalizeLegacyOnboardAuthChoice,
|
||||||
resolveDeprecatedAuthChoiceReplacement,
|
resolveDeprecatedAuthChoiceReplacement,
|
||||||
} from "./auth-choice-legacy.js";
|
} from "./auth-choice-legacy.js";
|
||||||
|
import { runGuidedOnboarding } from "./onboard-guided.js";
|
||||||
import { DEFAULT_WORKSPACE, handleReset } from "./onboard-helpers.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 { runNonInteractiveSetup } from "./onboard-non-interactive.js";
|
||||||
import type { OnboardOptions, ResetScope } from "./onboard-types.js";
|
import type { OnboardOptions, ResetScope } from "./onboard-types.js";
|
||||||
|
|
||||||
const VALID_RESET_SCOPES = new Set<ResetScope>(["config", "config+creds+sessions", "full"]);
|
const VALID_RESET_SCOPES = new Set<ResetScope>(["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
|
* 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
|
* Boolean false and undefined mean "not passed" (Commander coerces unset
|
||||||
* booleans to false); explicit `--no-install-daemon` arrives as `false` via
|
* booleans to false); explicit `--no-install-daemon` arrives as `false` via
|
||||||
* resolveInstallDaemonFlag and is special-cased. `--modern` never reaches this
|
* resolveInstallDaemonFlag and is special-cased. `--modern` never reaches this
|
||||||
* dispatch; it routes straight to Crestodian in the command layer.
|
* 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",
|
"workspace",
|
||||||
"acceptRisk",
|
"acceptRisk",
|
||||||
"reset",
|
"reset",
|
||||||
|
|
@ -49,7 +50,7 @@ function wantsClassicInteractiveSetup(opts: OnboardOptions): boolean {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
for (const [key, value] of Object.entries(opts)) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
if (value === undefined || value === false) {
|
if (value === undefined || value === false) {
|
||||||
|
|
@ -157,7 +158,7 @@ export async function setupWizardCommand(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await runConversationalOnboarding(normalizedOpts, runtime);
|
await runGuidedOnboarding(normalizedOpts, runtime);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const onboardCommand = setupWizardCommand;
|
export const onboardCommand = setupWizardCommand;
|
||||||
|
|
|
||||||
|
|
@ -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 <path> env <ENV_VAR>`. Never echo secret values back.",
|
"Secrets (tokens, API keys, passwords) must not be written as plaintext when the user prefers env storage: use `config set-ref <path> env <ENV_VAR>`. 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`.",
|
"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.",
|
"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>`.",
|
||||||
|
"Channel guidance: when the user asks ABOUT a channel or its prerequisites (bot tokens, app creation, e.g. Slack or Telegram), run `channel info <channel>` and use its docs link; never guess credentials or steps. When they ask to CONNECT a channel, run `connect <channel>` right away — do not detour through channel info.",
|
||||||
"",
|
"",
|
||||||
"Allowed commands:",
|
"Allowed commands:",
|
||||||
"- setup",
|
"- setup",
|
||||||
|
|
@ -49,6 +51,10 @@ export const CRESTODIAN_ASSISTANT_SYSTEM_PROMPT = [
|
||||||
"- configure model provider",
|
"- configure model provider",
|
||||||
"- channels",
|
"- channels",
|
||||||
"- connect <channel>",
|
"- connect <channel>",
|
||||||
|
"- channel info <channel>",
|
||||||
|
"- open setup wizard",
|
||||||
|
"- open classic wizard",
|
||||||
|
"- open channel wizard for <channel>",
|
||||||
"- plugins list",
|
"- plugins list",
|
||||||
"- plugins search <query>",
|
"- plugins search <query>",
|
||||||
"- plugin install <npm-or-clawhub-spec>",
|
"- plugin install <npm-or-clawhub-spec>",
|
||||||
|
|
@ -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.",
|
"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.",
|
"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.",
|
"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.",
|
"Keep replies under 120 words. Ask one question at a time. Never claim something was done unless the tool result confirms it.",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -252,6 +252,44 @@ describe("CrestodianChatEngine", () => {
|
||||||
expect(reply.text).toContain("Sensitive input is not accepted");
|
expect(reply.text).toContain("Sensitive input is not accepted");
|
||||||
expect(reply.text).toContain("openclaw channels add --channel telegram");
|
expect(reply.text).toContain("openclaw channels add --channel telegram");
|
||||||
expect(reply.sensitive).toBeUndefined();
|
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 () => {
|
it("keeps hosted-wizard validation errors on the current prompt", async () => {
|
||||||
|
|
@ -320,6 +358,21 @@ describe("CrestodianChatEngine", () => {
|
||||||
expect(reply.text).toContain("Handing you over");
|
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 () => {
|
it("starts the channel wizard from an agent-loop directive", async () => {
|
||||||
useTempStateDir();
|
useTempStateDir();
|
||||||
const engine = new CrestodianChatEngine({
|
const engine = new CrestodianChatEngine({
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ export type CrestodianChatEngineOptions = {
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CrestodianChatReplyAction = "none" | "exit" | "open-tui";
|
export type CrestodianChatReplyAction = "none" | "exit" | "open-tui" | "open-setup";
|
||||||
|
|
||||||
export type CrestodianChatReply = {
|
export type CrestodianChatReply = {
|
||||||
text: string;
|
text: string;
|
||||||
|
|
@ -304,6 +304,8 @@ async function withDeadline<T>(work: Promise<T>, fallback: T, deadlineMs: number
|
||||||
export class CrestodianChatEngine {
|
export class CrestodianChatEngine {
|
||||||
private pending: CrestodianOperation | null = null;
|
private pending: CrestodianOperation | null = null;
|
||||||
private wizardBridge: ActiveWizardBridge | null = null;
|
private wizardBridge: ActiveWizardBridge | null = null;
|
||||||
|
private lastSensitiveChannel: string | undefined;
|
||||||
|
private awaitingSetupChannel = false;
|
||||||
private readonly history: CrestodianAssistantTurn[] = [];
|
private readonly history: CrestodianAssistantTurn[] = [];
|
||||||
private readonly agentSession: CrestodianAgentSession = createCrestodianAgentSession();
|
private readonly agentSession: CrestodianAgentSession = createCrestodianAgentSession();
|
||||||
/** Turns run strictly one at a time; interleaved handles corrupt wizard/pending state. */
|
/** Turns run strictly one at a time; interleaved handles corrupt wizard/pending state. */
|
||||||
|
|
@ -334,6 +336,8 @@ export class CrestodianChatEngine {
|
||||||
async dispose(): Promise<void> {
|
async dispose(): Promise<void> {
|
||||||
this.wizardBridge?.session.cancel();
|
this.wizardBridge?.session.cancel();
|
||||||
this.wizardBridge = null;
|
this.wizardBridge = null;
|
||||||
|
this.lastSensitiveChannel = undefined;
|
||||||
|
this.awaitingSetupChannel = false;
|
||||||
await cleanupCrestodianAgentSession(this.agentSession);
|
await cleanupCrestodianAgentSession(this.agentSession);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -378,6 +382,23 @@ export class CrestodianChatEngine {
|
||||||
// Leaving the process is a host action, not a conversation the AI owns.
|
// Leaving the process is a host action, not a conversation the AI owns.
|
||||||
return { text: "Crestodian retracts into shell. Bye.", action: "exit" };
|
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
|
// 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
|
// token and must never reach a model. It runs on the deterministic path
|
||||||
|
|
@ -568,6 +589,13 @@ export class CrestodianChatEngine {
|
||||||
handoff: loopReply.directive,
|
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" };
|
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") {
|
if (operation.kind === "channel-setup") {
|
||||||
// Starting the wizard is not a write; the wizard collects explicit
|
// Starting the wizard is not a write; the wizard collects explicit
|
||||||
// answers and commits only at the end.
|
// answers and commits only at the end.
|
||||||
|
|
@ -712,6 +774,7 @@ export class CrestodianChatEngine {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async startChannelSetupWizard(channel: string): Promise<string> {
|
private async startChannelSetupWizard(channel: string): Promise<string> {
|
||||||
|
this.lastSensitiveChannel = undefined;
|
||||||
const runWizard =
|
const runWizard =
|
||||||
this.opts.runChannelSetupWizard ??
|
this.opts.runChannelSetupWizard ??
|
||||||
((ch: string, prompter: WizardPrompterLike) => defaultChannelSetupWizardRunner(ch)(prompter));
|
((ch: string, prompter: WizardPrompterLike) => defaultChannelSetupWizardRunner(ch)(prompter));
|
||||||
|
|
@ -826,15 +889,17 @@ export class CrestodianChatEngine {
|
||||||
if (this.opts.surface === "cli" && bridge.step.sensitive === true) {
|
if (this.opts.surface === "cli" && bridge.step.sensitive === true) {
|
||||||
bridge.session.cancel();
|
bridge.session.cancel();
|
||||||
this.wizardBridge = null;
|
this.wizardBridge = null;
|
||||||
return bridge.kind === "model"
|
if (bridge.kind === "model") {
|
||||||
? [
|
return [
|
||||||
"Sensitive input is not accepted in the Crestodian TUI because terminal input is visible.",
|
"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.",
|
"Run `openclaw configure --section model` to finish setup with masked prompts.",
|
||||||
].join("\n")
|
].join("\n");
|
||||||
: [
|
}
|
||||||
"Sensitive input is not accepted in the Crestodian TUI because terminal input is visible.",
|
this.lastSensitiveChannel = bridge.label;
|
||||||
`Run \`openclaw channels add --channel ${bridge.label}\` to finish setup with masked prompts.`,
|
return [
|
||||||
].join("\n");
|
"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") {
|
if (bridge.step.type === "note" || bridge.step.type === "progress") {
|
||||||
const step = bridge.step;
|
const step = bridge.step;
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,8 @@ export type RunCrestodianOptions = {
|
||||||
welcomeVariant?: "onboarding";
|
welcomeVariant?: "onboarding";
|
||||||
/** Workspace override for the proposed first-run setup (from --workspace). */
|
/** Workspace override for the proposed first-run setup (from --workspace). */
|
||||||
setupWorkspace?: string;
|
setupWorkspace?: string;
|
||||||
|
/** Risk acknowledgement already collected by the calling onboarding flow. */
|
||||||
|
setupAcceptRisk?: boolean;
|
||||||
onReady?: () => void;
|
onReady?: () => void;
|
||||||
deps?: CrestodianCommandDeps;
|
deps?: CrestodianCommandDeps;
|
||||||
formatOverview?: (overview: CrestodianOverview) => string;
|
formatOverview?: (overview: CrestodianOverview) => string;
|
||||||
|
|
|
||||||
|
|
@ -373,6 +373,108 @@ describe("parseCrestodianOperation", () => {
|
||||||
expect(isPersistentCrestodianOperation({ kind: "channel-list" })).toBe(false);
|
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", () => {
|
it("parses agent creation requests", () => {
|
||||||
expect(
|
expect(
|
||||||
parseCrestodianOperation("create agent Work workspace /tmp/work model openai/gpt-5.2"),
|
parseCrestodianOperation("create agent Work workspace /tmp/work model openai/gpt-5.2"),
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,13 @@ export type CrestodianOperation =
|
||||||
| { kind: "setup"; workspace?: string; model?: string }
|
| { kind: "setup"; workspace?: string; model?: string }
|
||||||
| { kind: "model-setup"; workspace?: string }
|
| { kind: "model-setup"; workspace?: string }
|
||||||
| { kind: "channel-list" }
|
| { kind: "channel-list" }
|
||||||
|
| { kind: "channel-info"; channel: string }
|
||||||
| { kind: "channel-setup"; channel: string }
|
| { kind: "channel-setup"; channel: string }
|
||||||
|
| {
|
||||||
|
kind: "open-setup";
|
||||||
|
target: "guided" | "classic" | "channels";
|
||||||
|
channel?: string;
|
||||||
|
}
|
||||||
| { kind: "gateway-status" }
|
| { kind: "gateway-status" }
|
||||||
| { kind: "gateway-start" }
|
| { kind: "gateway-start" }
|
||||||
| { kind: "gateway-stop" }
|
| { kind: "gateway-stop" }
|
||||||
|
|
@ -119,6 +125,9 @@ export type CrestodianCommandDeps = {
|
||||||
/** Where setup side effects run; the gateway surface never manages its own daemon. */
|
/** Where setup side effects run; the gateway surface never manages its own daemon. */
|
||||||
setupSurface?: "cli" | "gateway";
|
setupSurface?: "cli" | "gateway";
|
||||||
applySetup?: typeof import("./setup-apply.js").applyCrestodianSetup;
|
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
|
// 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_LIST_RE = /^(?:channels|list\s+channels|show\s+channels)$/i;
|
||||||
const CHANNEL_CONNECT_RE =
|
const CHANNEL_CONNECT_RE =
|
||||||
/^(?:connect|link)\s+(?:channel\s+)?(?:to\s+)?(?<channel>[a-z0-9_-]+)(?:\s+channel)?$/i;
|
/^(?:connect|link)\s+(?:channel\s+)?(?:to\s+)?(?<channel>[a-z0-9_-]+)(?:\s+channel)?$/i;
|
||||||
|
const CHANNEL_INFO_RE =
|
||||||
|
/^(?:channel\s+info\s+(?<channel>[a-z0-9_-]+)|about\s+(?<aboutChannel>[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+(?<channel>[a-z0-9_-]+))?$/i;
|
||||||
|
|
||||||
const NO_MATCH_MESSAGE =
|
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 <channel>`, open the setup wizard, show audit, or switch to your agent TUI.";
|
||||||
|
|
||||||
/** Audit/source labels for detected inference backends (docs-visible contract). */
|
/** Audit/source labels for detected inference backends (docs-visible contract). */
|
||||||
const INFERENCE_SOURCE_LABELS: Record<InferenceBackendKind, string> = {
|
const INFERENCE_SOURCE_LABELS: Record<InferenceBackendKind, string> = {
|
||||||
|
|
@ -289,6 +304,11 @@ export function parseCrestodianOperation(input: string): CrestodianOperation {
|
||||||
if (CHANNEL_LIST_RE.test(trimmed)) {
|
if (CHANNEL_LIST_RE.test(trimmed)) {
|
||||||
return { kind: "channel-list" };
|
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);
|
const channelConnectMatch = trimmed.match(CHANNEL_CONNECT_RE);
|
||||||
if (channelConnectMatch?.groups?.channel) {
|
if (channelConnectMatch?.groups?.channel) {
|
||||||
return { kind: "channel-setup", channel: channelConnectMatch.groups.channel.toLowerCase() };
|
return { kind: "channel-setup", channel: channelConnectMatch.groups.channel.toLowerCase() };
|
||||||
|
|
@ -301,6 +321,21 @@ export function parseCrestodianOperation(input: string): CrestodianOperation {
|
||||||
...(workspace ? { workspace } : {}),
|
...(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);
|
const setupMatch = trimmed.match(SETUP_RE);
|
||||||
if (setupMatch) {
|
if (setupMatch) {
|
||||||
const workspace = trimShellishToken(setupMatch.groups?.workspace);
|
const workspace = trimShellishToken(setupMatch.groups?.workspace);
|
||||||
|
|
@ -578,6 +613,38 @@ async function loadOverviewForOperation(
|
||||||
return await loadCrestodianOverview();
|
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 {
|
function formatConfigValidationLine(snapshot: ConfigFileSnapshot): string {
|
||||||
if (!snapshot.exists) {
|
if (!snapshot.exists) {
|
||||||
return `Config missing: ${shortenHomePath(snapshot.path)}`;
|
return `Config missing: ${shortenHomePath(snapshot.path)}`;
|
||||||
|
|
@ -1018,22 +1085,8 @@ export async function executeCrestodianOperation(
|
||||||
// Use the same discovery as channel setup (bundled plugins + trusted
|
// Use the same discovery as channel setup (bundled plugins + trusted
|
||||||
// catalog), so the listing matches what `connect <channel>` can configure
|
// catalog), so the listing matches what `connect <channel>` can configure
|
||||||
// even before any plugin registry is active.
|
// even before any plugin registry is active.
|
||||||
const [
|
const { resolved } = await resolveChannelSetupState(opts.deps);
|
||||||
{ listChannelSetupPlugins },
|
const entries = resolved.entries.toSorted((a, b) => a.id.localeCompare(b.id));
|
||||||
{ 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));
|
|
||||||
runtime.log(
|
runtime.log(
|
||||||
[
|
[
|
||||||
"Channels:",
|
"Channels:",
|
||||||
|
|
@ -1046,6 +1099,38 @@ export async function executeCrestodianOperation(
|
||||||
);
|
);
|
||||||
return { applied: false };
|
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":
|
case "channel-setup":
|
||||||
// Channel setup is a multi-step wizard; only interactive Crestodian (TUI
|
// 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
|
// chat bridge or the gateway chat) can host it. One-shot mode points at
|
||||||
|
|
@ -1067,6 +1152,18 @@ export async function executeCrestodianOperation(
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
);
|
);
|
||||||
return { applied: false };
|
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":
|
case "setup":
|
||||||
return await executeSetup(operation, runtime, opts);
|
return await executeSetup(operation, runtime, opts);
|
||||||
case "config-set":
|
case "config-set":
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
activateSetupInference,
|
activateSetupInference,
|
||||||
detectSetupInference,
|
detectSetupInference,
|
||||||
listSetupInferenceManualProviders,
|
listSetupInferenceManualProviders,
|
||||||
|
verifySetupInference,
|
||||||
} from "./setup-inference.js";
|
} from "./setup-inference.js";
|
||||||
|
|
||||||
vi.mock("../config/config.js", () => ({
|
vi.mock("../config/config.js", () => ({
|
||||||
|
|
@ -208,6 +209,14 @@ describe("activateSetupInference", () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(result).toMatchObject({ ok: false, status: "format" });
|
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();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,10 @@ export type ActivateSetupInferenceResult =
|
||||||
| { ok: true; modelRef: string; latencyMs: number; lines: string[] }
|
| { ok: true; modelRef: string; latencyMs: number; lines: string[] }
|
||||||
| { ok: false; status: SetupInferenceStatus; error: 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 = {
|
export type ActivateSetupInferenceParams = {
|
||||||
kind: InferenceBackendKind | "api-key";
|
kind: InferenceBackendKind | "api-key";
|
||||||
/** Manual step only: provider-auth choice returned by detection. */
|
/** 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<VerifySetupInferenceResult> {
|
||||||
|
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(
|
function applyManualAuthConfig(
|
||||||
config: OpenClawConfig,
|
config: OpenClawConfig,
|
||||||
manualAuth: NonNullable<SetupInferenceTestPlan["manualAuth"]>,
|
manualAuth: NonNullable<SetupInferenceTestPlan["manualAuth"]>,
|
||||||
|
|
@ -693,7 +738,9 @@ async function runSetupInferenceTest(params: {
|
||||||
{ ok: true; latencyMs: number } | { ok: false; status: SetupInferenceStatus; error: string }
|
{ ok: true; latencyMs: number } | { ok: false; status: SetupInferenceStatus; error: string }
|
||||||
> {
|
> {
|
||||||
const { plan, tempDir, deps } = params;
|
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 sessionId = `${runId}-session`;
|
||||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||||
const timeoutMs = deps.timeoutMs ?? SETUP_INFERENCE_TEST_TIMEOUT_MS;
|
const timeoutMs = deps.timeoutMs ?? SETUP_INFERENCE_TEST_TIMEOUT_MS;
|
||||||
|
|
@ -743,7 +790,7 @@ async function runSetupInferenceTest(params: {
|
||||||
: {}),
|
: {}),
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
runId,
|
runId,
|
||||||
lane: `setup-inference:${plan.provider}`,
|
lane: `session:probe-setup-inference:${plan.provider}`,
|
||||||
thinkLevel: "off",
|
thinkLevel: "off",
|
||||||
reasoningLevel: "off",
|
reasoningLevel: "off",
|
||||||
verboseLevel: "off",
|
verboseLevel: "off",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
// Crestodian TUI backend tests cover rescue status integration with the TUI backend.
|
// Crestodian TUI backend tests cover rescue status integration with the TUI backend.
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { CrestodianOperation } from "./operations.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import type { CrestodianOverview } from "./overview.js";
|
import type { CrestodianOverview } from "./overview.js";
|
||||||
import { runCrestodianTui } from "./tui-backend.js";
|
import { runCrestodianTui } from "./tui-backend.js";
|
||||||
|
|
@ -300,4 +301,77 @@ describe("runCrestodianTui", () => {
|
||||||
expect(runModelSetup).toHaveBeenCalledOnce();
|
expect(runModelSetup).toHaveBeenCalledOnce();
|
||||||
expect(messages).toEqual([undefined, "configure model provider", undefined]);
|
expect(messages).toEqual([undefined, "configure model provider", undefined]);
|
||||||
});
|
});
|
||||||
|
it("launches setup handoffs after the chat TUI is disposed", async () => {
|
||||||
|
const cases: Array<{
|
||||||
|
handoff: Extract<CrestodianOperation, { kind: "open-setup" }>;
|
||||||
|
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<void>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
backend.engine.handle = async () => ({
|
||||||
|
text: "Opening setup.",
|
||||||
|
action: "open-setup",
|
||||||
|
handoff,
|
||||||
|
});
|
||||||
|
backend.engine.dispose = async () => {
|
||||||
|
events.push("disposed");
|
||||||
|
};
|
||||||
|
const requestedExit = new Promise<void>((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]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import type {
|
||||||
SessionsPatchParams,
|
SessionsPatchParams,
|
||||||
SessionsPatchResult,
|
SessionsPatchResult,
|
||||||
} from "../../packages/gateway-protocol/src/index.js";
|
} 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 { buildAgentMainSessionKey } from "../routing/session-key.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { notifyListeners } from "../shared/listeners.js";
|
import { notifyListeners } from "../shared/listeners.js";
|
||||||
|
|
@ -39,6 +41,8 @@ export type CrestodianTuiOptions = {
|
||||||
welcomeVariant?: "onboarding";
|
welcomeVariant?: "onboarding";
|
||||||
/** Workspace override for the proposed first-run setup (from --workspace). */
|
/** Workspace override for the proposed first-run setup (from --workspace). */
|
||||||
setupWorkspace?: string;
|
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. */
|
/** Test seam for the channel-setup wizard hosted by the chat bridge. */
|
||||||
runChannelSetupWizard?: CrestodianChatEngineOptions["runChannelSetupWizard"];
|
runChannelSetupWizard?: CrestodianChatEngineOptions["runChannelSetupWizard"];
|
||||||
/** Test seam for masked terminal model setup after the TUI exits. */
|
/** Test seam for masked terminal model setup after the TUI exits. */
|
||||||
|
|
@ -47,6 +51,13 @@ export type CrestodianTuiOptions = {
|
||||||
prompter: import("../wizard/prompts.js").WizardPrompter;
|
prompter: import("../wizard/prompts.js").WizardPrompter;
|
||||||
runtime: RuntimeEnv;
|
runtime: RuntimeEnv;
|
||||||
}) => Promise<CrestodianModelSetupResult>;
|
}) => Promise<CrestodianModelSetupResult>;
|
||||||
|
runGuidedSetup?: (opts: OnboardOptions, runtime: RuntimeEnv) => Promise<void>;
|
||||||
|
runClassicSetup?: (opts: OnboardOptions, runtime: RuntimeEnv) => Promise<void>;
|
||||||
|
runChannelsAdd?: (
|
||||||
|
opts: ChannelsAddOptions,
|
||||||
|
runtime: RuntimeEnv,
|
||||||
|
params?: { hasFlags?: boolean },
|
||||||
|
) => Promise<unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CrestodianHistoryMessage = {
|
type CrestodianHistoryMessage = {
|
||||||
|
|
@ -312,7 +323,7 @@ class CrestodianTuiBackend implements TuiBackend {
|
||||||
private async respond(runId: string, sessionKey: string, text: string): Promise<void> {
|
private async respond(runId: string, sessionKey: string, text: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const reply = await this.engine.handle(text);
|
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.
|
// The outer loop owns interactive handoffs after the Crestodian TUI exits.
|
||||||
this.handoff = reply.handoff;
|
this.handoff = reply.handoff;
|
||||||
queueMicrotask(() => this.requestExit?.());
|
queueMicrotask(() => this.requestExit?.());
|
||||||
|
|
@ -326,6 +337,44 @@ class CrestodianTuiBackend implements TuiBackend {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runSetupHandoff(
|
||||||
|
handoff: Extract<CrestodianOperation, { kind: "open-setup" }>,
|
||||||
|
opts: CrestodianTuiOptions,
|
||||||
|
runtime: RuntimeEnv,
|
||||||
|
): Promise<void> {
|
||||||
|
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(
|
export async function runCrestodianTui(
|
||||||
opts: CrestodianTuiOptions,
|
opts: CrestodianTuiOptions,
|
||||||
runtime: RuntimeEnv,
|
runtime: RuntimeEnv,
|
||||||
|
|
@ -398,6 +447,10 @@ export async function runCrestodianTui(
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (handoff.kind === "open-setup") {
|
||||||
|
await runSetupHandoff(handoff, opts, runtime);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const result = await executeCrestodianOperation(handoff, runtime, {
|
const result = await executeCrestodianOperation(handoff, runtime, {
|
||||||
approved: true,
|
approved: true,
|
||||||
deps: opts.deps,
|
deps: opts.deps,
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,12 @@ export const crestodianHandlers: GatewayRequestHandlers = {
|
||||||
const reply = await session.engine.handle(params.message);
|
const reply = await session.engine.handle(params.message);
|
||||||
// The TUI-only "open-tui" handoff becomes a client-visible "open-agent"
|
// The TUI-only "open-tui" handoff becomes a client-visible "open-agent"
|
||||||
// signal: the app should move the user to their normal agent chat.
|
// 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(
|
respond(
|
||||||
true,
|
true,
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,19 @@ export function setConsoleSubsystemFilter(filters?: string[] | null): void {
|
||||||
loggingState.consoleSubsystemFilter = normalized.length > 0 ? normalized : null;
|
loggingState.consoleSubsystemFilter = normalized.length > 0 ? normalized : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Hides subsystem console lines for TTY-owned work while preserving file logging. */
|
||||||
|
export async function withConsoleSubsystemsSuppressed<T>(work: () => Promise<T>): Promise<T> {
|
||||||
|
const previousFilter = loggingState.consoleSubsystemFilter
|
||||||
|
? [...loggingState.consoleSubsystemFilter]
|
||||||
|
: null;
|
||||||
|
setConsoleSubsystemFilter(["__openclaw_tui_quiet__"]);
|
||||||
|
try {
|
||||||
|
return await work();
|
||||||
|
} finally {
|
||||||
|
setConsoleSubsystemFilter(previousFilter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function setConsoleTimestampPrefix(enabled: boolean): void {
|
export function setConsoleTimestampPrefix(enabled: boolean): void {
|
||||||
loggingState.consoleTimestampPrefix = enabled;
|
loggingState.consoleTimestampPrefix = enabled;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,30 @@ describe("createSubsystemLogger().isEnabled", () => {
|
||||||
expect(warn).not.toHaveBeenCalled();
|
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", () => {
|
it("does not suppress probe errors for embedded subsystems", () => {
|
||||||
setLoggerOverride({ level: "silent", consoleLevel: "error" });
|
setLoggerOverride({ level: "silent", consoleLevel: "error" });
|
||||||
const error = installConsoleMethodSpy("error");
|
const error = installConsoleMethodSpy("error");
|
||||||
|
|
|
||||||
|
|
@ -331,6 +331,26 @@ describe("command queue", () => {
|
||||||
).toBe(true);
|
).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 () => {
|
it("getActiveTaskCount returns count of currently executing tasks", async () => {
|
||||||
const { task, release } = enqueueBlockedMainTask();
|
const { task, release } = enqueueBlockedMainTask();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,16 @@ function isExpectedNonErrorLaneFailure(err: unknown): boolean {
|
||||||
return err instanceof Error && err.name === "LiveSessionModelSwitchError";
|
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
|
* Keep queue runtime state on globalThis so every bundled entry/chunk shares
|
||||||
* the same lanes, counters, and draining flag in production builds.
|
* the same lanes, counters, and draining flag in production builds.
|
||||||
|
|
@ -423,7 +433,7 @@ function drainLane(lane: string) {
|
||||||
entry.resolve(result);
|
entry.resolve(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const completedCurrentGeneration = completeTask(state, taskId, taskGeneration);
|
const completedCurrentGeneration = completeTask(state, taskId, taskGeneration);
|
||||||
const isProbeLane = lane.startsWith("auth-probe:") || lane.startsWith("session:probe-");
|
const isProbeLane = isQuietProbeLane(lane);
|
||||||
if (!isProbeLane && !isExpectedNonErrorLaneFailure(err)) {
|
if (!isProbeLane && !isExpectedNonErrorLaneFailure(err)) {
|
||||||
diag.error(
|
diag.error(
|
||||||
`lane task error: lane=${lane} durationMs=${Date.now() - startTime} error="${String(err)}"`,
|
`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) {
|
export function setCommandLaneConcurrency(lane: string, maxConcurrent: number) {
|
||||||
const cleaned = normalizeLane(lane);
|
const cleaned = normalizeLane(lane);
|
||||||
const state = getLaneState(cleaned);
|
const state = getLaneState(cleaned);
|
||||||
const isProbeLane = cleaned.startsWith("auth-probe:") || cleaned.startsWith("session:probe-");
|
const isProbeLane = isQuietProbeLane(cleaned);
|
||||||
const minConcurrent = isProbeLane ? 1 : 0;
|
const minConcurrent = isProbeLane ? 1 : 0;
|
||||||
state.maxConcurrent = Math.max(minConcurrent, Math.floor(maxConcurrent));
|
state.maxConcurrent = Math.max(minConcurrent, Math.floor(maxConcurrent));
|
||||||
if (state.maxConcurrent > 0) {
|
if (state.maxConcurrent > 0) {
|
||||||
|
|
|
||||||
|
|
@ -233,6 +233,55 @@ export const en = {
|
||||||
validWebSocketUrl: "URL must start with ws:// or wss://",
|
validWebSocketUrl: "URL must start with ws:// or wss://",
|
||||||
websocketUrl: "Gateway WebSocket URL",
|
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: {
|
setup: {
|
||||||
authChoiceFailedRetry: "Pick another provider or auth method, or choose Skip for now.",
|
authChoiceFailedRetry: "Pick another provider or auth method, or choose Skip for now.",
|
||||||
authChoiceFailedTitle: "Provider setup failed",
|
authChoiceFailedTitle: "Provider setup failed",
|
||||||
|
|
@ -285,6 +334,14 @@ export const en = {
|
||||||
skipChannels: "Skipping channel setup.",
|
skipChannels: "Skipping channel setup.",
|
||||||
skipSearch: "Skipping search setup.",
|
skipSearch: "Skipping search setup.",
|
||||||
skipSkills: "Skipping skills 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?",
|
whatSetup: "What do you want to set up?",
|
||||||
workspaceDirectory: "Workspace directory",
|
workspaceDirectory: "Workspace directory",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -230,6 +230,55 @@ export const zh_CN = {
|
||||||
validWebSocketUrl: "URL 必须以 ws:// 或 wss:// 开头",
|
validWebSocketUrl: "URL 必须以 ws:// 或 wss:// 开头",
|
||||||
websocketUrl: "Gateway WebSocket URL",
|
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: {
|
setup: {
|
||||||
authChoiceFailedRetry: "请选择其他提供商或认证方式,或选择暂时跳过。",
|
authChoiceFailedRetry: "请选择其他提供商或认证方式,或选择暂时跳过。",
|
||||||
authChoiceFailedTitle: "提供商设置失败",
|
authChoiceFailedTitle: "提供商设置失败",
|
||||||
|
|
@ -282,6 +331,14 @@ export const zh_CN = {
|
||||||
skipChannels: "跳过频道设置。",
|
skipChannels: "跳过频道设置。",
|
||||||
skipSearch: "跳过搜索设置。",
|
skipSearch: "跳过搜索设置。",
|
||||||
skipSkills: "跳过技能设置。",
|
skipSkills: "跳过技能设置。",
|
||||||
|
testAiAccess: "现在用一次真实 completion 测试 AI 访问?",
|
||||||
|
testAiContinue: "仍然继续",
|
||||||
|
testAiFailure: "AI 访问测试失败:{reason}",
|
||||||
|
testAiFailureChoice: "你想如何继续?",
|
||||||
|
testAiFix: "修复模型/认证设置",
|
||||||
|
testAiProgress: "正在用真实 completion 测试 AI 访问…",
|
||||||
|
testAiSuccess: "AI 访问正常,在 {seconds} 秒内回复。",
|
||||||
|
testAiTitle: "AI 访问测试",
|
||||||
whatSetup: "你想设置什么?",
|
whatSetup: "你想设置什么?",
|
||||||
workspaceDirectory: "工作区目录",
|
workspaceDirectory: "工作区目录",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -230,6 +230,55 @@ export const zh_TW = {
|
||||||
validWebSocketUrl: "URL 必須以 ws:// 或 wss:// 開頭",
|
validWebSocketUrl: "URL 必須以 ws:// 或 wss:// 開頭",
|
||||||
websocketUrl: "Gateway WebSocket URL",
|
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: {
|
setup: {
|
||||||
authChoiceFailedRetry: "請選擇其他提供商或認證方式,或選擇暫時跳過。",
|
authChoiceFailedRetry: "請選擇其他提供商或認證方式,或選擇暫時跳過。",
|
||||||
authChoiceFailedTitle: "提供商設定失敗",
|
authChoiceFailedTitle: "提供商設定失敗",
|
||||||
|
|
@ -282,6 +331,14 @@ export const zh_TW = {
|
||||||
skipChannels: "略過頻道設定。",
|
skipChannels: "略過頻道設定。",
|
||||||
skipSearch: "略過搜尋設定。",
|
skipSearch: "略過搜尋設定。",
|
||||||
skipSkills: "略過技能設定。",
|
skipSkills: "略過技能設定。",
|
||||||
|
testAiAccess: "現在用一次真實 completion 測試 AI 存取?",
|
||||||
|
testAiContinue: "仍然繼續",
|
||||||
|
testAiFailure: "AI 存取測試失敗:{reason}",
|
||||||
|
testAiFailureChoice: "你想如何繼續?",
|
||||||
|
testAiFix: "修復模型/認證設定",
|
||||||
|
testAiProgress: "正在用真實 completion 測試 AI 存取…",
|
||||||
|
testAiSuccess: "AI 存取正常,在 {seconds} 秒內回覆。",
|
||||||
|
testAiTitle: "AI 存取測試",
|
||||||
whatSetup: "你想設定什麼?",
|
whatSetup: "你想設定什麼?",
|
||||||
workspaceDirectory: "工作區目錄",
|
workspaceDirectory: "工作區目錄",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,11 @@ const enableDefaultOnboardingInternalHooks = vi.hoisted(() =>
|
||||||
const detectSetupMigrationSources = vi.hoisted(() => vi.fn(async () => []));
|
const detectSetupMigrationSources = vi.hoisted(() => vi.fn(async () => []));
|
||||||
const listSetupMigrationOptions = vi.hoisted(() => vi.fn(async () => []));
|
const listSetupMigrationOptions = vi.hoisted(() => vi.fn(async () => []));
|
||||||
const runSetupMigrationImport = vi.hoisted(() => vi.fn(async () => {}));
|
const runSetupMigrationImport = vi.hoisted(() => vi.fn(async () => {}));
|
||||||
|
const verifySetupInference = vi.hoisted(() =>
|
||||||
|
vi.fn<() => Promise<import("../crestodian/setup-inference.js").VerifySetupInferenceResult>>(
|
||||||
|
async () => ({ ok: true, modelRef: "openai/gpt-5.5", latencyMs: 250 }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const setupChannels = vi.hoisted(() =>
|
const setupChannels = vi.hoisted(() =>
|
||||||
vi.fn(
|
vi.fn(
|
||||||
|
|
@ -283,6 +288,10 @@ vi.mock("./setup.migration-import.js", () => ({
|
||||||
runSetupMigrationImport,
|
runSetupMigrationImport,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../crestodian/setup-inference.js", () => ({
|
||||||
|
verifySetupInference,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../config/config.js", () => ({
|
vi.mock("../config/config.js", () => ({
|
||||||
DEFAULT_GATEWAY_PORT: 18789,
|
DEFAULT_GATEWAY_PORT: 18789,
|
||||||
createConfigIO,
|
createConfigIO,
|
||||||
|
|
@ -447,6 +456,12 @@ describe("runSetupWizard", () => {
|
||||||
warnIfModelConfigLooksOff.mockResolvedValue(undefined);
|
warnIfModelConfigLooksOff.mockResolvedValue(undefined);
|
||||||
buildPluginCompatibilitySnapshotNotices.mockReset();
|
buildPluginCompatibilitySnapshotNotices.mockReset();
|
||||||
buildPluginCompatibilitySnapshotNotices.mockReturnValue([]);
|
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 () => {
|
it("exits successfully after the auto-launched TUI returns", async () => {
|
||||||
|
|
@ -1715,4 +1730,101 @@ describe("runSetupWizard", () => {
|
||||||
"default model prompt params",
|
"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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { resolveAgentModelPrimaryValue } from "../config/model-input.js";
|
||||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||||
import { normalizeSecretInputString } from "../config/types.secrets.js";
|
import { normalizeSecretInputString } from "../config/types.secrets.js";
|
||||||
import { formatErrorMessage } from "../infra/errors.js";
|
import { formatErrorMessage } from "../infra/errors.js";
|
||||||
|
import { withConsoleSubsystemsSuppressed } from "../logging/console.js";
|
||||||
import {
|
import {
|
||||||
buildPluginCompatibilitySnapshotNotices,
|
buildPluginCompatibilitySnapshotNotices,
|
||||||
formatPluginCompatibilityNotice,
|
formatPluginCompatibilityNotice,
|
||||||
|
|
@ -44,6 +45,70 @@ function hasConfiguredDefaultModel(config: OpenClawConfig): boolean {
|
||||||
return resolveAgentModelPrimaryValue(config.agents?.defaults?.model) !== undefined;
|
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<OpenClawConfig>;
|
||||||
|
}): Promise<OpenClawConfig> {
|
||||||
|
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 {
|
function isSetupImportFlowChoice(flow: SetupFlowChoice): boolean {
|
||||||
return flow === "import" || flow.startsWith("import:");
|
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);
|
const importFrom = opts.importFrom ?? resolveImportProviderFromFlowChoice(flow);
|
||||||
prompter.disableBackNavigation?.();
|
prompter.disableBackNavigation?.();
|
||||||
await runSetupMigrationImport({
|
await runSetupMigrationImport({
|
||||||
|
|
@ -475,6 +541,23 @@ async function runSetupWizardOnce(
|
||||||
allowConfigSizeDrop: false,
|
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?.();
|
prompter.disableBackNavigation?.();
|
||||||
if (opts.skipChannels ?? opts.skipProviders) {
|
if (opts.skipChannels ?? opts.skipProviders) {
|
||||||
await prompter.note(t("wizard.setup.skipChannels"), t("wizard.setup.channelsTitle"));
|
await prompter.note(t("wizard.setup.skipChannels"), t("wizard.setup.channelsTitle"));
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue