feat: config-driven architecture, install wizard, live runtime switching, usage tracking, auto-failover

Major changes:
- Config-driven adapters: all channels (Slack, Discord, Telegram, WebChat, Webhooks) controlled via config.json with enabled flags and token auto-detection, no CLI flags required
- Runtime engine field: runtime.engine selects opencode/claude from config
- Interactive install script: 8-phase setup wizard with AI runtime detection/installation, token setup, identity file personalization (personality presets), aetheel CLI command, background service (launchd/systemd)
- Live runtime switching: /engine, /model, /provider commands hot-swap the AI runtime from chat without restart, changes persisted to config.json
- Usage tracking: per-request cost extraction from Claude Code JSON output, cumulative stats via /usage command
- Auto-failover: rate limit detection on both runtimes, automatic switch to other engine on quota errors with user notification
- Chat commands work without / prefix (Slack intercepts / in channels), commands: engine, model, provider, config, usage, reload, cron, subagents, status, help
- /config set for editing config.json from chat with dotted key notation
- Security audit saved to docs/security-audit.md
- Full command reference in docs/commands.md
- Future changes doc with NanoClaw agent teams analysis
- Logo added to README and WebChat UI
- README fully rewritten with all features documented
This commit is contained in:
2026-02-18 01:07:12 -05:00
parent 41b2f9a593
commit 6d73f74e0b
41 changed files with 11363 additions and 437 deletions

172
tests/test_hooks.py Normal file
View File

@@ -0,0 +1,172 @@
"""
Tests for the Hook system (HookManager, HookEvent).
"""
import os
import pytest
from hooks.hooks import HookEvent, HookEntry, HookManager
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def workspace(tmp_path):
return str(tmp_path)
@pytest.fixture
def manager(workspace):
return HookManager(workspace_dir=workspace)
def _create_hook(workspace, name, events, enabled=True, handler_code=None):
"""Helper to create a hook directory with HOOK.md and optional handler.py."""
hook_dir = os.path.join(workspace, "hooks", name)
os.makedirs(hook_dir, exist_ok=True)
enabled_str = "true" if enabled else "false"
events_str = "[" + ", ".join(events) + "]"
hook_md = f"""---
name: {name}
description: Test hook {name}
events: {events_str}
enabled: {enabled_str}
---
# {name}
Test hook documentation.
"""
with open(os.path.join(hook_dir, "HOOK.md"), "w") as f:
f.write(hook_md)
if handler_code:
with open(os.path.join(hook_dir, "handler.py"), "w") as f:
f.write(handler_code)
# ---------------------------------------------------------------------------
# Tests: HookEvent
# ---------------------------------------------------------------------------
class TestHookEvent:
def test_event_key(self):
event = HookEvent(type="gateway", action="startup")
assert event.event_key == "gateway:startup"
def test_messages_default_empty(self):
event = HookEvent(type="command", action="new")
assert event.messages == []
# ---------------------------------------------------------------------------
# Tests: HookManager discovery
# ---------------------------------------------------------------------------
class TestHookManagerDiscovery:
def test_discover_empty_workspace(self, manager):
hooks = manager.discover()
assert hooks == []
def test_discover_finds_hooks(self, workspace, manager):
_create_hook(workspace, "my-hook", ["gateway:startup"])
hooks = manager.discover()
assert len(hooks) == 1
assert hooks[0].name == "my-hook"
assert hooks[0].events == ["gateway:startup"]
def test_discover_skips_disabled(self, workspace, manager):
_create_hook(workspace, "disabled-hook", ["gateway:startup"], enabled=False)
hooks = manager.discover()
assert len(hooks) == 0
def test_discover_multiple_hooks(self, workspace, manager):
_create_hook(workspace, "hook-a", ["gateway:startup"])
_create_hook(workspace, "hook-b", ["command:new"])
hooks = manager.discover()
assert len(hooks) == 2
def test_discover_multiple_events(self, workspace, manager):
_create_hook(workspace, "multi", ["gateway:startup", "command:new"])
hooks = manager.discover()
assert hooks[0].events == ["gateway:startup", "command:new"]
# ---------------------------------------------------------------------------
# Tests: HookManager trigger
# ---------------------------------------------------------------------------
class TestHookManagerTrigger:
def test_trigger_calls_handler(self, workspace, manager):
handler_code = """
results = []
def handle(event):
results.append(event.event_key)
"""
_create_hook(workspace, "test-hook", ["gateway:startup"], handler_code=handler_code)
manager.discover()
event = HookEvent(type="gateway", action="startup")
manager.trigger(event)
# Handler was called without error — that's the test
def test_trigger_no_matching_hooks(self, manager):
event = HookEvent(type="gateway", action="startup")
messages = manager.trigger(event)
assert messages == []
def test_trigger_handler_error_doesnt_crash(self, workspace, manager):
handler_code = """
def handle(event):
raise RuntimeError("boom")
"""
_create_hook(workspace, "bad-hook", ["gateway:startup"], handler_code=handler_code)
manager.discover()
event = HookEvent(type="gateway", action="startup")
# Should not raise
manager.trigger(event)
def test_trigger_messages(self, workspace, manager):
handler_code = """
def handle(event):
event.messages.append("hello from hook")
"""
_create_hook(workspace, "msg-hook", ["gateway:startup"], handler_code=handler_code)
manager.discover()
event = HookEvent(type="gateway", action="startup")
messages = manager.trigger(event)
assert "hello from hook" in messages
# ---------------------------------------------------------------------------
# Tests: Programmatic hooks
# ---------------------------------------------------------------------------
class TestProgrammaticHooks:
def test_register_and_trigger(self, manager):
received = []
manager.register("gateway:startup", lambda e: received.append(e.event_key))
event = HookEvent(type="gateway", action="startup")
manager.trigger(event)
assert received == ["gateway:startup"]
def test_unregister(self, manager):
received = []
handler = lambda e: received.append(e.event_key)
manager.register("gateway:startup", handler)
manager.unregister("gateway:startup", handler)
manager.trigger(HookEvent(type="gateway", action="startup"))
assert received == []
def test_type_level_handler(self, manager):
received = []
manager.register("gateway", lambda e: received.append(e.action))
manager.trigger(HookEvent(type="gateway", action="startup"))
manager.trigger(HookEvent(type="gateway", action="shutdown"))
assert received == ["startup", "shutdown"]

