diff --git a/README.md b/README.md index 0bb6efe..13971e2 100644 --- a/README.md +++ b/README.md @@ -722,6 +722,12 @@ nanobot provider login openai-codex **3. Chat:** ```bash nanobot agent -m "Hello!" + +# Target a specific workspace/config locally +nanobot agent -c ~/.nanobot-telegram/config.json -m "Hello!" + +# One-off workspace override on top of that config +nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test -m "Hello!" ``` > Docker users: use `docker run -it` for interactive OAuth login. @@ -924,6 +930,18 @@ nanobot gateway --config ~/.nanobot-feishu/config.json --port 18792 When using `--config`, nanobot derives its runtime data directory from the config file location. The workspace still comes from `agents.defaults.workspace` unless you override it with `--workspace`. +To open a CLI session against one of these instances locally: + +```bash +nanobot agent -c ~/.nanobot-telegram/config.json -m "Hello from Telegram instance" +nanobot agent -c ~/.nanobot-discord/config.json -m "Hello from Discord instance" + +# Optional one-off workspace override +nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test +``` + +> `nanobot agent` starts a local CLI agent using the selected workspace/config. It does not attach to or proxy through an already running `nanobot gateway` process. + | Component | Resolved From | Example | |-----------|---------------|---------| | **Config** | `--config` path | `~/.nanobot-A/config.json` | @@ -998,6 +1016,8 @@ nanobot gateway --config ~/.nanobot-telegram/config.json --workspace /tmp/nanobo |---------|-------------| | `nanobot onboard` | Initialize config & 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 | | `nanobot agent` | Interactive chat mode | | `nanobot agent --no-markdown` | Show plain-text replies | | `nanobot agent --logs` | Show runtime logs during chat | diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index da8906d..2c8d6d3 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -9,7 +9,6 @@ from pathlib import Path # Force UTF-8 encoding for Windows console if sys.platform == "win32": - import locale if sys.stdout.encoding != "utf-8": os.environ["PYTHONIOENCODING"] = "utf-8" # Re-open stdout/stderr with UTF-8 encoding @@ -265,6 +264,25 @@ def _make_provider(config: Config): ) +def _load_runtime_config(config: str | None = None, workspace: str | None = None) -> Config: + """Load config and optionally override the active workspace.""" + from nanobot.config.loader import load_config, set_config_path + + config_path = None + if config: + config_path = Path(config).expanduser().resolve() + if not config_path.exists(): + console.print(f"[red]Error: Config file not found: {config_path}[/red]") + raise typer.Exit(1) + set_config_path(config_path) + console.print(f"[dim]Using config: {config_path}[/dim]") + + loaded = load_config(config_path) + if workspace: + loaded.agents.defaults.workspace = workspace + return loaded + + # ============================================================================ # Gateway / Server # ============================================================================ @@ -278,20 +296,9 @@ def gateway( config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"), ): """Start the nanobot gateway.""" - # Set config path if provided (must be done before any imports that use get_data_dir) - if config: - from nanobot.config.loader import set_config_path - config_path = Path(config).expanduser().resolve() - if not config_path.exists(): - console.print(f"[red]Error: Config file not found: {config_path}[/red]") - raise typer.Exit(1) - set_config_path(config_path) - console.print(f"[dim]Using config: {config_path}[/dim]") - from nanobot.agent.loop import AgentLoop from nanobot.bus.queue import MessageBus from nanobot.channels.manager import ChannelManager - from nanobot.config.loader import load_config from nanobot.config.paths import get_cron_dir from nanobot.cron.service import CronService from nanobot.cron.types import CronJob @@ -302,11 +309,9 @@ def gateway( import logging logging.basicConfig(level=logging.DEBUG) - console.print(f"{__logo__} Starting nanobot gateway on port {port}...") + config = _load_runtime_config(config, workspace) - config = load_config() - if workspace: - config.agents.defaults.workspace = workspace + console.print(f"{__logo__} Starting nanobot gateway on port {port}...") sync_workspace_templates(config.workspace_path) bus = MessageBus() provider = _make_provider(config) @@ -474,6 +479,8 @@ def gateway( def agent( message: str = typer.Option(None, "--message", "-m", help="Message to send to the agent"), session_id: str = typer.Option("cli:direct", "--session", "-s", help="Session ID"), + workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"), + config: str | None = typer.Option(None, "--config", "-c", help="Config file path"), markdown: bool = typer.Option(True, "--markdown/--no-markdown", help="Render assistant output as Markdown"), logs: bool = typer.Option(False, "--logs/--no-logs", help="Show nanobot runtime logs during chat"), ): @@ -482,11 +489,10 @@ def agent( from nanobot.agent.loop import AgentLoop from nanobot.bus.queue import MessageBus - from nanobot.config.loader import load_config from nanobot.config.paths import get_cron_dir from nanobot.cron.service import CronService - config = load_config() + config = _load_runtime_config(config, workspace) sync_workspace_templates(config.workspace_path) bus = MessageBus() diff --git a/tests/test_commands.py b/tests/test_commands.py index a276653..19c1998 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1,6 +1,6 @@ import shutil from pathlib import Path -from unittest.mock import patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from typer.testing import CliRunner @@ -134,6 +134,139 @@ def test_openai_codex_strip_prefix_supports_hyphen_and_underscore(): assert _strip_model_prefix("openai_codex/gpt-5.1-codex") == "gpt-5.1-codex" +@pytest.fixture +def mock_agent_runtime(tmp_path): + """Mock agent command dependencies for focused CLI tests.""" + config = Config() + config.agents.defaults.workspace = str(tmp_path / "default-workspace") + cron_dir = tmp_path / "data" / "cron" + + with patch("nanobot.config.loader.load_config", return_value=config) as mock_load_config, \ + patch("nanobot.config.paths.get_cron_dir", return_value=cron_dir), \ + patch("nanobot.cli.commands.sync_workspace_templates") as mock_sync_templates, \ + patch("nanobot.cli.commands._make_provider", return_value=object()), \ + patch("nanobot.cli.commands._print_agent_response") as mock_print_response, \ + patch("nanobot.bus.queue.MessageBus"), \ + patch("nanobot.cron.service.CronService"), \ + patch("nanobot.agent.loop.AgentLoop") as mock_agent_loop_cls: + + agent_loop = MagicMock() + agent_loop.channels_config = None + agent_loop.process_direct = AsyncMock(return_value="mock-response") + agent_loop.close_mcp = AsyncMock(return_value=None) + mock_agent_loop_cls.return_value = agent_loop + + yield { + "config": config, + "load_config": mock_load_config, + "sync_templates": mock_sync_templates, + "agent_loop_cls": mock_agent_loop_cls, + "agent_loop": agent_loop, + "print_response": mock_print_response, + } + + +def test_agent_help_shows_workspace_and_config_options(): + result = runner.invoke(app, ["agent", "--help"]) + + assert result.exit_code == 0 + assert "--workspace" in result.stdout + assert "-w" in result.stdout + assert "--config" in result.stdout + assert "-c" in result.stdout + + +def test_agent_uses_default_config_when_no_workspace_or_config_flags(mock_agent_runtime): + result = runner.invoke(app, ["agent", "-m", "hello"]) + + assert result.exit_code == 0 + assert mock_agent_runtime["load_config"].call_args.args == (None,) + assert mock_agent_runtime["sync_templates"].call_args.args == ( + mock_agent_runtime["config"].workspace_path, + ) + assert mock_agent_runtime["agent_loop_cls"].call_args.kwargs["workspace"] == ( + mock_agent_runtime["config"].workspace_path + ) + mock_agent_runtime["agent_loop"].process_direct.assert_awaited_once() + mock_agent_runtime["print_response"].assert_called_once_with("mock-response", render_markdown=True) + + +def test_agent_uses_explicit_config_path(mock_agent_runtime, tmp_path: Path): + config_path = tmp_path / "agent-config.json" + config_path.write_text("{}") + + result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_path)]) + + assert result.exit_code == 0 + assert mock_agent_runtime["load_config"].call_args.args == (config_path.resolve(),) + + +def test_agent_config_sets_active_path(monkeypatch, tmp_path: Path) -> None: + config_file = tmp_path / "instance" / "config.json" + config_file.parent.mkdir(parents=True) + config_file.write_text("{}") + + config = Config() + seen: dict[str, Path] = {} + + monkeypatch.setattr( + "nanobot.config.loader.set_config_path", + lambda path: seen.__setitem__("config_path", path), + ) + monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) + monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: config_file.parent / "cron") + monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None) + monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object()) + monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object()) + monkeypatch.setattr("nanobot.cron.service.CronService", lambda _store: object()) + + class _FakeAgentLoop: + def __init__(self, *args, **kwargs) -> None: + pass + + async def process_direct(self, *_args, **_kwargs) -> str: + return "ok" + + async def close_mcp(self) -> None: + return None + + monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop) + monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None) + + result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)]) + + assert result.exit_code == 0 + assert seen["config_path"] == config_file.resolve() + + +def test_agent_overrides_workspace_path(mock_agent_runtime): + workspace_path = Path("/tmp/agent-workspace") + + result = runner.invoke(app, ["agent", "-m", "hello", "-w", str(workspace_path)]) + + assert result.exit_code == 0 + assert mock_agent_runtime["config"].agents.defaults.workspace == str(workspace_path) + assert mock_agent_runtime["sync_templates"].call_args.args == (workspace_path,) + assert mock_agent_runtime["agent_loop_cls"].call_args.kwargs["workspace"] == workspace_path + + +def test_agent_workspace_override_wins_over_config_workspace(mock_agent_runtime, tmp_path: Path): + config_path = tmp_path / "agent-config.json" + config_path.write_text("{}") + workspace_path = Path("/tmp/agent-workspace") + + result = runner.invoke( + app, + ["agent", "-m", "hello", "-c", str(config_path), "-w", str(workspace_path)], + ) + + assert result.exit_code == 0 + assert mock_agent_runtime["load_config"].call_args.args == (config_path.resolve(),) + assert mock_agent_runtime["config"].agents.defaults.workspace == str(workspace_path) + assert mock_agent_runtime["sync_templates"].call_args.args == (workspace_path,) + assert mock_agent_runtime["agent_loop_cls"].call_args.kwargs["workspace"] == workspace_path + + def test_gateway_uses_workspace_from_config_by_default(monkeypatch, tmp_path: Path) -> None: config_file = tmp_path / "instance" / "config.json" config_file.parent.mkdir(parents=True) @@ -147,7 +280,7 @@ def test_gateway_uses_workspace_from_config_by_default(monkeypatch, tmp_path: Pa "nanobot.config.loader.set_config_path", lambda path: seen.__setitem__("config_path", path), ) - monkeypatch.setattr("nanobot.config.loader.load_config", lambda: config) + monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) monkeypatch.setattr( "nanobot.cli.commands.sync_workspace_templates", lambda path: seen.__setitem__("workspace", path), @@ -175,7 +308,7 @@ def test_gateway_workspace_option_overrides_config(monkeypatch, tmp_path: Path) seen: dict[str, Path] = {} monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None) - monkeypatch.setattr("nanobot.config.loader.load_config", lambda: config) + monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) monkeypatch.setattr( "nanobot.cli.commands.sync_workspace_templates", lambda path: seen.__setitem__("workspace", path), @@ -205,7 +338,7 @@ def test_gateway_uses_config_directory_for_cron_store(monkeypatch, tmp_path: Pat seen: dict[str, Path] = {} monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None) - monkeypatch.setattr("nanobot.config.loader.load_config", lambda: config) + monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: config_file.parent / "cron") monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None) monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object())