From 4e4d40ef333177da2aca9540d8d3f8266c6725d5 Mon Sep 17 00:00:00 2001 From: samsonchoi Date: Thu, 5 Mar 2026 23:48:45 +0800 Subject: [PATCH 1/3] feat: multi-instance support with --config parameter Add support for running multiple nanobot instances with complete isolation: - Add --config parameter to gateway command for custom config file path - Implement set_config_path() in config/loader.py for dynamic config path - Derive data directory from config file location (e.g., ~/.nanobot-xxx/) - Update get_data_path() to use unified data directory from config loader - Ensure cron jobs use instance-specific data directory This enables running multiple isolated nanobot instances by specifying different config files, with each instance maintaining separate: - Configuration files - Workspace (memory, sessions, skills) - Cron jobs - Logs and media Example usage: nanobot gateway --config ~/.nanobot-instance2/config.json --port 18791 --- nanobot/cli/commands.py | 25 +++++++++++++++---------- nanobot/config/loader.py | 22 ++++++++++++++++++---- nanobot/utils/helpers.py | 5 +++-- 3 files changed, 36 insertions(+), 16 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index aca0778..097e41c 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -244,15 +244,24 @@ def _make_provider(config: Config): @app.command() def gateway( port: int = typer.Option(18790, "--port", "-p", help="Gateway port"), - workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"), - config: str | None = typer.Option(None, "--config", "-c", help="Config file path"), verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"), + config: str = 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.loader import get_data_dir, load_config from nanobot.cron.service import CronService from nanobot.cron.types import CronJob from nanobot.heartbeat.service import HeartbeatService @@ -262,20 +271,16 @@ def gateway( import logging logging.basicConfig(level=logging.DEBUG) - config_path = Path(config) if config else None - config = load_config(config_path) - if workspace: - config.agents.defaults.workspace = workspace - console.print(f"{__logo__} Starting nanobot gateway on port {port}...") + + config = load_config() sync_workspace_templates(config.workspace_path) bus = MessageBus() provider = _make_provider(config) session_manager = SessionManager(config.workspace_path) # Create cron service first (callback set after agent creation) - # Use workspace path for per-instance cron store - cron_store_path = config.workspace_path / "cron" / "jobs.json" + cron_store_path = get_data_dir() / "cron" / "jobs.json" cron = CronService(cron_store_path) # Create agent with cron service diff --git a/nanobot/config/loader.py b/nanobot/config/loader.py index c789efd..4355bd3 100644 --- a/nanobot/config/loader.py +++ b/nanobot/config/loader.py @@ -6,15 +6,29 @@ from pathlib import Path from nanobot.config.schema import Config +# Global variable to store current config path (for multi-instance support) +_current_config_path: Path | None = None + + +def set_config_path(path: Path) -> None: + """Set the current config path (used to derive data directory).""" + global _current_config_path + _current_config_path = path + + def get_config_path() -> Path: - """Get the default configuration file path.""" + """Get the configuration file path.""" + if _current_config_path: + return _current_config_path return Path.home() / ".nanobot" / "config.json" def get_data_dir() -> Path: - """Get the nanobot data directory.""" - from nanobot.utils.helpers import get_data_path - return get_data_path() + """Get the nanobot data directory (derived from config path).""" + config_path = get_config_path() + # If config is ~/.nanobot-xxx/config.json, data dir is ~/.nanobot-xxx/ + # If config is ~/.nanobot/config.json, data dir is ~/.nanobot/ + return config_path.parent def load_config(config_path: Path | None = None) -> Config: diff --git a/nanobot/utils/helpers.py b/nanobot/utils/helpers.py index 3a8c802..e244829 100644 --- a/nanobot/utils/helpers.py +++ b/nanobot/utils/helpers.py @@ -12,8 +12,9 @@ def ensure_dir(path: Path) -> Path: def get_data_path() -> Path: - """~/.nanobot data directory.""" - return ensure_dir(Path.home() / ".nanobot") + """Get nanobot data directory (derived from config path).""" + from nanobot.config.loader import get_data_dir + return ensure_dir(get_data_dir()) def get_workspace_path(workspace: str | None = None) -> Path: From 858b136f30a6206ac8f005dfebcd9ae142e64f47 Mon Sep 17 00:00:00 2001 From: samsonchoi Date: Fri, 6 Mar 2026 17:57:21 +0800 Subject: [PATCH 2/3] docs: add comprehensive multi-instance configuration guide - Add detailed setup examples with directory structure - Document complete isolation mechanism (config, workspace, cron, logs, media) - Include use cases and production deployment patterns - Add management scripts for systemd (Linux) and launchd (macOS) - Provide step-by-step configuration examples --- README.md | 224 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 209 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index fc0a1fb..685817f 100644 --- a/README.md +++ b/README.md @@ -892,30 +892,224 @@ MCP tools are automatically discovered and registered on startup. The LLM can us ## Multiple Instances -Run multiple nanobot instances simultaneously, each with its own workspace and configuration. +Run multiple nanobot instances simultaneously with complete isolation. Each instance has its own configuration, workspace, cron jobs, logs, and media storage. + +### Quick Start ```bash # Instance A - Telegram bot -nanobot gateway -w ~/.nanobot/botA -p 18791 +nanobot gateway --config ~/.nanobot-telegram/config.json -# Instance B - Discord bot -nanobot gateway -w ~/.nanobot/botB -p 18792 +# Instance B - Discord bot +nanobot gateway --config ~/.nanobot-discord/config.json -# Instance C - Using custom config file -nanobot gateway -w ~/.nanobot/botC -c ~/.nanobot/botC/config.json -p 18793 +# Instance C - Feishu bot with custom port +nanobot gateway --config ~/.nanobot-feishu/config.json --port 18792 ``` -| Option | Short | Description | -|--------|-------|-------------| -| `--workspace` | `-w` | Workspace directory (default: `~/.nanobot/workspace`) | -| `--config` | `-c` | Config file path (default: `~/.nanobot/config.json`) | -| `--port` | `-p` | Gateway port (default: `18790`) | +### Complete Isolation -Each instance has its own: -- Workspace directory (MEMORY.md, HEARTBEAT.md, session files) -- Cron jobs storage (`workspace/cron/jobs.json`) -- Configuration (if using `--config`) +When using `--config` parameter, nanobot automatically derives the data directory from the config file path, ensuring complete isolation: +| Component | Isolation | Example | +|-----------|-----------|---------| +| **Config** | Separate config files | `~/.nanobot-A/config.json`, `~/.nanobot-B/config.json` | +| **Workspace** | Independent memory, sessions, skills | `~/.nanobot-A/workspace/`, `~/.nanobot-B/workspace/` | +| **Cron Jobs** | Separate job storage | `~/.nanobot-A/cron/`, `~/.nanobot-B/cron/` | +| **Logs** | Independent log files | `~/.nanobot-A/logs/`, `~/.nanobot-B/logs/` | +| **Media** | Separate media storage | `~/.nanobot-A/media/`, `~/.nanobot-B/media/` | + +### Setup Example + +**1. Create directory structure for each instance:** + +```bash +# Instance A +mkdir -p ~/.nanobot-telegram/{workspace,cron,logs,media} +cp ~/.nanobot/config.json ~/.nanobot-telegram/config.json + +# Instance B +mkdir -p ~/.nanobot-discord/{workspace,cron,logs,media} +cp ~/.nanobot/config.json ~/.nanobot-discord/config.json +``` + +**2. Configure each instance:** + +Edit `~/.nanobot-telegram/config.json`: +```json +{ + "agents": { + "defaults": { + "workspace": "~/.nanobot-telegram/workspace", + "model": "anthropic/claude-sonnet-4-6" + } + }, + "channels": { + "telegram": { + "enabled": true, + "token": "YOUR_TELEGRAM_BOT_TOKEN" + } + }, + "gateway": { + "port": 18790 + } +} +``` + +Edit `~/.nanobot-discord/config.json`: +```json +{ + "agents": { + "defaults": { + "workspace": "~/.nanobot-discord/workspace", + "model": "anthropic/claude-opus-4" + } + }, + "channels": { + "discord": { + "enabled": true, + "token": "YOUR_DISCORD_BOT_TOKEN" + } + }, + "gateway": { + "port": 18791 + } +} +``` + +**3. Start instances:** + +```bash +# Terminal 1 +nanobot gateway --config ~/.nanobot-telegram/config.json + +# Terminal 2 +nanobot gateway --config ~/.nanobot-discord/config.json +``` + +### Use Cases + +- **Multiple Chat Platforms**: Run separate bots for Telegram, Discord, Feishu, etc. +- **Different Models**: Test different LLM models (Claude, GPT, DeepSeek) simultaneously +- **Role Separation**: Dedicated instances for different purposes (personal assistant, work bot, research agent) +- **Multi-Tenant**: Serve multiple users/teams with isolated configurations + +### Management Scripts + +For production deployments, create management scripts for each instance: + +```bash +#!/bin/bash +# manage-telegram.sh + +INSTANCE_NAME="telegram" +CONFIG_FILE="$HOME/.nanobot-telegram/config.json" +LOG_FILE="$HOME/.nanobot-telegram/logs/stderr.log" + +case "$1" in + start) + nohup nanobot gateway --config "$CONFIG_FILE" >> "$LOG_FILE" 2>&1 & + echo "Started $INSTANCE_NAME instance (PID: $!)" + ;; + stop) + pkill -f "nanobot gateway.*$CONFIG_FILE" + echo "Stopped $INSTANCE_NAME instance" + ;; + restart) + $0 stop + sleep 2 + $0 start + ;; + status) + pgrep -f "nanobot gateway.*$CONFIG_FILE" > /dev/null + if [ $? -eq 0 ]; then + echo "$INSTANCE_NAME instance is running" + else + echo "$INSTANCE_NAME instance is not running" + fi + ;; + *) + echo "Usage: $0 {start|stop|restart|status}" + exit 1 + ;; +esac +``` + +### systemd Service (Linux) + +For automatic startup and crash recovery, create a systemd service for each instance: + +```ini +# ~/.config/systemd/user/nanobot-telegram.service +[Unit] +Description=Nanobot Telegram Instance +After=network.target + +[Service] +Type=simple +ExecStart=%h/.local/bin/nanobot gateway --config %h/.nanobot-telegram/config.json +Restart=always +RestartSec=10 + +[Install] +WantedBy=default.target +``` + +Enable and start: +```bash +systemctl --user daemon-reload +systemctl --user enable --now nanobot-telegram +systemctl --user enable --now nanobot-discord +``` + +### launchd Service (macOS) + +Create a plist file for each instance: + +```xml + + + + + + Label + com.nanobot.telegram + + ProgramArguments + + /path/to/nanobot + gateway + --config + /Users/yourname/.nanobot-telegram/config.json + + + RunAtLoad + + + KeepAlive + + + StandardOutPath + /Users/yourname/.nanobot-telegram/logs/stdout.log + + StandardErrorPath + /Users/yourname/.nanobot-telegram/logs/stderr.log + + +``` + +Load the service: +```bash +launchctl load ~/Library/LaunchAgents/com.nanobot.telegram.plist +launchctl load ~/Library/LaunchAgents/com.nanobot.discord.plist +``` + +### Notes + +- Each instance must use a different port (default: 18790) +- Instances are completely independent — no shared state or cross-talk +- You can run different LLM models, providers, and channel configurations per instance +- Memory, sessions, and cron jobs are fully isolated between instances ## CLI Reference From 20dfaa5d34968cf8d3f19a180e053f145a7dfad3 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 8 Mar 2026 02:58:25 +0000 Subject: [PATCH 3/3] refactor: unify instance path resolution and preserve workspace override --- README.md | 192 ++++++----------------------------- bridge/src/whatsapp.ts | 3 +- nanobot/channels/discord.py | 3 +- nanobot/channels/feishu.py | 4 +- nanobot/channels/matrix.py | 6 +- nanobot/channels/mochat.py | 4 +- nanobot/channels/telegram.py | 6 +- nanobot/cli/commands.py | 27 +++-- nanobot/config/__init__.py | 26 ++++- nanobot/config/loader.py | 8 -- nanobot/config/paths.py | 55 ++++++++++ nanobot/session/manager.py | 3 +- nanobot/utils/__init__.py | 4 +- nanobot/utils/helpers.py | 12 --- tests/test_commands.py | 97 +++++++++++++++++- tests/test_config_paths.py | 42 ++++++++ 16 files changed, 282 insertions(+), 210 deletions(-) create mode 100644 nanobot/config/paths.py create mode 100644 tests/test_config_paths.py diff --git a/README.md b/README.md index fdbd5cf..5bd11f8 100644 --- a/README.md +++ b/README.md @@ -905,7 +905,7 @@ MCP tools are automatically discovered and registered on startup. The LLM can us ## Multiple Instances -Run multiple nanobot instances simultaneously with complete isolation. Each instance has its own configuration, workspace, cron jobs, logs, and media storage. +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. ### Quick Start @@ -920,35 +920,31 @@ nanobot gateway --config ~/.nanobot-discord/config.json nanobot gateway --config ~/.nanobot-feishu/config.json --port 18792 ``` -### Complete Isolation +### Path Resolution -When using `--config` parameter, nanobot automatically derives the data directory from the config file path, ensuring complete isolation: +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`. -| Component | Isolation | Example | -|-----------|-----------|---------| -| **Config** | Separate config files | `~/.nanobot-A/config.json`, `~/.nanobot-B/config.json` | -| **Workspace** | Independent memory, sessions, skills | `~/.nanobot-A/workspace/`, `~/.nanobot-B/workspace/` | -| **Cron Jobs** | Separate job storage | `~/.nanobot-A/cron/`, `~/.nanobot-B/cron/` | -| **Logs** | Independent log files | `~/.nanobot-A/logs/`, `~/.nanobot-B/logs/` | -| **Media** | Separate media storage | `~/.nanobot-A/media/`, `~/.nanobot-B/media/` | +| Component | Resolved From | Example | +|-----------|---------------|---------| +| **Config** | `--config` path | `~/.nanobot-A/config.json` | +| **Workspace** | `--workspace` or config | `~/.nanobot-A/workspace/` | +| **Cron Jobs** | config directory | `~/.nanobot-A/cron/` | +| **Media / runtime state** | config directory | `~/.nanobot-A/media/` | -### Setup Example +### How It Works -**1. Create directory structure for each instance:** +- `--config` selects which config file to load +- By default, the workspace comes from `agents.defaults.workspace` in that config +- If you pass `--workspace`, it overrides the workspace from the config file -```bash -# Instance A -mkdir -p ~/.nanobot-telegram/{workspace,cron,logs,media} -cp ~/.nanobot/config.json ~/.nanobot-telegram/config.json +### Minimal Setup -# Instance B -mkdir -p ~/.nanobot-discord/{workspace,cron,logs,media} -cp ~/.nanobot/config.json ~/.nanobot-discord/config.json -``` +1. Copy your base config into a new instance directory. +2. Set a different `agents.defaults.workspace` for that instance. +3. Start the instance with `--config`. -**2. Configure each instance:** +Example config: -Edit `~/.nanobot-telegram/config.json`: ```json { "agents": { @@ -969,160 +965,32 @@ Edit `~/.nanobot-telegram/config.json`: } ``` -Edit `~/.nanobot-discord/config.json`: -```json -{ - "agents": { - "defaults": { - "workspace": "~/.nanobot-discord/workspace", - "model": "anthropic/claude-opus-4" - } - }, - "channels": { - "discord": { - "enabled": true, - "token": "YOUR_DISCORD_BOT_TOKEN" - } - }, - "gateway": { - "port": 18791 - } -} -``` - -**3. Start instances:** +Start separate instances: ```bash -# Terminal 1 nanobot gateway --config ~/.nanobot-telegram/config.json - -# Terminal 2 nanobot gateway --config ~/.nanobot-discord/config.json ``` -### Use Cases - -- **Multiple Chat Platforms**: Run separate bots for Telegram, Discord, Feishu, etc. -- **Different Models**: Test different LLM models (Claude, GPT, DeepSeek) simultaneously -- **Role Separation**: Dedicated instances for different purposes (personal assistant, work bot, research agent) -- **Multi-Tenant**: Serve multiple users/teams with isolated configurations - -### Management Scripts - -For production deployments, create management scripts for each instance: +Override workspace for one-off runs when needed: ```bash -#!/bin/bash -# manage-telegram.sh - -INSTANCE_NAME="telegram" -CONFIG_FILE="$HOME/.nanobot-telegram/config.json" -LOG_FILE="$HOME/.nanobot-telegram/logs/stderr.log" - -case "$1" in - start) - nohup nanobot gateway --config "$CONFIG_FILE" >> "$LOG_FILE" 2>&1 & - echo "Started $INSTANCE_NAME instance (PID: $!)" - ;; - stop) - pkill -f "nanobot gateway.*$CONFIG_FILE" - echo "Stopped $INSTANCE_NAME instance" - ;; - restart) - $0 stop - sleep 2 - $0 start - ;; - status) - pgrep -f "nanobot gateway.*$CONFIG_FILE" > /dev/null - if [ $? -eq 0 ]; then - echo "$INSTANCE_NAME instance is running" - else - echo "$INSTANCE_NAME instance is not running" - fi - ;; - *) - echo "Usage: $0 {start|stop|restart|status}" - exit 1 - ;; -esac +nanobot gateway --config ~/.nanobot-telegram/config.json --workspace /tmp/nanobot-telegram-test ``` -### systemd Service (Linux) +### Common Use Cases -For automatic startup and crash recovery, create a systemd service for each instance: - -```ini -# ~/.config/systemd/user/nanobot-telegram.service -[Unit] -Description=Nanobot Telegram Instance -After=network.target - -[Service] -Type=simple -ExecStart=%h/.local/bin/nanobot gateway --config %h/.nanobot-telegram/config.json -Restart=always -RestartSec=10 - -[Install] -WantedBy=default.target -``` - -Enable and start: -```bash -systemctl --user daemon-reload -systemctl --user enable --now nanobot-telegram -systemctl --user enable --now nanobot-discord -``` - -### launchd Service (macOS) - -Create a plist file for each instance: - -```xml - - - - - - Label - com.nanobot.telegram - - ProgramArguments - - /path/to/nanobot - gateway - --config - /Users/yourname/.nanobot-telegram/config.json - - - RunAtLoad - - - KeepAlive - - - StandardOutPath - /Users/yourname/.nanobot-telegram/logs/stdout.log - - StandardErrorPath - /Users/yourname/.nanobot-telegram/logs/stderr.log - - -``` - -Load the service: -```bash -launchctl load ~/Library/LaunchAgents/com.nanobot.telegram.plist -launchctl load ~/Library/LaunchAgents/com.nanobot.discord.plist -``` +- Run separate bots for Telegram, Discord, Feishu, and other platforms +- Keep testing and production instances isolated +- Use different models or providers for different teams +- Serve multiple tenants with separate configs and runtime data ### Notes -- Each instance must use a different port (default: 18790) -- Instances are completely independent — no shared state or cross-talk -- You can run different LLM models, providers, and channel configurations per instance -- Memory, sessions, and cron jobs are fully isolated between instances +- Each instance must use a different port if they run at the same time +- Use a different workspace per instance if you want isolated memory, sessions, and skills +- `--workspace` overrides the workspace defined in the config file +- Cron jobs and runtime media/state are derived from the config directory ## CLI Reference diff --git a/bridge/src/whatsapp.ts b/bridge/src/whatsapp.ts index b91bacc..f0485bd 100644 --- a/bridge/src/whatsapp.ts +++ b/bridge/src/whatsapp.ts @@ -18,7 +18,6 @@ import qrcode from 'qrcode-terminal'; import pino from 'pino'; import { writeFile, mkdir } from 'fs/promises'; import { join } from 'path'; -import { homedir } from 'os'; import { randomBytes } from 'crypto'; const VERSION = '0.1.0'; @@ -162,7 +161,7 @@ export class WhatsAppClient { private async downloadMedia(msg: any, mimetype?: string, fileName?: string): Promise { try { - const mediaDir = join(homedir(), '.nanobot', 'media'); + const mediaDir = join(this.options.authDir, '..', 'media'); await mkdir(mediaDir, { recursive: true }); const buffer = await downloadMediaMessage(msg, 'buffer', {}) as Buffer; diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index 0187c62..2ee4f77 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -12,6 +12,7 @@ from loguru import logger from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel +from nanobot.config.paths import get_media_dir from nanobot.config.schema import DiscordConfig from nanobot.utils.helpers import split_message @@ -289,7 +290,7 @@ class DiscordChannel(BaseChannel): content_parts = [content] if content else [] media_paths: list[str] = [] - media_dir = Path.home() / ".nanobot" / "media" + media_dir = get_media_dir("discord") for attachment in payload.get("attachments") or []: url = attachment.get("url") diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 2dcf710..a637025 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -14,6 +14,7 @@ from loguru import logger from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel +from nanobot.config.paths import get_media_dir from nanobot.config.schema import FeishuConfig import importlib.util @@ -732,8 +733,7 @@ class FeishuChannel(BaseChannel): (file_path, content_text) - file_path is None if download failed """ loop = asyncio.get_running_loop() - media_dir = Path.home() / ".nanobot" / "media" - media_dir.mkdir(parents=True, exist_ok=True) + media_dir = get_media_dir("feishu") data, filename = None, None diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 4967ac1..63cb0ca 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -38,7 +38,7 @@ except ImportError as e: from nanobot.bus.events import OutboundMessage from nanobot.channels.base import BaseChannel -from nanobot.config.loader import get_data_dir +from nanobot.config.paths import get_data_dir, get_media_dir from nanobot.utils.helpers import safe_filename TYPING_NOTICE_TIMEOUT_MS = 30_000 @@ -490,9 +490,7 @@ class MatrixChannel(BaseChannel): return False def _media_dir(self) -> Path: - d = get_data_dir() / "media" / "matrix" - d.mkdir(parents=True, exist_ok=True) - return d + return get_media_dir("matrix") @staticmethod def _event_source_content(event: RoomMessage) -> dict[str, Any]: diff --git a/nanobot/channels/mochat.py b/nanobot/channels/mochat.py index e762dfd..09e31c3 100644 --- a/nanobot/channels/mochat.py +++ b/nanobot/channels/mochat.py @@ -15,8 +15,8 @@ from loguru import logger from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel +from nanobot.config.paths import get_runtime_subdir from nanobot.config.schema import MochatConfig -from nanobot.utils.helpers import get_data_path try: import socketio @@ -224,7 +224,7 @@ class MochatChannel(BaseChannel): self._socket: Any = None self._ws_connected = self._ws_ready = False - self._state_dir = get_data_path() / "mochat" + self._state_dir = get_runtime_subdir("mochat") self._cursor_path = self._state_dir / "session_cursors.json" self._session_cursor: dict[str, int] = {} self._cursor_save_task: asyncio.Task | None = None diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 501a3c1..ecb1440 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -15,6 +15,7 @@ from telegram.request import HTTPXRequest from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel +from nanobot.config.paths import get_media_dir from nanobot.config.schema import TelegramConfig from nanobot.utils.helpers import split_message @@ -536,10 +537,7 @@ class TelegramChannel(BaseChannel): getattr(media_file, 'mime_type', None), getattr(media_file, 'file_name', None), ) - # Save to workspace/media/ - from pathlib import Path - media_dir = Path.home() / ".nanobot" / "media" - media_dir.mkdir(parents=True, exist_ok=True) + media_dir = get_media_dir("telegram") file_path = media_dir / f"{media_file.file_id[:16]}{ext}" await file.download_to_drive(str(file_path)) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 47c9a30..da8906d 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -30,6 +30,7 @@ from rich.table import Table from rich.text import Text from nanobot import __logo__, __version__ +from nanobot.config.paths import get_workspace_path from nanobot.config.schema import Config from nanobot.utils.helpers import sync_workspace_templates @@ -99,7 +100,9 @@ def _init_prompt_session() -> None: except Exception: pass - history_file = Path.home() / ".nanobot" / "history" / "cli_history" + from nanobot.config.paths import get_cli_history_path + + history_file = get_cli_history_path() history_file.parent.mkdir(parents=True, exist_ok=True) _PROMPT_SESSION = PromptSession( @@ -170,7 +173,6 @@ def onboard(): """Initialize nanobot configuration and workspace.""" from nanobot.config.loader import get_config_path, load_config, save_config from nanobot.config.schema import Config - from nanobot.utils.helpers import get_workspace_path config_path = get_config_path() @@ -271,8 +273,9 @@ def _make_provider(config: Config): @app.command() def gateway( port: int = typer.Option(18790, "--port", "-p", help="Gateway port"), + workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"), verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"), - config: str = typer.Option(None, "--config", "-c", help="Path to config file"), + 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) @@ -288,7 +291,8 @@ def gateway( from nanobot.agent.loop import AgentLoop from nanobot.bus.queue import MessageBus from nanobot.channels.manager import ChannelManager - from nanobot.config.loader import get_data_dir, load_config + 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 from nanobot.heartbeat.service import HeartbeatService @@ -301,13 +305,15 @@ def gateway( console.print(f"{__logo__} Starting nanobot gateway on port {port}...") config = load_config() + if workspace: + config.agents.defaults.workspace = workspace sync_workspace_templates(config.workspace_path) bus = MessageBus() provider = _make_provider(config) session_manager = SessionManager(config.workspace_path) # Create cron service first (callback set after agent creation) - cron_store_path = get_data_dir() / "cron" / "jobs.json" + cron_store_path = get_cron_dir() / "jobs.json" cron = CronService(cron_store_path) # Create agent with cron service @@ -476,7 +482,8 @@ def agent( from nanobot.agent.loop import AgentLoop from nanobot.bus.queue import MessageBus - from nanobot.config.loader import get_data_dir, load_config + from nanobot.config.loader import load_config + from nanobot.config.paths import get_cron_dir from nanobot.cron.service import CronService config = load_config() @@ -486,7 +493,7 @@ def agent( provider = _make_provider(config) # Create cron service for tool usage (no callback needed for CLI unless running) - cron_store_path = get_data_dir() / "cron" / "jobs.json" + cron_store_path = get_cron_dir() / "jobs.json" cron = CronService(cron_store_path) if logs: @@ -752,7 +759,9 @@ def _get_bridge_dir() -> Path: import subprocess # User's bridge location - user_bridge = Path.home() / ".nanobot" / "bridge" + from nanobot.config.paths import get_bridge_install_dir + + user_bridge = get_bridge_install_dir() # Check if already built if (user_bridge / "dist" / "index.js").exists(): @@ -810,6 +819,7 @@ def channels_login(): import subprocess from nanobot.config.loader import load_config + from nanobot.config.paths import get_runtime_subdir config = load_config() bridge_dir = _get_bridge_dir() @@ -820,6 +830,7 @@ def channels_login(): env = {**os.environ} if config.channels.whatsapp.bridge_token: env["BRIDGE_TOKEN"] = config.channels.whatsapp.bridge_token + env["AUTH_DIR"] = str(get_runtime_subdir("whatsapp-auth")) try: subprocess.run(["npm", "start"], cwd=bridge_dir, check=True, env=env) diff --git a/nanobot/config/__init__.py b/nanobot/config/__init__.py index 6c59668..e2c24f8 100644 --- a/nanobot/config/__init__.py +++ b/nanobot/config/__init__.py @@ -1,6 +1,30 @@ """Configuration module for nanobot.""" from nanobot.config.loader import get_config_path, load_config +from nanobot.config.paths import ( + get_bridge_install_dir, + get_cli_history_path, + get_cron_dir, + get_data_dir, + get_legacy_sessions_dir, + get_logs_dir, + get_media_dir, + get_runtime_subdir, + get_workspace_path, +) from nanobot.config.schema import Config -__all__ = ["Config", "load_config", "get_config_path"] +__all__ = [ + "Config", + "load_config", + "get_config_path", + "get_data_dir", + "get_runtime_subdir", + "get_media_dir", + "get_cron_dir", + "get_logs_dir", + "get_workspace_path", + "get_cli_history_path", + "get_bridge_install_dir", + "get_legacy_sessions_dir", +] diff --git a/nanobot/config/loader.py b/nanobot/config/loader.py index 4355bd3..7d309e5 100644 --- a/nanobot/config/loader.py +++ b/nanobot/config/loader.py @@ -23,14 +23,6 @@ def get_config_path() -> Path: return Path.home() / ".nanobot" / "config.json" -def get_data_dir() -> Path: - """Get the nanobot data directory (derived from config path).""" - config_path = get_config_path() - # If config is ~/.nanobot-xxx/config.json, data dir is ~/.nanobot-xxx/ - # If config is ~/.nanobot/config.json, data dir is ~/.nanobot/ - return config_path.parent - - def load_config(config_path: Path | None = None) -> Config: """ Load configuration from file or create default. diff --git a/nanobot/config/paths.py b/nanobot/config/paths.py new file mode 100644 index 0000000..f4dfbd9 --- /dev/null +++ b/nanobot/config/paths.py @@ -0,0 +1,55 @@ +"""Runtime path helpers derived from the active config context.""" + +from __future__ import annotations + +from pathlib import Path + +from nanobot.config.loader import get_config_path +from nanobot.utils.helpers import ensure_dir + + +def get_data_dir() -> Path: + """Return the instance-level runtime data directory.""" + return ensure_dir(get_config_path().parent) + + +def get_runtime_subdir(name: str) -> Path: + """Return a named runtime subdirectory under the instance data dir.""" + return ensure_dir(get_data_dir() / name) + + +def get_media_dir(channel: str | None = None) -> Path: + """Return the media directory, optionally namespaced per channel.""" + base = get_runtime_subdir("media") + return ensure_dir(base / channel) if channel else base + + +def get_cron_dir() -> Path: + """Return the cron storage directory.""" + return get_runtime_subdir("cron") + + +def get_logs_dir() -> Path: + """Return the logs directory.""" + return get_runtime_subdir("logs") + + +def get_workspace_path(workspace: str | None = None) -> Path: + """Resolve and ensure the agent workspace path.""" + path = Path(workspace).expanduser() if workspace else Path.home() / ".nanobot" / "workspace" + return ensure_dir(path) + + +def get_cli_history_path() -> Path: + """Return the shared CLI history file path.""" + return Path.home() / ".nanobot" / "history" / "cli_history" + + +def get_bridge_install_dir() -> Path: + """Return the shared WhatsApp bridge installation directory.""" + return Path.home() / ".nanobot" / "bridge" + + +def get_legacy_sessions_dir() -> Path: + """Return the legacy global session directory used for migration fallback.""" + return Path.home() / ".nanobot" / "sessions" diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index dce4b2e..f0a6484 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -9,6 +9,7 @@ from typing import Any from loguru import logger +from nanobot.config.paths import get_legacy_sessions_dir from nanobot.utils.helpers import ensure_dir, safe_filename @@ -79,7 +80,7 @@ class SessionManager: def __init__(self, workspace: Path): self.workspace = workspace self.sessions_dir = ensure_dir(self.workspace / "sessions") - self.legacy_sessions_dir = Path.home() / ".nanobot" / "sessions" + self.legacy_sessions_dir = get_legacy_sessions_dir() self._cache: dict[str, Session] = {} def _get_session_path(self, key: str) -> Path: diff --git a/nanobot/utils/__init__.py b/nanobot/utils/__init__.py index 9163e38..46f02ac 100644 --- a/nanobot/utils/__init__.py +++ b/nanobot/utils/__init__.py @@ -1,5 +1,5 @@ """Utility functions for nanobot.""" -from nanobot.utils.helpers import ensure_dir, get_data_path, get_workspace_path +from nanobot.utils.helpers import ensure_dir -__all__ = ["ensure_dir", "get_workspace_path", "get_data_path"] +__all__ = ["ensure_dir"] diff --git a/nanobot/utils/helpers.py b/nanobot/utils/helpers.py index 6e8ecd5..57c60dc 100644 --- a/nanobot/utils/helpers.py +++ b/nanobot/utils/helpers.py @@ -24,18 +24,6 @@ def ensure_dir(path: Path) -> Path: return path -def get_data_path() -> Path: - """Get nanobot data directory (derived from config path).""" - from nanobot.config.loader import get_data_dir - return ensure_dir(get_data_dir()) - - -def get_workspace_path(workspace: str | None = None) -> Path: - """Resolve and ensure workspace path. Defaults to ~/.nanobot/workspace.""" - path = Path(workspace).expanduser() if workspace else Path.home() / ".nanobot" / "workspace" - return ensure_dir(path) - - def timestamp() -> str: """Current ISO timestamp.""" return datetime.now().isoformat() diff --git a/tests/test_commands.py b/tests/test_commands.py index 044d113..a276653 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -14,13 +14,17 @@ from nanobot.providers.registry import find_by_model runner = CliRunner() +class _StopGateway(RuntimeError): + pass + + @pytest.fixture def mock_paths(): """Mock config/workspace paths for test isolation.""" with patch("nanobot.config.loader.get_config_path") as mock_cp, \ patch("nanobot.config.loader.save_config") as mock_sc, \ patch("nanobot.config.loader.load_config") as mock_lc, \ - patch("nanobot.utils.helpers.get_workspace_path") as mock_ws: + patch("nanobot.cli.commands.get_workspace_path") as mock_ws: base_dir = Path("./test_onboard_data") if base_dir.exists(): @@ -128,3 +132,94 @@ def test_litellm_provider_canonicalizes_github_copilot_hyphen_prefix(): 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" + + +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) + config_file.write_text("{}") + + config = Config() + config.agents.defaults.workspace = str(tmp_path / "config-workspace") + 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: config) + monkeypatch.setattr( + "nanobot.cli.commands.sync_workspace_templates", + lambda path: seen.__setitem__("workspace", path), + ) + monkeypatch.setattr( + "nanobot.cli.commands._make_provider", + lambda _config: (_ for _ in ()).throw(_StopGateway("stop")), + ) + + result = runner.invoke(app, ["gateway", "--config", str(config_file)]) + + assert isinstance(result.exception, _StopGateway) + assert seen["config_path"] == config_file.resolve() + assert seen["workspace"] == Path(config.agents.defaults.workspace) + + +def test_gateway_workspace_option_overrides_config(monkeypatch, tmp_path: Path) -> None: + config_file = tmp_path / "instance" / "config.json" + config_file.parent.mkdir(parents=True) + config_file.write_text("{}") + + config = Config() + config.agents.defaults.workspace = str(tmp_path / "config-workspace") + override = tmp_path / "override-workspace" + 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.cli.commands.sync_workspace_templates", + lambda path: seen.__setitem__("workspace", path), + ) + monkeypatch.setattr( + "nanobot.cli.commands._make_provider", + lambda _config: (_ for _ in ()).throw(_StopGateway("stop")), + ) + + result = runner.invoke( + app, + ["gateway", "--config", str(config_file), "--workspace", str(override)], + ) + + assert isinstance(result.exception, _StopGateway) + assert seen["workspace"] == override + assert config.workspace_path == override + + +def test_gateway_uses_config_directory_for_cron_store(monkeypatch, tmp_path: Path) -> None: + config_file = tmp_path / "instance" / "config.json" + config_file.parent.mkdir(parents=True) + config_file.write_text("{}") + + config = Config() + config.agents.defaults.workspace = str(tmp_path / "config-workspace") + 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.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.session.manager.SessionManager", lambda _workspace: object()) + + class _StopCron: + def __init__(self, store_path: Path) -> None: + seen["cron_store"] = store_path + raise _StopGateway("stop") + + monkeypatch.setattr("nanobot.cron.service.CronService", _StopCron) + + result = runner.invoke(app, ["gateway", "--config", str(config_file)]) + + assert isinstance(result.exception, _StopGateway) + assert seen["cron_store"] == config_file.parent / "cron" / "jobs.json" diff --git a/tests/test_config_paths.py b/tests/test_config_paths.py new file mode 100644 index 0000000..473a6c8 --- /dev/null +++ b/tests/test_config_paths.py @@ -0,0 +1,42 @@ +from pathlib import Path + +from nanobot.config.paths import ( + get_bridge_install_dir, + get_cli_history_path, + get_cron_dir, + get_data_dir, + get_legacy_sessions_dir, + get_logs_dir, + get_media_dir, + get_runtime_subdir, + get_workspace_path, +) + + +def test_runtime_dirs_follow_config_path(monkeypatch, tmp_path: Path) -> None: + config_file = tmp_path / "instance-a" / "config.json" + monkeypatch.setattr("nanobot.config.paths.get_config_path", lambda: config_file) + + assert get_data_dir() == config_file.parent + assert get_runtime_subdir("cron") == config_file.parent / "cron" + assert get_cron_dir() == config_file.parent / "cron" + assert get_logs_dir() == config_file.parent / "logs" + + +def test_media_dir_supports_channel_namespace(monkeypatch, tmp_path: Path) -> None: + config_file = tmp_path / "instance-b" / "config.json" + monkeypatch.setattr("nanobot.config.paths.get_config_path", lambda: config_file) + + assert get_media_dir() == config_file.parent / "media" + assert get_media_dir("telegram") == config_file.parent / "media" / "telegram" + + +def test_shared_and_legacy_paths_remain_global() -> None: + assert get_cli_history_path() == Path.home() / ".nanobot" / "history" / "cli_history" + assert get_bridge_install_dir() == Path.home() / ".nanobot" / "bridge" + assert get_legacy_sessions_dir() == Path.home() / ".nanobot" / "sessions" + + +def test_workspace_path_is_explicitly_resolved() -> None: + assert get_workspace_path() == Path.home() / ".nanobot" / "workspace" + assert get_workspace_path("~/custom-workspace") == Path.home() / "custom-workspace"