134
tests/test_mcp_config.py Normal file
View File

@@ -0,0 +1,134 @@
"""
Tests for the MCP config writer utility (write_mcp_config).
"""
import json
import os
import pytest
from config import MCPConfig, MCPServerConfig, write_mcp_config
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def workspace(tmp_path):
"""Return a temporary workspace directory path."""
return str(tmp_path)
@pytest.fixture
def sample_mcp_config():
"""Return an MCPConfig with one server entry."""
return MCPConfig(
servers={
"my-tool": MCPServerConfig(
command="npx",
args=["-y", "@my/tool"],
env={"API_KEY": "test123"},
)
}
)
# ---------------------------------------------------------------------------
# Tests: write_mcp_config
# ---------------------------------------------------------------------------
class TestWriteMcpConfig:
def test_skips_when_no_servers(self, workspace):
"""Empty servers dict should not create any file."""
write_mcp_config(MCPConfig(), workspace, use_claude=True)
assert not os.path.exists(os.path.join(workspace, ".mcp.json"))
assert not os.path.exists(os.path.join(workspace, "opencode.json"))
def test_writes_mcp_json_for_claude(self, workspace, sample_mcp_config):
"""Claude runtime should produce .mcp.json with 'mcpServers' key."""
write_mcp_config(sample_mcp_config, workspace, use_claude=True)
path = os.path.join(workspace, ".mcp.json")
assert os.path.isfile(path)
with open(path, encoding="utf-8") as f:
data = json.load(f)
assert "mcpServers" in data
assert "my-tool" in data["mcpServers"]
srv = data["mcpServers"]["my-tool"]
assert srv["command"] == "npx"
assert srv["args"] == ["-y", "@my/tool"]
assert srv["env"] == {"API_KEY": "test123"}
def test_writes_opencode_json_for_opencode(self, workspace, sample_mcp_config):
"""OpenCode runtime should produce opencode.json with 'mcp' key."""
write_mcp_config(sample_mcp_config, workspace, use_claude=False)
path = os.path.join(workspace, "opencode.json")
assert os.path.isfile(path)
with open(path, encoding="utf-8") as f:
data = json.load(f)
assert "mcp" in data
assert "my-tool" in data["mcp"]
def test_multiple_servers(self, workspace):
"""Multiple server entries should all appear in the output."""
cfg = MCPConfig(
servers={
"server-a": MCPServerConfig(command="cmd-a", args=["--flag"]),
"server-b": MCPServerConfig(command="cmd-b", env={"X": "1"}),
}
)
write_mcp_config(cfg, workspace, use_claude=True)
with open(os.path.join(workspace, ".mcp.json"), encoding="utf-8") as f:
data = json.load(f)
assert len(data["mcpServers"]) == 2
assert "server-a" in data["mcpServers"]
assert "server-b" in data["mcpServers"]
def test_creates_parent_directories(self, tmp_path):
"""Should create intermediate directories if workspace doesn't exist."""
nested = str(tmp_path / "deep" / "nested" / "workspace")
cfg = MCPConfig(
servers={"s": MCPServerConfig(command="echo")}
)
write_mcp_config(cfg, nested, use_claude=True)
assert os.path.isfile(os.path.join(nested, ".mcp.json"))
def test_produces_valid_json(self, workspace, sample_mcp_config):
"""Output file must be valid, parseable JSON."""
write_mcp_config(sample_mcp_config, workspace, use_claude=True)
path = os.path.join(workspace, ".mcp.json")
with open(path, encoding="utf-8") as f:
data = json.load(f) # Would raise on invalid JSON
assert isinstance(data, dict)
def test_overwrites_existing_file(self, workspace):
"""Writing twice should overwrite the previous config."""
cfg1 = MCPConfig(servers={"old": MCPServerConfig(command="old-cmd")})
cfg2 = MCPConfig(servers={"new": MCPServerConfig(command="new-cmd")})
write_mcp_config(cfg1, workspace, use_claude=True)
write_mcp_config(cfg2, workspace, use_claude=True)
with open(os.path.join(workspace, ".mcp.json"), encoding="utf-8") as f:
data = json.load(f)
assert "new" in data["mcpServers"]
assert "old" not in data["mcpServers"]
def test_tilde_expansion(self, monkeypatch, tmp_path):
"""Workspace path with ~ should be expanded."""
monkeypatch.setenv("HOME", str(tmp_path))
# Also handle Windows
monkeypatch.setattr(os.path, "expanduser", lambda p: p.replace("~", str(tmp_path)))
cfg = MCPConfig(servers={"s": MCPServerConfig(command="echo")})
write_mcp_config(cfg, "~/my-workspace", use_claude=False)
assert os.path.isfile(os.path.join(str(tmp_path), "my-workspace", "opencode.json"))

