Merge branch 'main' into pr-560
This commit is contained in:
commit
9a83301ea6
3
.gitignore
vendored
3
.gitignore
vendored
@ -14,8 +14,9 @@ docs/
|
|||||||
*.pywz
|
*.pywz
|
||||||
*.pyzz
|
*.pyzz
|
||||||
.venv/
|
.venv/
|
||||||
|
venv/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
poetry.lock
|
poetry.lock
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
tests/
|
|
||||||
botpy.log
|
botpy.log
|
||||||
|
tests/
|
||||||
|
|||||||
@ -16,10 +16,11 @@
|
|||||||
|
|
||||||
⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines.
|
⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines.
|
||||||
|
|
||||||
📏 Real-time line count: **3,578 lines** (run `bash core_agent_lines.sh` to verify anytime)
|
📏 Real-time line count: **3,583 lines** (run `bash core_agent_lines.sh` to verify anytime)
|
||||||
|
|
||||||
## 📢 News
|
## 📢 News
|
||||||
|
|
||||||
|
- **2026-02-13** 🎉 Released v0.1.3.post7 — includes security hardening and multiple improvements. All users are recommended to upgrade to the latest version. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post7) for more details.
|
||||||
- **2026-02-12** 🧠 Redesigned memory system — Less code, more reliable. Join the [discussion](https://github.com/HKUDS/nanobot/discussions/566) about it!
|
- **2026-02-12** 🧠 Redesigned memory system — Less code, more reliable. Join the [discussion](https://github.com/HKUDS/nanobot/discussions/566) about it!
|
||||||
- **2026-02-10** 🎉 Released v0.1.3.post6 with improvements! Check the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431).
|
- **2026-02-10** 🎉 Released v0.1.3.post6 with improvements! Check the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431).
|
||||||
- **2026-02-09** 💬 Added Slack, Email, and QQ support — nanobot now supports multiple chat platforms!
|
- **2026-02-09** 💬 Added Slack, Email, and QQ support — nanobot now supports multiple chat platforms!
|
||||||
|
|||||||
@ -95,8 +95,8 @@ File operations have path traversal protection, but:
|
|||||||
- Consider using a firewall to restrict outbound connections if needed
|
- Consider using a firewall to restrict outbound connections if needed
|
||||||
|
|
||||||
**WhatsApp Bridge:**
|
**WhatsApp Bridge:**
|
||||||
- The bridge runs on `localhost:3001` by default
|
- The bridge binds to `127.0.0.1:3001` (localhost only, not accessible from external network)
|
||||||
- If exposing to network, use proper authentication and TLS
|
- Set `bridgeToken` in config to enable shared-secret authentication between Python and Node.js
|
||||||
- Keep authentication data in `~/.nanobot/whatsapp-auth` secure (mode 0700)
|
- Keep authentication data in `~/.nanobot/whatsapp-auth` secure (mode 0700)
|
||||||
|
|
||||||
### 6. Dependency Security
|
### 6. Dependency Security
|
||||||
@ -224,7 +224,7 @@ If you suspect a security breach:
|
|||||||
✅ **Secure Communication**
|
✅ **Secure Communication**
|
||||||
- HTTPS for all external API calls
|
- HTTPS for all external API calls
|
||||||
- TLS for Telegram API
|
- TLS for Telegram API
|
||||||
- WebSocket security for WhatsApp bridge
|
- WhatsApp bridge: localhost-only binding + optional token auth
|
||||||
|
|
||||||
## Known Limitations
|
## Known Limitations
|
||||||
|
|
||||||
|
|||||||
@ -25,11 +25,12 @@ import { join } from 'path';
|
|||||||
|
|
||||||
const PORT = parseInt(process.env.BRIDGE_PORT || '3001', 10);
|
const PORT = parseInt(process.env.BRIDGE_PORT || '3001', 10);
|
||||||
const AUTH_DIR = process.env.AUTH_DIR || join(homedir(), '.nanobot', 'whatsapp-auth');
|
const AUTH_DIR = process.env.AUTH_DIR || join(homedir(), '.nanobot', 'whatsapp-auth');
|
||||||
|
const TOKEN = process.env.BRIDGE_TOKEN || undefined;
|
||||||
|
|
||||||
console.log('🐈 nanobot WhatsApp Bridge');
|
console.log('🐈 nanobot WhatsApp Bridge');
|
||||||
console.log('========================\n');
|
console.log('========================\n');
|
||||||
|
|
||||||
const server = new BridgeServer(PORT, AUTH_DIR);
|
const server = new BridgeServer(PORT, AUTH_DIR, TOKEN);
|
||||||
|
|
||||||
// Handle graceful shutdown
|
// Handle graceful shutdown
|
||||||
process.on('SIGINT', async () => {
|
process.on('SIGINT', async () => {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* WebSocket server for Python-Node.js bridge communication.
|
* WebSocket server for Python-Node.js bridge communication.
|
||||||
|
* Security: binds to 127.0.0.1 only; optional BRIDGE_TOKEN auth.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { WebSocketServer, WebSocket } from 'ws';
|
import { WebSocketServer, WebSocket } from 'ws';
|
||||||
@ -21,12 +22,13 @@ export class BridgeServer {
|
|||||||
private wa: WhatsAppClient | null = null;
|
private wa: WhatsAppClient | null = null;
|
||||||
private clients: Set<WebSocket> = new Set();
|
private clients: Set<WebSocket> = new Set();
|
||||||
|
|
||||||
constructor(private port: number, private authDir: string) {}
|
constructor(private port: number, private authDir: string, private token?: string) {}
|
||||||
|
|
||||||
async start(): Promise<void> {
|
async start(): Promise<void> {
|
||||||
// Create WebSocket server
|
// Bind to localhost only — never expose to external network
|
||||||
this.wss = new WebSocketServer({ port: this.port });
|
this.wss = new WebSocketServer({ host: '127.0.0.1', port: this.port });
|
||||||
console.log(`🌉 Bridge server listening on ws://localhost:${this.port}`);
|
console.log(`🌉 Bridge server listening on ws://127.0.0.1:${this.port}`);
|
||||||
|
if (this.token) console.log('🔒 Token authentication enabled');
|
||||||
|
|
||||||
// Initialize WhatsApp client
|
// Initialize WhatsApp client
|
||||||
this.wa = new WhatsAppClient({
|
this.wa = new WhatsAppClient({
|
||||||
@ -38,35 +40,58 @@ export class BridgeServer {
|
|||||||
|
|
||||||
// Handle WebSocket connections
|
// Handle WebSocket connections
|
||||||
this.wss.on('connection', (ws) => {
|
this.wss.on('connection', (ws) => {
|
||||||
console.log('🔗 Python client connected');
|
if (this.token) {
|
||||||
this.clients.add(ws);
|
// Require auth handshake as first message
|
||||||
|
const timeout = setTimeout(() => ws.close(4001, 'Auth timeout'), 5000);
|
||||||
ws.on('message', async (data) => {
|
ws.once('message', (data) => {
|
||||||
try {
|
clearTimeout(timeout);
|
||||||
const cmd = JSON.parse(data.toString()) as SendCommand;
|
try {
|
||||||
await this.handleCommand(cmd);
|
const msg = JSON.parse(data.toString());
|
||||||
ws.send(JSON.stringify({ type: 'sent', to: cmd.to }));
|
if (msg.type === 'auth' && msg.token === this.token) {
|
||||||
} catch (error) {
|
console.log('🔗 Python client authenticated');
|
||||||
console.error('Error handling command:', error);
|
this.setupClient(ws);
|
||||||
ws.send(JSON.stringify({ type: 'error', error: String(error) }));
|
} else {
|
||||||
}
|
ws.close(4003, 'Invalid token');
|
||||||
});
|
}
|
||||||
|
} catch {
|
||||||
ws.on('close', () => {
|
ws.close(4003, 'Invalid auth message');
|
||||||
console.log('🔌 Python client disconnected');
|
}
|
||||||
this.clients.delete(ws);
|
});
|
||||||
});
|
} else {
|
||||||
|
console.log('🔗 Python client connected');
|
||||||
ws.on('error', (error) => {
|
this.setupClient(ws);
|
||||||
console.error('WebSocket error:', error);
|
}
|
||||||
this.clients.delete(ws);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Connect to WhatsApp
|
// Connect to WhatsApp
|
||||||
await this.wa.connect();
|
await this.wa.connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private setupClient(ws: WebSocket): void {
|
||||||
|
this.clients.add(ws);
|
||||||
|
|
||||||
|
ws.on('message', async (data) => {
|
||||||
|
try {
|
||||||
|
const cmd = JSON.parse(data.toString()) as SendCommand;
|
||||||
|
await this.handleCommand(cmd);
|
||||||
|
ws.send(JSON.stringify({ type: 'sent', to: cmd.to }));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error handling command:', error);
|
||||||
|
ws.send(JSON.stringify({ type: 'error', error: String(error) }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
console.log('🔌 Python client disconnected');
|
||||||
|
this.clients.delete(ws);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('error', (error) => {
|
||||||
|
console.error('WebSocket error:', error);
|
||||||
|
this.clients.delete(ws);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async handleCommand(cmd: SendCommand): Promise<void> {
|
private async handleCommand(cmd: SendCommand): Promise<void> {
|
||||||
if (cmd.type === 'send' && this.wa) {
|
if (cmd.type === 'send' && this.wa) {
|
||||||
await this.wa.sendMessage(cmd.to, cmd.text);
|
await this.wa.sendMessage(cmd.to, cmd.text);
|
||||||
|
|||||||
@ -166,6 +166,10 @@ class FeishuChannel(BaseChannel):
|
|||||||
re.MULTILINE,
|
re.MULTILINE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+)$", re.MULTILINE)
|
||||||
|
|
||||||
|
_CODE_BLOCK_RE = re.compile(r"(```[\s\S]*?```)", re.MULTILINE)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_md_table(table_text: str) -> dict | None:
|
def _parse_md_table(table_text: str) -> dict | None:
|
||||||
"""Parse a markdown table into a Feishu table element."""
|
"""Parse a markdown table into a Feishu table element."""
|
||||||
@ -185,17 +189,52 @@ class FeishuChannel(BaseChannel):
|
|||||||
}
|
}
|
||||||
|
|
||||||
def _build_card_elements(self, content: str) -> list[dict]:
|
def _build_card_elements(self, content: str) -> list[dict]:
|
||||||
"""Split content into markdown + table elements for Feishu card."""
|
"""Split content into div/markdown + table elements for Feishu card."""
|
||||||
elements, last_end = [], 0
|
elements, last_end = [], 0
|
||||||
for m in self._TABLE_RE.finditer(content):
|
for m in self._TABLE_RE.finditer(content):
|
||||||
before = content[last_end:m.start()].strip()
|
before = content[last_end:m.start()]
|
||||||
if before:
|
if before.strip():
|
||||||
elements.append({"tag": "markdown", "content": before})
|
elements.extend(self._split_headings(before))
|
||||||
elements.append(self._parse_md_table(m.group(1)) or {"tag": "markdown", "content": m.group(1)})
|
elements.append(self._parse_md_table(m.group(1)) or {"tag": "markdown", "content": m.group(1)})
|
||||||
last_end = m.end()
|
last_end = m.end()
|
||||||
remaining = content[last_end:].strip()
|
remaining = content[last_end:]
|
||||||
|
if remaining.strip():
|
||||||
|
elements.extend(self._split_headings(remaining))
|
||||||
|
return elements or [{"tag": "markdown", "content": content}]
|
||||||
|
|
||||||
|
def _split_headings(self, content: str) -> list[dict]:
|
||||||
|
"""Split content by headings, converting headings to div elements."""
|
||||||
|
protected = content
|
||||||
|
code_blocks = []
|
||||||
|
for m in self._CODE_BLOCK_RE.finditer(content):
|
||||||
|
code_blocks.append(m.group(1))
|
||||||
|
protected = protected.replace(m.group(1), f"\x00CODE{len(code_blocks)-1}\x00", 1)
|
||||||
|
|
||||||
|
elements = []
|
||||||
|
last_end = 0
|
||||||
|
for m in self._HEADING_RE.finditer(protected):
|
||||||
|
before = protected[last_end:m.start()].strip()
|
||||||
|
if before:
|
||||||
|
elements.append({"tag": "markdown", "content": before})
|
||||||
|
level = len(m.group(1))
|
||||||
|
text = m.group(2).strip()
|
||||||
|
elements.append({
|
||||||
|
"tag": "div",
|
||||||
|
"text": {
|
||||||
|
"tag": "lark_md",
|
||||||
|
"content": f"**{text}**",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
last_end = m.end()
|
||||||
|
remaining = protected[last_end:].strip()
|
||||||
if remaining:
|
if remaining:
|
||||||
elements.append({"tag": "markdown", "content": remaining})
|
elements.append({"tag": "markdown", "content": remaining})
|
||||||
|
|
||||||
|
for i, cb in enumerate(code_blocks):
|
||||||
|
for el in elements:
|
||||||
|
if el.get("tag") == "markdown":
|
||||||
|
el["content"] = el["content"].replace(f"\x00CODE{i}\x00", cb)
|
||||||
|
|
||||||
return elements or [{"tag": "markdown", "content": content}]
|
return elements or [{"tag": "markdown", "content": content}]
|
||||||
|
|
||||||
async def send(self, msg: OutboundMessage) -> None:
|
async def send(self, msg: OutboundMessage) -> None:
|
||||||
|
|||||||
@ -42,6 +42,9 @@ class WhatsAppChannel(BaseChannel):
|
|||||||
try:
|
try:
|
||||||
async with websockets.connect(bridge_url) as ws:
|
async with websockets.connect(bridge_url) as ws:
|
||||||
self._ws = ws
|
self._ws = ws
|
||||||
|
# Send auth token if configured
|
||||||
|
if self.config.bridge_token:
|
||||||
|
await ws.send(json.dumps({"type": "auth", "token": self.config.bridge_token}))
|
||||||
self._connected = True
|
self._connected = True
|
||||||
logger.info("Connected to WhatsApp bridge")
|
logger.info("Connected to WhatsApp bridge")
|
||||||
|
|
||||||
|
|||||||
@ -155,7 +155,7 @@ def main(
|
|||||||
@app.command()
|
@app.command()
|
||||||
def onboard():
|
def onboard():
|
||||||
"""Initialize nanobot configuration and workspace."""
|
"""Initialize nanobot configuration and workspace."""
|
||||||
from nanobot.config.loader import get_config_path, save_config
|
from nanobot.config.loader import get_config_path, load_config, save_config
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
from nanobot.utils.helpers import get_workspace_path
|
from nanobot.utils.helpers import get_workspace_path
|
||||||
|
|
||||||
@ -163,17 +163,26 @@ def onboard():
|
|||||||
|
|
||||||
if config_path.exists():
|
if config_path.exists():
|
||||||
console.print(f"[yellow]Config already exists at {config_path}[/yellow]")
|
console.print(f"[yellow]Config already exists at {config_path}[/yellow]")
|
||||||
if not typer.confirm("Overwrite?"):
|
console.print(" [bold]y[/bold] = overwrite with defaults (existing values will be lost)")
|
||||||
raise typer.Exit()
|
console.print(" [bold]N[/bold] = refresh config, keeping existing values and adding new fields")
|
||||||
|
if typer.confirm("Overwrite?"):
|
||||||
# Create default config
|
config = Config()
|
||||||
config = Config()
|
save_config(config)
|
||||||
save_config(config)
|
console.print(f"[green]✓[/green] Config reset to defaults at {config_path}")
|
||||||
console.print(f"[green]✓[/green] Created config at {config_path}")
|
else:
|
||||||
|
config = load_config()
|
||||||
|
save_config(config)
|
||||||
|
console.print(f"[green]✓[/green] Config refreshed at {config_path} (existing values preserved)")
|
||||||
|
else:
|
||||||
|
save_config(Config())
|
||||||
|
console.print(f"[green]✓[/green] Created config at {config_path}")
|
||||||
|
|
||||||
# Create workspace
|
# Create workspace
|
||||||
workspace = get_workspace_path()
|
workspace = get_workspace_path()
|
||||||
console.print(f"[green]✓[/green] Created workspace at {workspace}")
|
|
||||||
|
if not workspace.exists():
|
||||||
|
workspace.mkdir(parents=True, exist_ok=True)
|
||||||
|
console.print(f"[green]✓[/green] Created workspace at {workspace}")
|
||||||
|
|
||||||
# Create default bootstrap files
|
# Create default bootstrap files
|
||||||
_create_workspace_templates(workspace)
|
_create_workspace_templates(workspace)
|
||||||
@ -638,14 +647,20 @@ def _get_bridge_dir() -> Path:
|
|||||||
def channels_login():
|
def channels_login():
|
||||||
"""Link device via QR code."""
|
"""Link device via QR code."""
|
||||||
import subprocess
|
import subprocess
|
||||||
|
from nanobot.config.loader import load_config
|
||||||
|
|
||||||
|
config = load_config()
|
||||||
bridge_dir = _get_bridge_dir()
|
bridge_dir = _get_bridge_dir()
|
||||||
|
|
||||||
console.print(f"{__logo__} Starting bridge...")
|
console.print(f"{__logo__} Starting bridge...")
|
||||||
console.print("Scan the QR code to connect.\n")
|
console.print("Scan the QR code to connect.\n")
|
||||||
|
|
||||||
|
env = {**os.environ}
|
||||||
|
if config.channels.whatsapp.bridge_token:
|
||||||
|
env["BRIDGE_TOKEN"] = config.channels.whatsapp.bridge_token
|
||||||
|
|
||||||
try:
|
try:
|
||||||
subprocess.run(["npm", "start"], cwd=bridge_dir, check=True)
|
subprocess.run(["npm", "start"], cwd=bridge_dir, check=True, env=env)
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
console.print(f"[red]Bridge failed: {e}[/red]")
|
console.print(f"[red]Bridge failed: {e}[/red]")
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
|
|||||||
@ -9,6 +9,7 @@ class WhatsAppConfig(BaseModel):
|
|||||||
"""WhatsApp channel configuration."""
|
"""WhatsApp channel configuration."""
|
||||||
enabled: bool = False
|
enabled: bool = False
|
||||||
bridge_url: str = "ws://localhost:3001"
|
bridge_url: str = "ws://localhost:3001"
|
||||||
|
bridge_token: str = "" # Shared token for bridge auth (optional, recommended)
|
||||||
allow_from: list[str] = Field(default_factory=list) # Allowed phone numbers
|
allow_from: list[str] = Field(default_factory=list) # Allowed phone numbers
|
||||||
|
|
||||||
|
|
||||||
@ -178,6 +179,7 @@ class ProviderConfig(BaseModel):
|
|||||||
|
|
||||||
class ProvidersConfig(BaseModel):
|
class ProvidersConfig(BaseModel):
|
||||||
"""Configuration for LLM providers."""
|
"""Configuration for LLM providers."""
|
||||||
|
custom: ProviderConfig = Field(default_factory=ProviderConfig) # Any OpenAI-compatible endpoint
|
||||||
anthropic: ProviderConfig = Field(default_factory=ProviderConfig)
|
anthropic: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||||
openai: ProviderConfig = Field(default_factory=ProviderConfig)
|
openai: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||||
openrouter: ProviderConfig = Field(default_factory=ProviderConfig)
|
openrouter: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||||
|
|||||||
@ -62,6 +62,20 @@ class ProviderSpec:
|
|||||||
|
|
||||||
PROVIDERS: tuple[ProviderSpec, ...] = (
|
PROVIDERS: tuple[ProviderSpec, ...] = (
|
||||||
|
|
||||||
|
# === Custom (user-provided OpenAI-compatible endpoint) =================
|
||||||
|
# No auto-detection — only activates when user explicitly configures "custom".
|
||||||
|
|
||||||
|
ProviderSpec(
|
||||||
|
name="custom",
|
||||||
|
keywords=(),
|
||||||
|
env_key="OPENAI_API_KEY",
|
||||||
|
display_name="Custom",
|
||||||
|
litellm_prefix="openai",
|
||||||
|
skip_prefixes=("openai/",),
|
||||||
|
is_gateway=True,
|
||||||
|
strip_model_prefix=True,
|
||||||
|
),
|
||||||
|
|
||||||
# === Gateways (detected by api_key / api_base, not model name) =========
|
# === Gateways (detected by api_key / api_base, not model name) =========
|
||||||
# Gateways can route any model, so they win in fallback.
|
# Gateways can route any model, so they win in fallback.
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "nanobot-ai"
|
name = "nanobot-ai"
|
||||||
version = "0.1.3.post6"
|
version = "0.1.3.post7"
|
||||||
description = "A lightweight personal AI assistant framework"
|
description = "A lightweight personal AI assistant framework"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
license = {text = "MIT"}
|
license = {text = "MIT"}
|
||||||
|
|||||||
92
tests/test_commands.py
Normal file
92
tests/test_commands.py
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
from nanobot.cli.commands import app
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
|
||||||
|
@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:
|
||||||
|
|
||||||
|
base_dir = Path("./test_onboard_data")
|
||||||
|
if base_dir.exists():
|
||||||
|
shutil.rmtree(base_dir)
|
||||||
|
base_dir.mkdir()
|
||||||
|
|
||||||
|
config_file = base_dir / "config.json"
|
||||||
|
workspace_dir = base_dir / "workspace"
|
||||||
|
|
||||||
|
mock_cp.return_value = config_file
|
||||||
|
mock_ws.return_value = workspace_dir
|
||||||
|
mock_sc.side_effect = lambda config: config_file.write_text("{}")
|
||||||
|
|
||||||
|
yield config_file, workspace_dir
|
||||||
|
|
||||||
|
if base_dir.exists():
|
||||||
|
shutil.rmtree(base_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def test_onboard_fresh_install(mock_paths):
|
||||||
|
"""No existing config — should create from scratch."""
|
||||||
|
config_file, workspace_dir = mock_paths
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["onboard"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Created config" in result.stdout
|
||||||
|
assert "Created workspace" in result.stdout
|
||||||
|
assert "nanobot is ready" in result.stdout
|
||||||
|
assert config_file.exists()
|
||||||
|
assert (workspace_dir / "AGENTS.md").exists()
|
||||||
|
assert (workspace_dir / "memory" / "MEMORY.md").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_onboard_existing_config_refresh(mock_paths):
|
||||||
|
"""Config exists, user declines overwrite — should refresh (load-merge-save)."""
|
||||||
|
config_file, workspace_dir = mock_paths
|
||||||
|
config_file.write_text('{"existing": true}')
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["onboard"], input="n\n")
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Config already exists" in result.stdout
|
||||||
|
assert "existing values preserved" in result.stdout
|
||||||
|
assert workspace_dir.exists()
|
||||||
|
assert (workspace_dir / "AGENTS.md").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_onboard_existing_config_overwrite(mock_paths):
|
||||||
|
"""Config exists, user confirms overwrite — should reset to defaults."""
|
||||||
|
config_file, workspace_dir = mock_paths
|
||||||
|
config_file.write_text('{"existing": true}')
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["onboard"], input="y\n")
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Config already exists" in result.stdout
|
||||||
|
assert "Config reset to defaults" in result.stdout
|
||||||
|
assert workspace_dir.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_onboard_existing_workspace_safe_create(mock_paths):
|
||||||
|
"""Workspace exists — should not recreate, but still add missing templates."""
|
||||||
|
config_file, workspace_dir = mock_paths
|
||||||
|
workspace_dir.mkdir(parents=True)
|
||||||
|
config_file.write_text("{}")
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["onboard"], input="n\n")
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Created workspace" not in result.stdout
|
||||||
|
assert "Created AGENTS.md" in result.stdout
|
||||||
|
assert (workspace_dir / "AGENTS.md").exists()
|
||||||
Loading…
x
Reference in New Issue
Block a user