feat(cli): add workspace and config flags to agent

This commit is contained in:
Maciej Wojcik
2026-03-06 20:32:10 +00:00
parent 0409d72579
commit 79f3ca4f12
3 changed files with 125 additions and 10 deletions

View File

@@ -710,6 +710,9 @@ nanobot provider login openai-codex
**3. Chat:** **3. Chat:**
```bash ```bash
nanobot agent -m "Hello!" nanobot agent -m "Hello!"
# Target a specific workspace/config locally
nanobot agent -w ~/.nanobot/botA -c ~/.nanobot/botA/config.json -m "Hello!"
``` ```
> Docker users: use `docker run -it` for interactive OAuth login. > Docker users: use `docker run -it` for interactive OAuth login.
@@ -917,6 +920,15 @@ Each instance has its own:
- Cron jobs storage (`workspace/cron/jobs.json`) - Cron jobs storage (`workspace/cron/jobs.json`)
- Configuration (if using `--config`) - Configuration (if using `--config`)
To open a CLI session against one of these instances locally:
```bash
nanobot agent -w ~/.nanobot/botA -m "Hello from botA"
nanobot agent -w ~/.nanobot/botC -c ~/.nanobot/botC/config.json
```
> `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.
## CLI Reference ## CLI Reference
@@ -924,6 +936,8 @@ Each instance has its own:
|---------|-------------| |---------|-------------|
| `nanobot onboard` | Initialize config & workspace | | `nanobot onboard` | Initialize config & workspace |
| `nanobot agent -m "..."` | Chat with the agent | | `nanobot agent -m "..."` | Chat with the agent |
| `nanobot agent -w <workspace>` | Chat against a specific workspace |
| `nanobot agent -w <workspace> -c <config>` | Chat against a specific workspace/config |
| `nanobot agent` | Interactive chat mode | | `nanobot agent` | Interactive chat mode |
| `nanobot agent --no-markdown` | Show plain-text replies | | `nanobot agent --no-markdown` | Show plain-text replies |
| `nanobot agent --logs` | Show runtime logs during chat | | `nanobot agent --logs` | Show runtime logs during chat |

View File

@@ -9,7 +9,6 @@ from pathlib import Path
# Force UTF-8 encoding for Windows console # Force UTF-8 encoding for Windows console
if sys.platform == "win32": if sys.platform == "win32":
import locale
if sys.stdout.encoding != "utf-8": if sys.stdout.encoding != "utf-8":
os.environ["PYTHONIOENCODING"] = "utf-8" os.environ["PYTHONIOENCODING"] = "utf-8"
# Re-open stdout/stderr with UTF-8 encoding # Re-open stdout/stderr with UTF-8 encoding
@@ -248,6 +247,17 @@ 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
config_path = Path(config) if config else None
loaded = load_config(config_path)
if workspace:
loaded.agents.defaults.workspace = workspace
return loaded
# ============================================================================ # ============================================================================
# Gateway / Server # Gateway / Server
# ============================================================================ # ============================================================================
@@ -264,7 +274,6 @@ def gateway(
from nanobot.agent.loop import AgentLoop from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.manager import ChannelManager from nanobot.channels.manager import ChannelManager
from nanobot.config.loader import load_config
from nanobot.cron.service import CronService from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob from nanobot.cron.types import CronJob
from nanobot.heartbeat.service import HeartbeatService from nanobot.heartbeat.service import HeartbeatService
@@ -274,10 +283,7 @@ def gateway(
import logging import logging
logging.basicConfig(level=logging.DEBUG) logging.basicConfig(level=logging.DEBUG)
config_path = Path(config) if config else None config = _load_runtime_config(config, workspace)
config = load_config(config_path)
if workspace:
config.agents.defaults.workspace = workspace
console.print(f"{__logo__} Starting nanobot gateway on port {port}...") console.print(f"{__logo__} Starting nanobot gateway on port {port}...")
sync_workspace_templates(config.workspace_path) sync_workspace_templates(config.workspace_path)
@@ -448,6 +454,8 @@ def gateway(
def agent( def agent(
message: str = typer.Option(None, "--message", "-m", help="Message to send to the 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"), 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"), 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"), logs: bool = typer.Option(False, "--logs/--no-logs", help="Show nanobot runtime logs during chat"),
): ):
@@ -456,10 +464,10 @@ def agent(
from nanobot.agent.loop import AgentLoop from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.config.loader import get_data_dir, load_config from nanobot.config.loader import get_data_dir
from nanobot.cron.service import CronService from nanobot.cron.service import CronService
config = load_config() config = _load_runtime_config(config, workspace)
sync_workspace_templates(config.workspace_path) sync_workspace_templates(config.workspace_path)
bus = MessageBus() bus = MessageBus()

View File

@@ -1,6 +1,6 @@
import shutil import shutil
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from typer.testing import CliRunner from typer.testing import CliRunner
@@ -19,7 +19,7 @@ def mock_paths():
"""Mock config/workspace paths for test isolation.""" """Mock config/workspace paths for test isolation."""
with patch("nanobot.config.loader.get_config_path") as mock_cp, \ with patch("nanobot.config.loader.get_config_path") as mock_cp, \
patch("nanobot.config.loader.save_config") as mock_sc, \ patch("nanobot.config.loader.save_config") as mock_sc, \
patch("nanobot.config.loader.load_config") as mock_lc, \ patch("nanobot.config.loader.load_config"), \
patch("nanobot.utils.helpers.get_workspace_path") as mock_ws: patch("nanobot.utils.helpers.get_workspace_path") as mock_ws:
base_dir = Path("./test_onboard_data") base_dir = Path("./test_onboard_data")
@@ -128,3 +128,96 @@ def test_litellm_provider_canonicalizes_github_copilot_hyphen_prefix():
def test_openai_codex_strip_prefix_supports_hyphen_and_underscore(): def test_openai_codex_strip_prefix_supports_hyphen_and_underscore():
assert _strip_model_prefix("openai-codex/gpt-5.1-codex") == "gpt-5.1-codex" assert _strip_model_prefix("openai-codex/gpt-5.1-codex") == "gpt-5.1-codex"
assert _strip_model_prefix("openai_codex/gpt-5.1-codex") == "gpt-5.1-codex" 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")
data_dir = tmp_path / "data"
with patch("nanobot.config.loader.load_config", return_value=config) as mock_load_config, \
patch("nanobot.config.loader.get_data_dir", return_value=data_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):
config_path = Path("/tmp/agent-config.json")
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,)
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):
config_path = Path("/tmp/agent-config.json")
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,)
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