122
tests/test_subagent_bus.py Normal file
View File

@@ -0,0 +1,122 @@
"""
Tests for SubagentBus pub/sub message bus.
"""
import threading
import pytest
from agent.subagent import SubagentBus, SubagentManager
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def bus():
return SubagentBus()
# ---------------------------------------------------------------------------
# Tests: SubagentBus
# ---------------------------------------------------------------------------
class TestSubagentBus:
def test_subscribe_and_publish(self, bus):
received = []
bus.subscribe("ch1", lambda msg, sender: received.append((msg, sender)))
bus.publish("ch1", "hello", "agent-1")
assert received == [("hello", "agent-1")]
def test_multiple_subscribers(self, bus):
received = []
bus.subscribe("ch1", lambda msg, sender: received.append(("a", msg)))
bus.subscribe("ch1", lambda msg, sender: received.append(("b", msg)))
bus.publish("ch1", "ping", "sender")
assert len(received) == 2
assert ("a", "ping") in received
assert ("b", "ping") in received
def test_publish_to_empty_channel(self, bus):
# Should not raise
bus.publish("nonexistent", "msg", "sender")
def test_unsubscribe_all(self, bus):
received = []
bus.subscribe("ch1", lambda msg, sender: received.append(msg))
bus.unsubscribe_all("ch1")
bus.publish("ch1", "hello", "sender")
assert received == []
def test_unsubscribe_all_nonexistent_channel(self, bus):
# Should not raise
bus.unsubscribe_all("nonexistent")
def test_channels_are_isolated(self, bus):
received_a = []
received_b = []
bus.subscribe("a", lambda msg, sender: received_a.append(msg))
bus.subscribe("b", lambda msg, sender: received_b.append(msg))
bus.publish("a", "only-a", "sender")
assert received_a == ["only-a"]
assert received_b == []
def test_callback_error_does_not_stop_others(self, bus):
received = []
def bad_cb(msg, sender):
raise RuntimeError("boom")
bus.subscribe("ch1", bad_cb)
bus.subscribe("ch1", lambda msg, sender: received.append(msg))
bus.publish("ch1", "hello", "sender")
# Second callback should still fire
assert received == ["hello"]
def test_thread_safety(self, bus):
"""Concurrent subscribes and publishes should not crash."""
results = []
barrier = threading.Barrier(4)
def subscriber():
barrier.wait()
bus.subscribe("stress", lambda msg, sender: results.append(msg))
def publisher():
barrier.wait()
bus.publish("stress", "msg", "sender")
threads = [
threading.Thread(target=subscriber),
threading.Thread(target=subscriber),
threading.Thread(target=publisher),
threading.Thread(target=publisher),
]
for t in threads:
t.start()
for t in threads:
t.join(timeout=5)
# ---------------------------------------------------------------------------
# Tests: SubagentManager.bus property
# ---------------------------------------------------------------------------
class TestSubagentManagerBus:
def test_manager_has_bus(self):
mgr = SubagentManager(
runtime_factory=lambda: None,
send_fn=lambda *a: None,
)
assert isinstance(mgr.bus, SubagentBus)
def test_bus_is_same_instance(self):
mgr = SubagentManager(
runtime_factory=lambda: None,
send_fn=lambda *a: None,
)
assert mgr.bus is mgr.bus

