feat(cli): add workspace and config flags to agent
This commit is contained in:
14
README.md
14
README.md
@@ -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 |
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user