diff --git a/README.md b/README.md index 38ea3a3..581821d 100644 --- a/README.md +++ b/README.md @@ -1196,10 +1196,27 @@ MCP tools are automatically discovered and registered on startup. The LLM can us ## 🧩 Multiple Instances -Run multiple nanobot instances simultaneously with separate configs and runtime data. Use `--config` as the main entrypoint, and optionally use `--workspace` to override the workspace for a specific run. +Run multiple nanobot instances simultaneously with separate configs and runtime data. Use `--config` as the main entrypoint. Optionally pass `--workspace` during `onboard` when you want to initialize or update the saved workspace for a specific instance. ### Quick Start +If you want each instance to have its own dedicated workspace from the start, pass both `--config` and `--workspace` during onboarding. + +**Initialize instances:** + +```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 +``` + +**Configure each instance:** + +Edit `~/.nanobot-telegram/config.json`, `~/.nanobot-discord/config.json`, etc. with different channel settings. The workspace you passed during `onboard` is saved into each config as that instance's default workspace. + +**Run instances:** + ```bash # Instance A - Telegram bot nanobot gateway --config ~/.nanobot-telegram/config.json @@ -1299,7 +1316,8 @@ nanobot gateway --config ~/.nanobot-telegram/config.json --workspace /tmp/nanobo | Command | Description | |---------|-------------| -| `nanobot onboard` | Initialize config & workspace | +| `nanobot onboard` | Initialize config & workspace at `~/.nanobot/` | +| `nanobot onboard -c -w ` | Initialize or refresh a specific instance config and workspace | | `nanobot agent -m "..."` | Chat with the agent | | `nanobot agent -w ` | Chat against a specific workspace | | `nanobot agent -w -c ` | Chat against a specific workspace/config | diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 19c956d..07b97a7 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -262,28 +262,42 @@ def main( @app.command() -def onboard(): +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"), +): """Initialize nanobot configuration and workspace.""" - from nanobot.config.loader import get_config_path, load_config, save_config + from nanobot.config.loader import get_config_path, load_config, save_config, set_config_path from nanobot.config.schema import Config - config_path = get_config_path() + if config: + config_path = Path(config).expanduser().resolve() + set_config_path(config_path) + console.print(f"[dim]Using config: {config_path}[/dim]") + else: + config_path = get_config_path() + def _apply_workspace_override(loaded: Config) -> Config: + if workspace: + loaded.agents.defaults.workspace = workspace + return loaded + + # Create or update config if config_path.exists(): console.print(f"[yellow]Config already exists at {config_path}[/yellow]") console.print(" [bold]y[/bold] = overwrite with defaults (existing values will be lost)") console.print(" [bold]N[/bold] = refresh config, keeping existing values and adding new fields") if typer.confirm("Overwrite?"): - config = Config() - save_config(config) + config = _apply_workspace_override(Config()) + save_config(config, config_path) console.print(f"[green]✓[/green] Config reset to defaults at {config_path}") else: - config = load_config() - save_config(config) + 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: - config = Config() - save_config(config) + config = _apply_workspace_override(Config()) + save_config(config, config_path) console.print(f"[green]✓[/green] Created config at {config_path}") console.print("[dim]Config template now uses `maxTokens` + `contextWindowTokens`; `memoryWindow` is no longer a runtime setting.[/dim]") @@ -297,11 +311,15 @@ def onboard(): sync_workspace_templates(workspace) + agent_cmd = 'nanobot agent -m "Hello!"' + if config: + agent_cmd += f" --config {config_path}" + console.print(f"\n{__logo__} nanobot is ready!") console.print("\nNext steps:") - console.print(" 1. Add your API key to [cyan]~/.nanobot/config.json[/cyan]") + console.print(f" 1. Add your API key to [cyan]{config_path}[/cyan]") console.print(" Get one at: https://openrouter.ai/keys") - console.print(" 2. Chat: [cyan]nanobot agent -m \"Hello!\"[/cyan]") + console.print(f" 2. Chat: [cyan]{agent_cmd}[/cyan]") console.print("\n[dim]Want Telegram/WhatsApp? See: https://github.com/HKUDS/nanobot#-chat-apps[/dim]") diff --git a/tests/test_commands.py b/tests/test_commands.py index ec4463d..c920e00 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1,3 +1,5 @@ +import json +import re import shutil from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch @@ -11,6 +13,13 @@ from nanobot.providers.litellm_provider import LiteLLMProvider from nanobot.providers.openai_codex_provider import _strip_model_prefix from nanobot.providers.registry import find_by_model + +def _strip_ansi(text: str) -> str: + """Remove ANSI escape codes from CLI output before assertions.""" + ansi_escape = re.compile(r"\x1b\[[0-9;]*m") + return ansi_escape.sub("", text) + + runner = CliRunner() @@ -36,7 +45,14 @@ def mock_paths(): mock_cp.return_value = config_file mock_ws.return_value = workspace_dir - mock_sc.side_effect = lambda config: config_file.write_text("{}") + mock_lc.side_effect = lambda _config_path=None: Config() + + def _save_config(config: Config, config_path: Path | None = None): + target = config_path or config_file + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(json.dumps(config.model_dump(by_alias=True)), encoding="utf-8") + + mock_sc.side_effect = _save_config yield config_file, workspace_dir, mock_ws @@ -102,6 +118,40 @@ def test_onboard_existing_workspace_safe_create(mock_paths): assert (workspace_dir / "AGENTS.md").exists() +def test_onboard_help_shows_workspace_and_config_options(): + result = runner.invoke(app, ["onboard", "--help"]) + + assert result.exit_code == 0 + stripped_output = _strip_ansi(result.stdout) + assert "--workspace" in stripped_output + assert "-w" in stripped_output + assert "--config" in stripped_output + assert "-c" in stripped_output + assert "--dir" not in stripped_output + + +def test_onboard_uses_explicit_config_and_workspace_paths(tmp_path, monkeypatch): + config_path = tmp_path / "instance" / "config.json" + workspace_path = tmp_path / "workspace" + + monkeypatch.setattr("nanobot.channels.registry.discover_all", lambda: {}) + + result = runner.invoke( + app, + ["onboard", "--config", str(config_path), "--workspace", str(workspace_path)], + ) + + assert result.exit_code == 0 + saved = Config.model_validate(json.loads(config_path.read_text(encoding="utf-8"))) + assert saved.workspace_path == workspace_path + assert (workspace_path / "AGENTS.md").exists() + stripped_output = _strip_ansi(result.stdout) + compact_output = stripped_output.replace("\n", "") + resolved_config = str(config_path.resolve()) + assert resolved_config in compact_output + assert f"--config {resolved_config}" in compact_output + + def test_config_matches_github_copilot_codex_with_hyphen_prefix(): config = Config() config.agents.defaults.model = "github-copilot/gpt-5.3-codex"