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