Improve onboard wizard setup flow

This commit is contained in:
chengyongru
2026-06-22 13:04:05 +08:00
committed by Xubin Ren
parent 9db3dc5e32
commit fc7971b3b6
16 changed files with 483 additions and 93 deletions
+3 -3
View File
@@ -234,7 +234,7 @@ Windows PowerShell:
irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | iex
```
The default command installs or upgrades `nanobot-ai` from PyPI, then starts `nanobot onboard --wizard`. It avoids system-wide pip installs by using an active virtual environment, `uv`, `pipx`, or a managed venv under `~/.nanobot/venv`. If you finish the wizard and save the config, skip the manual initialize/configure steps below and go straight to **Test one message**.
The default command installs or upgrades `nanobot-ai` from PyPI, then starts `nanobot onboard`. It avoids system-wide pip installs by using an active virtual environment, `uv`, `pipx`, or a managed venv under `~/.nanobot/venv`. If you finish the wizard and save the config, skip the manual initialize/configure steps below and go straight to **Test one message**.
To preview the plan without changing your environment, pass `--dry-run`; combine it with `--dev` when you want to preview the main-branch install.
@@ -296,13 +296,13 @@ Skip this step if the one-command setup already started the wizard and you saved
nanobot onboard
```
Use `nanobot onboard --wizard` if you prefer an interactive setup.
Use `nanobot onboard --defaults` if you want the old non-interactive default config.
**2. Configure** (`~/.nanobot/config.json`)
Skip this step if you already configured provider and model settings in the wizard.
`nanobot onboard` creates `~/.nanobot/config.json` and `~/.nanobot/workspace/`. Configure these **two parts** in the config file. Add or merge the following blocks into the existing file instead of replacing the whole file.
`nanobot onboard --defaults` creates `~/.nanobot/config.json` and `~/.nanobot/workspace/`. Configure these **two parts** in the config file. Add or merge the following blocks into the existing file instead of replacing the whole file.
The example below uses [OpenRouter](https://openrouter.ai/keys) only so the JSON has concrete names. Provider examples are recipes, not rankings or endorsements. If you use another provider, replace the provider config key, API key, preset provider name, and model ID together.
+2 -2
View File
@@ -155,7 +155,7 @@ The key (`webhook`) becomes the config section name. The value points to your `B
```bash
python -m pip install -e .
nanobot plugins list # verify "Webhook" shows as "plugin"
nanobot onboard # auto-adds default config for detected plugins
nanobot onboard --defaults # auto-adds default config for detected plugins
```
Edit `~/.nanobot/config.json`:
@@ -507,7 +507,7 @@ async def start(self) -> None:
`allowFrom` is handled automatically by `_handle_message()` — you don't need to check it yourself.
Override `default_config()` so `nanobot onboard` auto-populates `config.json`:
Override `default_config()` so `nanobot onboard --defaults` auto-populates `config.json`:
```python
@classmethod
+7 -5
View File
@@ -7,8 +7,8 @@ Use this page when you know what you want to run and need the command shape. For
| Goal | Command | Notes |
|---|---|---|
| Check the install | `nanobot --version` | If this fails, try `python -m nanobot --version` |
| Create or refresh config | `nanobot onboard` | Creates `~/.nanobot/config.json` and `~/.nanobot/workspace/` |
| Use guided setup | `nanobot onboard --wizard` | Best when you prefer prompts over hand-editing JSON |
| Use guided setup | `nanobot onboard` | Best when you prefer prompts over hand-editing JSON |
| Create or refresh defaults | `nanobot onboard --defaults` | Creates `~/.nanobot/config.json` and `~/.nanobot/workspace/` without prompts |
| Check config without calling a model | `nanobot status` | Reads the default config and summarizes the active model/provider |
| Send one test message | `nanobot agent -m "Hello!"` | First proof that install, config, provider, model, and workspace all work |
| Chat in the terminal | `nanobot agent` | Interactive local chat; exit with `exit`, `/exit`, `:q`, or `Ctrl+D` |
@@ -52,9 +52,11 @@ Long-running commands keep working until you stop them. Press `Ctrl+C` in that t
| Command | Description |
|---|---|
| `nanobot onboard` | Initialize or refresh the default config and workspace |
| `nanobot onboard --wizard` | Use the interactive setup wizard |
| `nanobot onboard --config <path> --workspace <path>` | Initialize or refresh a specific instance |
| `nanobot onboard` | Use the interactive setup wizard |
| `nanobot onboard --defaults` | Initialize or refresh the default config and workspace without prompts |
| `nanobot onboard --defaults --config <path> --workspace <path>` | Initialize or refresh a specific instance without prompts |
Without `--defaults`, `nanobot onboard` opens the wizard only when stdin and stdout are attached to a terminal. In scripts, CI, and Docker non-TTY runs, it falls back to the defaults setup.
Default paths:
+1 -1
View File
@@ -31,7 +31,7 @@ The default instance lives under `~/.nanobot/`:
You can override both with command flags:
```bash
nanobot onboard --config ./bot-a/config.json --workspace ./bot-a/workspace
nanobot onboard --defaults --config ./bot-a/config.json --workspace ./bot-a/workspace
nanobot agent --config ./bot-a/config.json --workspace ./bot-a/workspace -m "Hello"
nanobot gateway --config ./bot-a/config.json --workspace ./bot-a/workspace
```
+1 -1
View File
@@ -11,7 +11,7 @@ The generated `config.json` uses camelCase keys such as `apiKey` and `intervalS`
For setup and runtime failures, follow the diagnosis order in [`troubleshooting.md`](./troubleshooting.md) before changing multiple config areas at once.
> [!NOTE]
> If your config file is older than the current schema, you can refresh it without overwriting your existing values: run `nanobot onboard`, then answer `N` when asked whether to overwrite the config. nanobot will merge in missing default fields and keep your current settings.
> If your config file is older than the current schema, you can refresh it without overwriting your existing values: run `nanobot onboard --defaults`, then answer `N` when asked whether to overwrite the config. nanobot will merge in missing default fields and keep your current settings.
## Quick Jump
+4 -4
View File
@@ -59,9 +59,9 @@ Restart the deployed process after editing `config.json`. Long-running processes
### Docker Compose
```bash
docker compose run --rm nanobot-cli onboard # first-time setup
vim ~/.nanobot/config.json # add API keys
docker compose up -d nanobot-gateway # start gateway
docker compose run --rm nanobot-cli onboard --defaults # first-time setup
vim ~/.nanobot/config.json # add API keys
docker compose up -d nanobot-gateway # start gateway
```
```bash
@@ -77,7 +77,7 @@ docker compose down # stop
docker build -t nanobot .
# Initialize config (first time only)
docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot onboard
docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot onboard --defaults
# Edit config on host to add API keys
vim ~/.nanobot/config.json
+3 -3
View File
@@ -10,9 +10,9 @@ If you want each instance to have its own dedicated workspace from the start, pa
```bash
# Create separate instance configs and workspaces
nanobot onboard --config ~/.nanobot-telegram/config.json --workspace ~/.nanobot-telegram/workspace
nanobot onboard --config ~/.nanobot-discord/config.json --workspace ~/.nanobot-discord/workspace
nanobot onboard --config ~/.nanobot-feishu/config.json --workspace ~/.nanobot-feishu/workspace
nanobot onboard --defaults --config ~/.nanobot-telegram/config.json --workspace ~/.nanobot-telegram/workspace
nanobot onboard --defaults --config ~/.nanobot-discord/config.json --workspace ~/.nanobot-discord/workspace
nanobot onboard --defaults --config ~/.nanobot-feishu/config.json --workspace ~/.nanobot-feishu/workspace
```
**Configure each instance:**
+1 -1
View File
@@ -25,7 +25,7 @@ tools:
To allow the agent to set its configuration (e.g. switch models, adjust parameters), set `tools.my.allow_set: true`.
Legacy `tools.myEnabled` / `tools.mySet` keys are auto-migrated on load, and rewritten in-place the next time `nanobot onboard` refreshes the config.
Legacy `tools.myEnabled` / `tools.mySet` keys are auto-migrated on load, and rewritten in-place the next time `nanobot onboard --defaults` refreshes the config.
All modifications are held in memory only — restart restores defaults.
+1 -1
View File
@@ -25,7 +25,7 @@ Match the recipe to the credential or endpoint you already have:
## How to Use a Recipe
1. Install nanobot and run `nanobot onboard` or `nanobot onboard --wizard` once so `~/.nanobot/config.json` exists.
1. Install nanobot and run `nanobot onboard` once so `~/.nanobot/config.json` exists. Use `nanobot onboard --defaults` if you prefer editing JSON by hand.
2. Put secrets in environment variables when possible.
3. Merge the recipe snippet into `~/.nanobot/config.json`.
4. Run `nanobot status`.
+4 -4
View File
@@ -32,7 +32,7 @@ On Windows PowerShell:
irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | iex
```
The default command installs or upgrades `nanobot-ai` from PyPI, then starts `nanobot onboard --wizard`. It avoids system-wide pip installs by using an active virtual environment, `uv`, `pipx`, or a managed venv under `~/.nanobot/venv`. If you finish the wizard and save the config, skip the manual initialize/configure steps and go straight to [Check the Setup](#4-check-the-setup).
The default command installs or upgrades `nanobot-ai` from PyPI, then starts `nanobot onboard`. It avoids system-wide pip installs by using an active virtual environment, `uv`, `pipx`, or a managed venv under `~/.nanobot/venv`. If you finish the wizard and save the config, skip the manual initialize/configure steps and go straight to [Check the Setup](#4-check-the-setup).
To preview the plan without changing your environment, pass `--dry-run`; combine it with `--dev` when you want to preview the main-branch install.
@@ -102,10 +102,10 @@ Skip this section if the one-command setup already started the wizard and you sa
nanobot onboard
```
Use the wizard if you prefer prompts instead of editing JSON by hand:
Use the old non-interactive defaults path if you prefer editing JSON by hand:
```bash
nanobot onboard --wizard
nanobot onboard --defaults
```
Initialization creates:
@@ -115,7 +115,7 @@ Initialization creates:
| `~/.nanobot/config.json` | Main settings file for providers, models, channels, tools, gateway, and API |
| `~/.nanobot/workspace/` | Agent workspace for memory, sessions, heartbeat tasks, skills, and artifacts |
If you already have a config, `nanobot onboard` can refresh missing default fields without overwriting your existing values.
If you already have a config, `nanobot onboard --defaults` can refresh missing default fields without overwriting your existing values.
## 3. Configure a Provider
+16 -25
View File
@@ -145,16 +145,16 @@ Use `python3 -m nanobot --version` or `py -m nanobot --version` if that is the P
The one-command installer starts this for you after installation. If you installed manually, run:
```bash
nanobot onboard --wizard
nanobot onboard
```
If `nanobot` is not found, run:
```bash
python -m nanobot onboard --wizard
python -m nanobot onboard
```
Use `python3 -m nanobot onboard --wizard` or `py -m nanobot onboard --wizard` if that is the Python command that worked in step 2.
Use `python3 -m nanobot onboard` or `py -m nanobot onboard` if that is the Python command that worked in step 2.
The wizard is a terminal menu. It is not a graphical app, but it lets you choose options instead of hand-editing every JSON field.
@@ -162,6 +162,7 @@ You will see a menu like this:
```text
> What would you like to configure?
[Q] Quick Start (recommended)
[P] LLM Provider
[M] Model Presets
[C] Chat Channel
@@ -184,27 +185,17 @@ Move through the wizard like this:
| A field you do not need | Keep the shown default or leave it blank, then press `Enter`. |
| A back option | Choose it to return to the previous menu. |
For the first setup, only configure the model provider and one model preset.
For the first setup, choose `[Q] Quick Start (recommended)`. It asks for the model provider, API key, model ID, and optionally one chat channel. The other menu items are advanced settings.
If you are following the OpenRouter example:
1. Choose `[P] LLM Provider`.
2. Select OpenRouter.
3. Paste your OpenRouter API key.
4. Keep the default `apiBase`, or leave it blank if the wizard shows no default. Only change it if OpenRouter or your deployment guide explicitly tells you to set one.
5. Return to the main menu.
6. Choose `[M] Model Presets`.
7. Add or edit a preset named `primary`.
8. Set:
```text
label: Primary
provider: openrouter
model: anthropic/claude-sonnet-4.5
maxTokens: 4096
contextWindowTokens: 65536
temperature: 0.1
```
1. Choose `[Q] Quick Start (recommended)`.
2. Choose `WebUI only (recommended)` unless you already have a Telegram, Feishu/Lark, Slack, or Discord bot token ready.
3. Select OpenRouter.
4. Paste your OpenRouter API key.
5. Enter a model ID, for example `anthropic/claude-sonnet-4.5`.
6. Review the Quick Start summary.
7. Choose `[S] Save and Exit`.
If OpenRouter says your account cannot use that model, use another OpenRouter model ID that your account can access.
@@ -215,9 +206,7 @@ If you are using another provider, use the same wizard choices but substitute th
| Provider menu | The provider that owns your API key or endpoint. |
| API key | The key from that provider, or leave it blank only if the provider does not use one. |
| `apiBase` | Leave blank unless the provider docs, proxy docs, or local server docs give you a URL. |
| Preset `provider` | The nanobot provider name, such as the one shown in [`provider-cookbook.md`](./provider-cookbook.md). |
| Preset `model` | A model ID that provider can actually serve. |
| Preset name | `primary` is fine for the first setup. |
| Model ID | A model ID that provider can actually serve. |
Then choose `[S] Save and Exit`.
@@ -260,12 +249,14 @@ Merge them into one object:
}
```
Notice the comma after the `providers` block. JSON needs commas between sibling sections, but not after the last section. If this feels hard, use `nanobot onboard --wizard` whenever possible.
Notice the comma after the `providers` block. JSON needs commas between sibling sections, but not after the last section. If this feels hard, use `nanobot onboard` whenever possible.
## 6. Manual Config Fallback
Use this only if the wizard is unavailable or you prefer opening the file yourself.
Run `nanobot onboard --defaults` first if `~/.nanobot/config.json` does not exist yet.
Use one of these commands:
**Windows PowerShell**
+29 -5
View File
@@ -432,16 +432,34 @@ def main(
# ============================================================================
def _onboard_can_prompt() -> bool:
"""Return True when onboard can safely show interactive prompts."""
return sys.stdin.isatty() and sys.stdout.isatty()
@app.command()
def onboard(
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
wizard: bool = typer.Option(False, "--wizard", help="Use interactive wizard"),
wizard: bool = typer.Option(False, "--wizard", help="Use interactive wizard (default)"),
defaults: bool = typer.Option(
False,
"--defaults",
help="Create or refresh the default config without the wizard",
),
):
"""Initialize nanobot configuration and workspace."""
from nanobot.config.loader import get_config_path, load_config, save_config, set_config_path
from nanobot.config.schema import Config
wants_wizard = wizard or not defaults
use_wizard = wants_wizard and not defaults and _onboard_can_prompt()
default_fallback = wants_wizard and not use_wizard and not defaults
if default_fallback:
console.print(
"[yellow]No interactive terminal detected; using --defaults setup instead.[/yellow]"
)
if config:
config_path = Path(config).expanduser().resolve()
set_config_path(config_path)
@@ -456,8 +474,14 @@ def onboard(
# Create or update config
if config_path.exists():
if wizard:
if use_wizard:
config = _apply_workspace_override(load_config(config_path))
elif default_fallback:
config = _apply_workspace_override(load_config(config_path))
save_config(config, config_path)
console.print(
f"[green]✓[/green] Config refreshed at {config_path} (existing values preserved)"
)
else:
console.print(f"[yellow]Config already exists at {config_path}[/yellow]")
console.print(
@@ -479,12 +503,12 @@ def onboard(
else:
config = _apply_workspace_override(Config())
# In wizard mode, don't save yet - the wizard will handle saving if should_save=True
if not wizard:
if not use_wizard:
save_config(config, config_path)
console.print(f"[green]✓[/green] Created config at {config_path}")
# Run interactive wizard if enabled
if wizard:
if use_wizard:
from nanobot.cli.onboard import run_onboard
try:
@@ -518,7 +542,7 @@ def onboard(
console.print(f"\n{__logo__} nanobot is ready!")
console.print("\nNext steps:")
if wizard:
if use_wizard:
console.print(f" 1. Chat: [cyan]{agent_cmd}[/cyan]")
console.print(f" 2. Start gateway: [cyan]{gateway_cmd}[/cyan]")
else:
+247 -20
View File
@@ -53,6 +53,47 @@ _BACK_PRESSED = object() # Sentinel value for back navigation
# offer existing presets as choices (e.g. AgentDefaults.model_preset).
_MODEL_PRESET_CACHE: set[str] = set()
_QUICK_START_TARGETS = {
"WebUI only (recommended)": "websocket",
"Telegram": "telegram",
"Feishu / Lark": "feishu",
"Slack": "slack",
"Discord": "discord",
}
_QUICK_START_PROVIDER_CHOICES = {
"OpenRouter": "openrouter",
"Anthropic": "anthropic",
"OpenAI": "openai",
"DeepSeek": "deepseek",
"DashScope": "dashscope",
"Gemini": "gemini",
"Ollama (local)": "ollama",
"Custom OpenAI-compatible": "custom",
}
_QUICK_START_CHANNEL_FIELDS = {
"telegram": (("token", "Telegram bot token from BotFather"),),
"feishu": (
("app_id", "Feishu/Lark App ID"),
("app_secret", "Feishu/Lark App Secret"),
),
"slack": (
("bot_token", "Slack bot token (xoxb-...)"),
("app_token", "Slack app token (xapp-...)"),
),
"discord": (("token", "Discord bot token"),),
}
_QUICK_START_STEPS = ("Entry point", "AI provider", "Channel", "Review")
# Low-contrast terminal palette inspired by JetBrains Darcula/Islands.
_UI_ACCENT = "#6B9BFA"
_UI_BORDER = "#4E5254"
_UI_TEXT = "#A9B7C6"
_UI_MUTED = "#80868B"
_UI_SUCCESS = "#6AAB73"
def _get_questionary():
"""Return questionary or raise a clear error when wizard deps are unavailable."""
@@ -156,8 +197,8 @@ def _select_with_back(
# Style
style = Style.from_dict({
"selected": "fg:green bold",
"question": "fg:cyan",
"selected": f"fg:{_UI_ACCENT} bold",
"question": f"fg:{_UI_TEXT}",
})
app = Application(layout=layout, key_bindings=bindings, style=style)
@@ -353,7 +394,7 @@ def _get_constraint_hint(field_info) -> str:
def _show_config_panel(display_name: str, model: BaseModel, fields: list) -> None:
"""Display current configuration as a rich table."""
table = Table(show_header=False, box=None, padding=(0, 2))
table.add_column("Field", style="cyan")
table.add_column("Field", style=_UI_ACCENT)
table.add_column("Value")
for fname, field_info in fields:
@@ -362,7 +403,7 @@ def _show_config_panel(display_name: str, model: BaseModel, fields: list) -> Non
formatted = _format_value(value, rich=True, field_name=fname)
table.add_row(display, formatted)
console.print(Panel(table, title=f"[bold]{display_name}[/bold]", border_style="blue"))
console.print(Panel(table, title=f"[bold {_UI_TEXT}]{display_name}[/]", border_style=_UI_BORDER))
def _show_main_menu_header() -> None:
@@ -370,11 +411,22 @@ def _show_main_menu_header() -> None:
from nanobot import __logo__, __version__
console.print()
# Use Align.CENTER for the single line of text
from rich.align import Align
body = Table.grid(expand=True)
body.add_column(ratio=1)
body.add_row(f"{__logo__} [bold {_UI_TEXT}]nanobot[/] [{_UI_MUTED}]v{__version__}[/]")
body.add_row(
f"[{_UI_ACCENT}]Quick Start configures a model and enables WebUI by default.[/]"
)
body.add_row(
f"[{_UI_MUTED}]Chat channels and advanced settings stay available when you need them.[/]"
)
console.print(
Align.center(f"{__logo__} [bold cyan]nanobot[{__version__}][/bold cyan]")
Panel(
body,
title=f"[bold {_UI_TEXT}]Setup Wizard[/]",
border_style=_UI_BORDER,
padding=(1, 2),
)
)
console.print()
@@ -384,10 +436,15 @@ def _show_section_header(title: str, subtitle: str = "") -> None:
console.print()
if subtitle:
console.print(
Panel(f"[dim]{subtitle}[/dim]", title=f"[bold]{title}[/bold]", border_style="blue")
Panel(
f"[{_UI_MUTED}]{subtitle}[/]",
title=f"[bold {_UI_TEXT}]{title}[/]",
border_style=_UI_BORDER,
padding=(1, 2),
)
)
else:
console.print(Panel("", title=f"[bold]{title}[/bold]", border_style="blue"))
console.print(Panel("", title=f"[bold {_UI_TEXT}]{title}[/]", border_style=_UI_BORDER))
# --- Input Handlers ---
@@ -548,7 +605,10 @@ def _input_context_window_with_recommendation(
context_limit = get_model_context_limit(model_name, provider)
if context_limit:
console.print(f"[green]+ Recommended context window: {format_token_count(context_limit)} tokens[/green]")
console.print(
f"[{_UI_SUCCESS}]+ Recommended context window: "
f"{format_token_count(context_limit)} tokens[/]"
)
return context_limit
else:
console.print("[yellow]! Could not fetch model info, please enter manually[/yellow]")
@@ -708,8 +768,8 @@ def _configure_pydantic_model(
) -> BaseModel | None:
"""Configure a Pydantic model interactively.
Returns the updated model only when the user explicitly selects "Done".
Back and cancel actions discard the section draft.
Returns the updated model when the user selects "Done" or navigates back.
Cancel actions discard the section draft.
"""
skip_fields = skip_fields or set()
working_model = model.model_copy(deep=True)
@@ -747,7 +807,9 @@ def _configure_pydantic_model(
"Select field to configure:", choices, default=default_choice
)
if answer is _BACK_PRESSED or answer is None:
if answer is _BACK_PRESSED:
return working_model
if answer is None:
return None
if answer == "[Done]":
return working_model
@@ -849,7 +911,10 @@ def _try_auto_fill_context_window(model: BaseModel, new_model_name: str) -> None
if context_limit:
setattr(model, "context_window_tokens", context_limit)
console.print(f"[green]+ Auto-filled context window: {format_token_count(context_limit)} tokens[/green]")
console.print(
f"[{_UI_SUCCESS}]+ Auto-filled context window: "
f"{format_token_count(context_limit)} tokens[/]"
)
else:
console.print("[dim](i) Could not auto-fill context window (model not in database)[/dim]")
@@ -1215,11 +1280,11 @@ def _print_summary_panel(rows: list[tuple[str, str]], title: str) -> None:
if not rows:
return
table = Table(show_header=False, box=None, padding=(0, 2))
table.add_column("Setting", style="cyan")
table.add_column("Setting", style=_UI_ACCENT)
table.add_column("Value")
for field, value in rows:
table.add_row(field, value)
console.print(Panel(table, title=f"[bold]{title}[/bold]", border_style="blue"))
console.print(Panel(table, title=f"[bold {_UI_TEXT}]{title}[/]", border_style=_UI_BORDER))
def _show_summary(config: Config) -> None:
@@ -1230,7 +1295,11 @@ def _show_summary(config: Config) -> None:
provider_rows = []
for name, display in _get_provider_names().items():
provider = getattr(config.providers, name, None)
status = "[green]configured[/green]" if (provider and provider.api_key) else "[dim]not configured[/dim]"
status = (
f"[{_UI_SUCCESS}]configured[/]"
if (provider and provider.api_key)
else f"[{_UI_MUTED}]not configured[/]"
)
provider_rows.append((display, status))
_print_summary_panel(provider_rows, "LLM Providers")
@@ -1244,9 +1313,9 @@ def _show_summary(config: Config) -> None:
if isinstance(channel, dict)
else getattr(channel, "enabled", False)
)
status = "[green]enabled[/green]" if enabled else "[dim]disabled[/dim]"
status = f"[{_UI_SUCCESS}]enabled[/]" if enabled else f"[{_UI_MUTED}]disabled[/]"
else:
status = "[dim]not configured[/dim]"
status = f"[{_UI_MUTED}]not configured[/]"
channel_rows.append((display, status))
_print_summary_panel(channel_rows, "Chat Channels")
@@ -1274,6 +1343,162 @@ def _pause() -> None:
_get_questionary().text("Press Enter to continue...", default="").ask()
# --- Quick Start ---
def _quick_start_model_default(config: Config, provider_name: str) -> str:
"""Return a low-risk default only when the existing model likely still fits."""
current = config.resolve_preset()
if provider_name in {"openrouter", "anthropic"}:
return current.model
return ""
def _show_quick_start_progress(active_step: int) -> None:
"""Render a compact step tracker for Quick Start."""
parts = []
for idx, label in enumerate(_QUICK_START_STEPS, 1):
if idx < active_step:
parts.append(f"[{_UI_SUCCESS}]{idx}. {label}[/]")
elif idx == active_step:
parts.append(f"[bold {_UI_ACCENT}]{idx}. {label}[/]")
else:
parts.append(f"[{_UI_MUTED}]{idx}. {label}[/]")
console.print(" " + " -> ".join(parts))
console.print()
def _configure_quick_start_provider(config: Config) -> bool:
"""Configure the minimum needed provider + primary model preset."""
_show_quick_start_progress(2)
answer = _select_with_back(
"Choose your AI provider:",
list(_QUICK_START_PROVIDER_CHOICES),
default="OpenRouter",
)
if answer is _BACK_PRESSED or answer is None:
return False
assert isinstance(answer, str)
provider_name = _QUICK_START_PROVIDER_CHOICES[answer]
provider_config = getattr(config.providers, provider_name, None)
if provider_config is None:
console.print(f"[red]Unknown provider: {provider_name}[/red]")
return False
_display, _is_gateway, is_local, default_api_base = _get_provider_info().get(
provider_name, (provider_name, False, False, "")
)
if default_api_base and not provider_config.api_base:
provider_config.api_base = default_api_base
if not is_local:
api_key = _input_with_existing(
"API key (leave blank only if this provider does not use one)",
provider_config.api_key,
"str",
)
if api_key is not None:
provider_config.api_key = api_key or None
if provider_name == "custom" or is_local:
api_base = _input_with_existing(
"API base URL",
provider_config.api_base,
"str",
)
if api_base is not None:
provider_config.api_base = api_base or None
model = _input_model_with_autocomplete(
"Model ID",
_quick_start_model_default(config, provider_name),
provider_name,
)
if model is None:
return False
model = model.strip()
if not model:
console.print("[yellow]! Model ID is required for Quick Start[/yellow]")
return False
config.model_presets["primary"] = ModelPresetConfig(
label="Primary",
model=model,
provider=provider_name,
)
config.agents.defaults.model_preset = "primary"
_sync_preset_cache(config)
return True
def _configure_quick_start_channel(config: Config, channel_name: str | None) -> bool:
"""Enable one common channel with only the fields needed to connect."""
_show_quick_start_progress(3)
if channel_name is None:
return True
config_cls = _get_channel_config_class(channel_name)
if config_cls is None:
console.print(f"[red]No configuration class found for {channel_name}[/red]")
return False
current = getattr(config.channels, channel_name, None) or {}
model = config_cls.model_validate(current)
if channel_name == "websocket":
console.print("[dim]WebUI uses the built-in local WebSocket channel. No token is needed now.[/dim]")
for field_name, prompt in _QUICK_START_CHANNEL_FIELDS.get(channel_name, ()):
value = _input_with_existing(prompt, getattr(model, field_name, ""), "str")
if value is not None:
setattr(model, field_name, value)
if hasattr(model, "enabled"):
setattr(model, "enabled", True)
setattr(config.channels, channel_name, model.model_dump(by_alias=True, exclude_none=True))
return True
def _show_quick_start_summary(config: Config, channel_name: str | None) -> None:
"""Show the small summary users need before returning to the menu."""
_show_quick_start_progress(4)
preset = config.model_presets.get("primary")
rows = [
("Provider", preset.provider if preset else "[not set]"),
("Model", preset.model if preset else "[not set]"),
("Entry point", "WebUI" if channel_name in {None, "websocket"} else channel_name),
("Next", "Save, then run `nanobot gateway`"),
]
_print_summary_panel(rows, "Quick Start")
def _configure_quick_start(config: Config) -> None:
"""First-run path: model + optional chat channel, with advanced settings hidden."""
console.clear()
_show_section_header(
"Quick Start",
"Set up one AI model and optionally one chat channel. Advanced settings stay unchanged.",
)
_show_quick_start_progress(1)
answer = _select_with_back(
"How do you want to use nanobot first?",
list(_QUICK_START_TARGETS) + ["<- Back"],
default="WebUI only (recommended)",
)
if answer is _BACK_PRESSED or answer is None or answer == "<- Back":
return
assert isinstance(answer, str)
channel_name = _QUICK_START_TARGETS[answer]
if not _configure_quick_start_provider(config):
_pause()
return
if not _configure_quick_start_channel(config, channel_name):
_pause()
return
_show_quick_start_summary(config, channel_name)
_pause()
# --- Main Entry Point ---
@@ -1336,6 +1561,7 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult:
answer = _get_questionary().select(
"What would you like to configure?",
choices=[
"[Q] Quick Start (recommended)",
"[P] LLM Provider",
"[M] Model Presets",
"[C] Chat Channel",
@@ -1363,6 +1589,7 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult:
continue
_menu_dispatch = {
"[Q] Quick Start (recommended)": lambda: _configure_quick_start(config),
"[P] LLM Provider": lambda: _configure_providers(config),
"[M] Model Presets": lambda: _configure_model_presets(config),
"[C] Chat Channel": lambda: _configure_channels(config),
+114 -9
View File
@@ -8,7 +8,6 @@ from pathlib import Path
from types import SimpleNamespace
from typing import Any, cast
import pytest
from pydantic import BaseModel, Field
from nanobot.cli import onboard as onboard_wizard
@@ -452,12 +451,23 @@ class TestConfigurePydanticModelDrafts:
onboard_wizard, "_input_with_existing", lambda *_args, **_kwargs: text_value
)
def test_discarding_section_keeps_original_model_unchanged(self, monkeypatch):
def test_back_commits_section_draft(self, monkeypatch):
model = _SimpleDraftModel()
self._patch_prompt_helpers(monkeypatch, ["first", "back"])
result = _configure_pydantic_model(model, "Simple")
assert result is not None
updated = cast(_SimpleDraftModel, result)
assert updated.api_key == "secret"
assert model.api_key == ""
def test_cancel_keeps_original_model_unchanged(self, monkeypatch):
model = _SimpleDraftModel()
self._patch_prompt_helpers(monkeypatch, ["first", None])
result = _configure_pydantic_model(model, "Simple")
assert result is None
assert model.api_key == ""
@@ -472,7 +482,7 @@ class TestConfigurePydanticModelDrafts:
assert updated.api_key == "secret"
assert model.api_key == ""
def test_nested_section_back_discards_nested_edits(self, monkeypatch):
def test_nested_section_back_commits_nested_edits(self, monkeypatch):
model = _OuterDraftModel()
self._patch_prompt_helpers(monkeypatch, ["first", "first", "back", "done"])
@@ -480,7 +490,7 @@ class TestConfigurePydanticModelDrafts:
assert result is not None
updated = cast(_OuterDraftModel, result)
assert updated.nested.api_key == ""
assert updated.nested.api_key == "secret"
assert model.nested.api_key == ""
def test_nested_section_done_commits_nested_edits(self, monkeypatch):
@@ -656,8 +666,8 @@ class TestValidateFieldConstraint:
def test_real_send_max_retries_field(self):
"""Validate against the actual ChannelsConfig.send_max_retries field."""
from nanobot.config.schema import ChannelsConfig
from nanobot.cli.onboard import _validate_field_constraint
from nanobot.config.schema import ChannelsConfig
field_info = ChannelsConfig.model_fields["send_max_retries"]
assert _validate_field_constraint(3, field_info) is None
@@ -847,14 +857,108 @@ class TestApiServerRegistration:
class TestMainMenuUpdate:
"""Tests for main menu including new Channel Common and API Server items."""
def test_run_onboard_quick_start_edit(self, monkeypatch):
"""run_onboard should route [Q] to Quick Start."""
initial_config = Config()
responses = iter([
"[Q] Quick Start (recommended)",
"[S] Save and Exit",
])
class FakePrompt:
def __init__(self, response):
self.response = response
def ask(self):
return self.response
def fake_select(*_args, **_kwargs):
return FakePrompt(next(responses))
def fake_quick_start(config):
config.agents.defaults.bot_name = "quickbot"
monkeypatch.setattr(onboard_wizard, "_show_main_menu_header", lambda: None)
monkeypatch.setattr(onboard_wizard, "questionary", SimpleNamespace(select=fake_select))
monkeypatch.setattr(onboard_wizard, "_configure_quick_start", fake_quick_start)
result = run_onboard(initial_config=initial_config)
assert result.should_save is True
assert result.config.agents.defaults.bot_name == "quickbot"
def test_quick_start_configures_primary_preset_and_telegram(self, monkeypatch):
"""Quick Start should set only the minimum provider, model and channel fields."""
config = Config()
selections = iter(["Telegram", "OpenRouter"])
def fake_select_with_back(*_args, **_kwargs):
return next(selections)
def fake_input(prompt, *_args, **_kwargs):
if "API key" in prompt:
return "sk-or-test"
if "Telegram bot token" in prompt:
return "123:abc"
raise AssertionError(prompt)
monkeypatch.setattr(onboard_wizard.console, "clear", lambda: None)
monkeypatch.setattr(onboard_wizard, "_show_section_header", lambda *a, **kw: None)
monkeypatch.setattr(onboard_wizard, "_select_with_back", fake_select_with_back)
monkeypatch.setattr(onboard_wizard, "_input_with_existing", fake_input)
monkeypatch.setattr(
onboard_wizard,
"_input_model_with_autocomplete",
lambda *_args, **_kwargs: "anthropic/claude-sonnet-4.5",
)
monkeypatch.setattr(onboard_wizard, "_print_summary_panel", lambda *a, **kw: None)
monkeypatch.setattr(onboard_wizard, "_pause", lambda: None)
onboard_wizard._configure_quick_start(config)
assert config.providers.openrouter.api_key == "sk-or-test"
assert config.providers.openrouter.api_base == "https://openrouter.ai/api/v1"
assert config.agents.defaults.model_preset == "primary"
assert config.model_presets["primary"].provider == "openrouter"
assert config.model_presets["primary"].model == "anthropic/claude-sonnet-4.5"
telegram = getattr(config.channels, "telegram")
assert telegram["enabled"] is True
assert telegram["token"] == "123:abc"
def test_quick_start_webui_enables_websocket(self, monkeypatch):
"""The recommended Quick Start target should create a working WebUI channel."""
config = Config()
selections = iter(["WebUI only (recommended)", "OpenRouter"])
def fake_select_with_back(*_args, **_kwargs):
return next(selections)
monkeypatch.setattr(onboard_wizard.console, "clear", lambda: None)
monkeypatch.setattr(onboard_wizard, "_show_section_header", lambda *a, **kw: None)
monkeypatch.setattr(onboard_wizard, "_select_with_back", fake_select_with_back)
monkeypatch.setattr(onboard_wizard, "_input_with_existing", lambda *a, **kw: "sk-or-test")
monkeypatch.setattr(
onboard_wizard,
"_input_model_with_autocomplete",
lambda *_args, **_kwargs: "anthropic/claude-sonnet-4.5",
)
monkeypatch.setattr(onboard_wizard, "_print_summary_panel", lambda *a, **kw: None)
monkeypatch.setattr(onboard_wizard, "_pause", lambda: None)
onboard_wizard._configure_quick_start(config)
websocket = getattr(config.channels, "websocket")
assert websocket["enabled"] is True
assert websocket["allowFrom"] == ["*"]
def test_main_menu_dispatch_includes_channel_common(self):
"""Main menu dispatch should route [H] to Channel Common."""
from nanobot.cli.onboard import run_onboard
# We verify by checking the dispatch table is set up correctly
# The menu items are defined inline in run_onboard, so we test
# that _configure_general_settings handles the new sections.
from nanobot.cli.onboard import _SETTINGS_SECTIONS, _SETTINGS_GETTER, _SETTINGS_SETTER
from nanobot.cli.onboard import _SETTINGS_GETTER, _SETTINGS_SECTIONS, _SETTINGS_SETTER
assert "Channel Common" in _SETTINGS_SECTIONS
assert "Channel Common" in _SETTINGS_GETTER
@@ -862,7 +966,7 @@ class TestMainMenuUpdate:
def test_main_menu_dispatch_includes_api_server(self):
"""Main menu dispatch should route [I] to API Server."""
from nanobot.cli.onboard import _SETTINGS_SECTIONS, _SETTINGS_GETTER, _SETTINGS_SETTER
from nanobot.cli.onboard import _SETTINGS_GETTER, _SETTINGS_SECTIONS, _SETTINGS_SETTER
assert "API Server" in _SETTINGS_SECTIONS
assert "API Server" in _SETTINGS_GETTER
@@ -1014,6 +1118,7 @@ class TestIsStrOrNone:
def test_optional_str_true(self):
from typing import Optional
from nanobot.cli.onboard import _is_str_or_none
assert _is_str_or_none(Optional[str]) is True
@@ -1035,7 +1140,7 @@ class TestConfigurePydanticModelEmptyString:
def test_optional_str_empty_string_becomes_none(self, monkeypatch):
"""Entering '' for an optional str field should set it to None."""
from pydantic import BaseModel
from nanobot.cli.onboard import _is_str_or_none
class M(BaseModel):
api_key: str | None = None
+48 -7
View File
@@ -91,12 +91,13 @@ def mock_paths():
def test_onboard_fresh_install(mock_paths):
"""No existing config — should create from scratch."""
"""No existing config in non-TTY mode should fall back to defaults."""
config_file, workspace_dir, mock_ws = mock_paths
result = runner.invoke(app, ["onboard"])
assert result.exit_code == 0
assert "No interactive terminal detected" in result.stdout
assert "Created config" in result.stdout
assert "Created workspace" in result.stdout
assert "nanobot is ready" in result.stdout
@@ -112,7 +113,7 @@ def test_onboard_existing_config_refresh(mock_paths):
config_file, workspace_dir, _ = mock_paths
config_file.write_text('{"existing": true}')
result = runner.invoke(app, ["onboard"], input="n\n")
result = runner.invoke(app, ["onboard", "--defaults"], input="n\n")
assert result.exit_code == 0
assert "Config already exists" in result.stdout
@@ -121,12 +122,26 @@ def test_onboard_existing_config_refresh(mock_paths):
assert (workspace_dir / "AGENTS.md").exists()
def test_onboard_non_tty_existing_config_refreshes_without_prompt(mock_paths):
"""Default onboard should not ask overwrite when falling back outside a TTY."""
config_file, workspace_dir, _ = mock_paths
config_file.write_text("{}")
result = runner.invoke(app, ["onboard"])
assert result.exit_code == 0
assert "No interactive terminal detected" in result.stdout
assert "Config already exists" not in result.stdout
assert "existing values preserved" in result.stdout
assert workspace_dir.exists()
def test_onboard_existing_config_overwrite(mock_paths):
"""Config exists, user confirms overwrite — should reset to defaults."""
config_file, workspace_dir, _ = mock_paths
config_file.write_text('{"existing": true}')
result = runner.invoke(app, ["onboard"], input="y\n")
result = runner.invoke(app, ["onboard", "--defaults"], input="y\n")
assert result.exit_code == 0
assert "Config already exists" in result.stdout
@@ -140,7 +155,7 @@ def test_onboard_existing_workspace_safe_create(mock_paths):
workspace_dir.mkdir(parents=True)
config_file.write_text("{}")
result = runner.invoke(app, ["onboard"], input="n\n")
result = runner.invoke(app, ["onboard", "--defaults"], input="n\n")
assert result.exit_code == 0
assert "Created workspace" not in result.stdout
@@ -164,6 +179,7 @@ def test_onboard_help_shows_workspace_and_config_options():
assert "--config" in stripped_output
assert "-c" in stripped_output
assert "--wizard" in stripped_output
assert "--defaults" in stripped_output
assert "--dir" not in stripped_output
@@ -172,12 +188,13 @@ def test_onboard_interactive_discard_does_not_save_or_create_workspace(mock_path
from nanobot.cli.onboard import OnboardResult
monkeypatch.setattr("nanobot.cli.commands._onboard_can_prompt", lambda: True)
monkeypatch.setattr(
"nanobot.cli.onboard.run_onboard",
lambda initial_config: OnboardResult(config=initial_config, should_save=False),
)
result = runner.invoke(app, ["onboard", "--wizard"])
result = runner.invoke(app, ["onboard"])
assert result.exit_code == 0
assert "No changes were saved" in result.stdout
@@ -193,7 +210,14 @@ def test_onboard_uses_explicit_config_and_workspace_paths(tmp_path, monkeypatch)
result = runner.invoke(
app,
["onboard", "--config", str(config_path), "--workspace", str(workspace_path)],
[
"onboard",
"--defaults",
"--config",
str(config_path),
"--workspace",
str(workspace_path),
],
)
assert result.exit_code == 0
@@ -217,11 +241,12 @@ def test_onboard_wizard_preserves_explicit_config_in_next_steps(tmp_path, monkey
"nanobot.cli.onboard.run_onboard",
lambda initial_config: OnboardResult(config=initial_config, should_save=True),
)
monkeypatch.setattr("nanobot.cli.commands._onboard_can_prompt", lambda: True)
monkeypatch.setattr("nanobot.channels.registry.discover_all", lambda: {})
result = runner.invoke(
app,
["onboard", "--wizard", "--config", str(config_path), "--workspace", str(workspace_path)],
["onboard", "--config", str(config_path), "--workspace", str(workspace_path)],
)
assert result.exit_code == 0
@@ -232,6 +257,22 @@ def test_onboard_wizard_preserves_explicit_config_in_next_steps(tmp_path, monkey
assert f"nanobot gateway --config {resolved_config}" in compact_output
def test_onboard_wizard_non_tty_falls_back_to_defaults(mock_paths, monkeypatch):
config_file, _workspace_dir, _ = mock_paths
monkeypatch.setattr("nanobot.cli.commands._onboard_can_prompt", lambda: False)
monkeypatch.setattr(
"nanobot.cli.onboard.run_onboard",
lambda initial_config: (_ for _ in ()).throw(AssertionError("should not prompt")),
)
result = runner.invoke(app, ["onboard", "--wizard"])
assert result.exit_code == 0
assert "No interactive terminal detected" in result.stdout
assert config_file.exists()
def test_config_matches_github_copilot_codex_with_hyphen_prefix():
config = Config()
config.agents.defaults.model = "github-copilot/gpt-5.3-codex"
+2 -2
View File
@@ -8,8 +8,8 @@ echo "=== Building Docker image ==="
docker build -t "$IMAGE_NAME" .
echo ""
echo "=== Running 'nanobot onboard' ==="
docker run --name nanobot-test-run "$IMAGE_NAME" onboard
echo "=== Running 'nanobot onboard --defaults' ==="
docker run --name nanobot-test-run "$IMAGE_NAME" onboard --defaults
echo ""
echo "=== Running 'nanobot status' ==="