171
tests/test_webhooks.py Normal file
View File

@@ -0,0 +1,171 @@
"""
Tests for the Webhook receiver.
"""
import json
import pytest
from aiohttp.test_utils import TestClient, TestServer
from webhooks.receiver import WebhookReceiver, WebhookConfig
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_handler(response_text="AI response"):
def handler(msg):
return response_text
return handler
def _make_send_fn():
calls = []
def send(channel_id, text, thread_id, channel_type):
calls.append({"channel_id": channel_id, "text": text, "channel_type": channel_type})
send.calls = calls
return send
def _make_receiver(token="", handler=None, send_fn=None):
config = WebhookConfig(enabled=True, port=0, host="127.0.0.1", token=token)
return WebhookReceiver(
ai_handler_fn=handler or _make_handler(),
send_fn=send_fn or _make_send_fn(),
config=config,
)
# ---------------------------------------------------------------------------
# Tests: Health endpoint
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_health_endpoint():
receiver = _make_receiver()
async with TestClient(TestServer(receiver._app)) as client:
resp = await client.get("/hooks/health")
assert resp.status == 200
data = await resp.json()
assert data["status"] == "ok"
# ---------------------------------------------------------------------------
# Tests: Wake endpoint
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_wake_no_auth():
receiver = _make_receiver()
async with TestClient(TestServer(receiver._app)) as client:
resp = await client.post("/hooks/wake", json={"text": "hello"})
assert resp.status == 200
data = await resp.json()
assert data["response"] == "AI response"
@pytest.mark.asyncio
async def test_wake_auth_required():
receiver = _make_receiver(token="secret")
async with TestClient(TestServer(receiver._app)) as client:
# No auth → 401
resp = await client.post("/hooks/wake", json={"text": "hello"})
assert resp.status == 401
# With auth → 200
resp = await client.post(
"/hooks/wake",
json={"text": "hello"},
headers={"Authorization": "Bearer secret"},
)
assert resp.status == 200
@pytest.mark.asyncio
async def test_wake_empty_text():
receiver = _make_receiver()
async with TestClient(TestServer(receiver._app)) as client:
resp = await client.post("/hooks/wake", json={"text": ""})
assert resp.status == 400
@pytest.mark.asyncio
async def test_wake_invalid_json():
receiver = _make_receiver()
async with TestClient(TestServer(receiver._app)) as client:
resp = await client.post(
"/hooks/wake",
data="not json",
headers={"Content-Type": "application/json"},
)
assert resp.status == 400
@pytest.mark.asyncio
async def test_wake_delivers_to_channel():
send_fn = _make_send_fn()
receiver = _make_receiver(handler=_make_handler("response"), send_fn=send_fn)
async with TestClient(TestServer(receiver._app)) as client:
resp = await client.post("/hooks/wake", json={
"text": "hello",
"channel": "slack",
"channel_id": "C123",
})
assert resp.status == 200
assert len(send_fn.calls) == 1
assert send_fn.calls[0]["channel_id"] == "C123"
# ---------------------------------------------------------------------------
# Tests: Agent endpoint
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_agent_endpoint():
send_fn = _make_send_fn()
receiver = _make_receiver(send_fn=send_fn)
async with TestClient(TestServer(receiver._app)) as client:
resp = await client.post("/hooks/agent", json={
"message": "research something",
"channel": "slack",
"channel_id": "C456",
})
assert resp.status == 200
assert len(send_fn.calls) == 1
@pytest.mark.asyncio
async def test_agent_empty_message():
receiver = _make_receiver()
async with TestClient(TestServer(receiver._app)) as client:
resp = await client.post("/hooks/agent", json={"message": ""})
assert resp.status == 400
@pytest.mark.asyncio
async def test_agent_auth_bearer():
receiver = _make_receiver(token="secret")
async with TestClient(TestServer(receiver._app)) as client:
resp = await client.post(
"/hooks/agent",
json={"message": "test"},
headers={"Authorization": "Bearer secret"},
)
assert resp.status == 200
@pytest.mark.asyncio
async def test_agent_auth_query_param():
receiver = _make_receiver(token="secret")
async with TestClient(TestServer(receiver._app)) as client:
resp = await client.post(
"/hooks/agent?token=secret",
json={"message": "test"},
)
assert resp.status == 200