From fc7971b3b614c86f19e07bcf693281202b311856 Mon Sep 17 00:00:00 2001 From: chengyongru Date: Thu, 18 Jun 2026 16:02:10 +0800 Subject: [PATCH] Improve onboard wizard setup flow --- README.md | 6 +- docs/channel-plugin-guide.md | 4 +- docs/cli-reference.md | 12 +- docs/concepts.md | 2 +- docs/configuration.md | 2 +- docs/deployment.md | 8 +- docs/multiple-instances.md | 6 +- docs/my-tool.md | 2 +- docs/provider-cookbook.md | 2 +- docs/quick-start.md | 8 +- docs/start-without-technical-background.md | 41 ++-- nanobot/cli/commands.py | 34 ++- nanobot/cli/onboard.py | 267 +++++++++++++++++++-- tests/agent/test_onboard_logic.py | 123 +++++++++- tests/cli/test_commands.py | 55 ++++- tests/test_docker.sh | 4 +- 16 files changed, 483 insertions(+), 93 deletions(-) diff --git a/README.md b/README.md index e47a1025..71bce64d 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/channel-plugin-guide.md b/docs/channel-plugin-guide.md index 292ad00f..c35c4e60 100644 --- a/docs/channel-plugin-guide.md +++ b/docs/channel-plugin-guide.md @@ -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 diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 278c2bbe..e9b5f5ea 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -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 --workspace ` | 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 --workspace ` | 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: diff --git a/docs/concepts.md b/docs/concepts.md index 405e6540..b93f2ed5 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -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 ``` diff --git a/docs/configuration.md b/docs/configuration.md index 997e6926..e9cdbbbd 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 diff --git a/docs/deployment.md b/docs/deployment.md index 0c398880..135760e2 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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 diff --git a/docs/multiple-instances.md b/docs/multiple-instances.md index 04ab9b8f..4469b6c3 100644 --- a/docs/multiple-instances.md +++ b/docs/multiple-instances.md @@ -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:** diff --git a/docs/my-tool.md b/docs/my-tool.md index 6992d122..bceeabc1 100644 --- a/docs/my-tool.md +++ b/docs/my-tool.md @@ -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. diff --git a/docs/provider-cookbook.md b/docs/provider-cookbook.md index b7c44a12..9a637785 100644 --- a/docs/provider-cookbook.md +++ b/docs/provider-cookbook.md @@ -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`. diff --git a/docs/quick-start.md b/docs/quick-start.md index 819b8a4b..6b80f127 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -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 diff --git a/docs/start-without-technical-background.md b/docs/start-without-technical-background.md index 32cdc095..997546a3 100644 --- a/docs/start-without-technical-background.md +++ b/docs/start-without-technical-background.md @@ -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** diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 93f5dc30..d8b15f4d 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -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: diff --git a/nanobot/cli/onboard.py b/nanobot/cli/onboard.py index 98c06173..f30314b4 100644 --- a/nanobot/cli/onboard.py +++ b/nanobot/cli/onboard.py @@ -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), diff --git a/tests/agent/test_onboard_logic.py b/tests/agent/test_onboard_logic.py index 762da4f3..a003c082 100644 --- a/tests/agent/test_onboard_logic.py +++ b/tests/agent/test_onboard_logic.py @@ -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 diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index ef462348..ea86aa6b 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -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" diff --git a/tests/test_docker.sh b/tests/test_docker.sh index 1e551338..2dcb8122 100644 --- a/tests/test_docker.sh +++ b/tests/test_docker.sh @@ -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' ==="