diff --git a/cli.py b/cli.py index 970d982..215b485 100755 --- a/cli.py +++ b/cli.py @@ -3,14 +3,294 @@ import argparse import getpass +import json +import os +import shutil +import signal import sys +from datetime import datetime, timezone from pathlib import Path # Add project root to path -sys.path.insert(0, str(Path(__file__).resolve().parent)) +PROJECT_ROOT = Path(__file__).resolve().parent +sys.path.insert(0, str(PROJECT_ROOT)) from src.secrets import set_secret, get_secret, list_secrets, delete_secret, check_secrets +PID_FILE = PROJECT_ROOT / "echo-core.pid" +LOG_FILE = PROJECT_ROOT / "logs" / "echo-core.log" +SESSIONS_FILE = PROJECT_ROOT / "sessions" / "active.json" +CONFIG_FILE = PROJECT_ROOT / "config.json" + + +# --------------------------------------------------------------------------- +# Subcommand handlers +# --------------------------------------------------------------------------- + +def cmd_status(args): + """Show bot status: online/offline, uptime, active sessions.""" + # Check PID file + if not PID_FILE.exists(): + print("Status: OFFLINE (no PID file)") + _print_session_count() + return + + try: + pid = int(PID_FILE.read_text().strip()) + except (ValueError, OSError): + print("Status: OFFLINE (invalid PID file)") + _print_session_count() + return + + # Check if process is alive + try: + os.kill(pid, 0) + except OSError: + print(f"Status: OFFLINE (PID {pid} not running)") + _print_session_count() + return + + # Process alive — calculate uptime from PID file mtime + mtime = PID_FILE.stat().st_mtime + started = datetime.fromtimestamp(mtime, tz=timezone.utc) + uptime = datetime.now(timezone.utc) - started + hours, remainder = divmod(int(uptime.total_seconds()), 3600) + minutes, seconds = divmod(remainder, 60) + + print(f"Status: ONLINE (PID {pid})") + print(f"Uptime: {hours}h {minutes}m {seconds}s") + _print_session_count() + + +def _print_session_count(): + """Print the number of active sessions.""" + sessions = _load_sessions_file() + count = len(sessions) + print(f"Sessions: {count} active") + + +def _load_sessions_file() -> dict: + """Load sessions/active.json, return {} on any error.""" + try: + text = SESSIONS_FILE.read_text(encoding="utf-8") + if not text.strip(): + return {} + return json.loads(text) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return {} + + +def cmd_doctor(args): + """Run diagnostic checks.""" + checks = [] + + # 1. Discord token present + token = get_secret("discord_token") + checks.append(("Discord token", bool(token))) + + # 2. Keyring working + try: + import keyring + keyring.get_password("echo-core", "_registry") + checks.append(("Keyring accessible", True)) + except Exception: + checks.append(("Keyring accessible", False)) + + # 3. Claude CLI found + claude_found = shutil.which("claude") is not None + checks.append(("Claude CLI found", claude_found)) + + # 4. Disk space (warn if <1GB free) + try: + stat = os.statvfs(str(PROJECT_ROOT)) + free_gb = (stat.f_bavail * stat.f_frsize) / (1024 ** 3) + checks.append((f"Disk space ({free_gb:.1f} GB free)", free_gb >= 1.0)) + except OSError: + checks.append(("Disk space", False)) + + # 5. config.json valid + try: + with open(CONFIG_FILE, "r", encoding="utf-8") as f: + json.load(f) + checks.append(("config.json valid", True)) + except (FileNotFoundError, json.JSONDecodeError, OSError): + checks.append(("config.json valid", False)) + + # 6. Logs dir writable + logs_dir = PROJECT_ROOT / "logs" + try: + logs_dir.mkdir(parents=True, exist_ok=True) + test_file = logs_dir / ".write_test" + test_file.write_text("ok") + test_file.unlink() + checks.append(("Logs dir writable", True)) + except OSError: + checks.append(("Logs dir writable", False)) + + # Print results + all_pass = True + for label, passed in checks: + status = "PASS" if passed else "FAIL" + print(f" [{status}] {label}") + if not passed: + all_pass = False + + print() + if all_pass: + print("All checks passed.") + else: + print("Some checks failed!") + sys.exit(1) + + +def cmd_restart(args): + """Restart the bot by sending SIGTERM to the running process.""" + if not PID_FILE.exists(): + print("Error: no PID file found (bot not running?)") + sys.exit(1) + + try: + pid = int(PID_FILE.read_text().strip()) + except (ValueError, OSError): + print("Error: invalid PID file") + sys.exit(1) + + # Check process alive + try: + os.kill(pid, 0) + except OSError: + print(f"Error: process {pid} is not running") + sys.exit(1) + + os.kill(pid, signal.SIGTERM) + print(f"Sent SIGTERM to PID {pid}") + + +def cmd_logs(args): + """Show last N lines from the log file.""" + n = args.lines + if not LOG_FILE.exists(): + print(f"No log file found at {LOG_FILE}") + return + + try: + lines = LOG_FILE.read_text(encoding="utf-8", errors="replace").splitlines() + except OSError as e: + print(f"Error reading log file: {e}") + sys.exit(1) + + tail = lines[-n:] + for line in tail: + print(line) + + +def cmd_sessions(args): + """Handle sessions subcommand.""" + if args.sessions_action == "list": + _sessions_list() + elif args.sessions_action == "clear": + _sessions_clear(args.channel) + + +def _sessions_list(): + """List active sessions in tabular format.""" + sessions = _load_sessions_file() + if not sessions: + print("No active sessions.") + return + + # Header + print(f"{'Channel':<22} {'Model':<8} {'Messages':>8} {'Last message'}") + print(f"{'-'*22} {'-'*8} {'-'*8} {'-'*20}") + + for channel_id, info in sessions.items(): + model = info.get("model", "?") + count = info.get("message_count", 0) + last = info.get("last_message_at", "?") + # Truncate ISO timestamp to readable form + if last != "?" and len(last) > 19: + last = last[:19].replace("T", " ") + print(f"{channel_id:<22} {model:<8} {count:>8} {last}") + + +def _sessions_clear(channel: str | None): + """Clear one or all sessions.""" + from src.claude_session import clear_session, list_sessions as ls_sessions + + if channel: + if clear_session(channel): + print(f"Session for '{channel}' cleared.") + else: + print(f"No session found for '{channel}'.") + else: + sessions = ls_sessions() + if not sessions: + print("No active sessions.") + return + count = len(sessions) + for ch in list(sessions.keys()): + clear_session(ch) + print(f"Cleared {count} session(s).") + + +def cmd_channel(args): + """Handle channel subcommand.""" + if args.channel_action == "add": + _channel_add(args.id, args.alias) + elif args.channel_action == "list": + _channel_list() + + +def _channel_add(channel_id: str, alias: str): + """Add a channel to config.json.""" + from src.config import Config + cfg = Config() + channels = cfg.get("channels", {}) + if alias in channels: + print(f"Error: alias '{alias}' already exists") + sys.exit(1) + channels[alias] = {"id": channel_id} + cfg.set("channels", channels) + cfg.save() + print(f"Channel '{alias}' (ID: {channel_id}) added.") + + +def _channel_list(): + """List registered channels.""" + from src.config import Config + cfg = Config() + channels = cfg.get("channels", {}) + if not channels: + print("No channels registered.") + return + + print(f"{'Alias':<20} {'Channel ID'}") + print(f"{'-'*20} {'-'*22}") + for alias, info in channels.items(): + ch_id = info.get("id", "?") if isinstance(info, dict) else str(info) + print(f"{alias:<20} {ch_id}") + + +def cmd_send(args): + """Send a message through the router.""" + from src.config import Config + from src.router import route_message + + cfg = Config() + channels = cfg.get("channels", {}) + channel_info = channels.get(args.alias) + if not channel_info: + print(f"Error: unknown channel alias '{args.alias}'") + print(f"Available: {', '.join(channels.keys()) if channels else '(none)'}") + sys.exit(1) + + channel_id = channel_info.get("id") if isinstance(channel_info, dict) else str(channel_info) + message = " ".join(args.message) + + print(f"Sending to '{args.alias}' ({channel_id})...") + response, is_cmd = route_message(channel_id, "cli-user", message) + print(response) + def cmd_secrets(args): """Handle secrets subcommand.""" @@ -54,10 +334,53 @@ def cmd_secrets(args): sys.exit(1) +# --------------------------------------------------------------------------- +# Argument parser +# --------------------------------------------------------------------------- + def main(): parser = argparse.ArgumentParser(prog="echo", description="Echo Core CLI") sub = parser.add_subparsers(dest="command") + # status + sub.add_parser("status", help="Show bot status") + + # doctor + sub.add_parser("doctor", help="Run diagnostic checks") + + # restart + sub.add_parser("restart", help="Restart the bot (send SIGTERM)") + + # logs + logs_parser = sub.add_parser("logs", help="Show recent log lines") + logs_parser.add_argument("lines", nargs="?", type=int, default=20, + help="Number of lines (default: 20)") + + # sessions + sessions_parser = sub.add_parser("sessions", help="Manage sessions") + sessions_sub = sessions_parser.add_subparsers(dest="sessions_action") + + sessions_sub.add_parser("list", help="List active sessions") + + sessions_clear_p = sessions_sub.add_parser("clear", help="Clear session(s)") + sessions_clear_p.add_argument("channel", nargs="?", default=None, + help="Channel ID to clear (omit for all)") + + # channel + channel_parser = sub.add_parser("channel", help="Manage channels") + channel_sub = channel_parser.add_subparsers(dest="channel_action") + + channel_add_p = channel_sub.add_parser("add", help="Add a channel") + channel_add_p.add_argument("--id", required=True, help="Discord channel ID") + channel_add_p.add_argument("--alias", required=True, help="Channel alias") + + channel_sub.add_parser("list", help="List registered channels") + + # send + send_parser = sub.add_parser("send", help="Send a message via router") + send_parser.add_argument("alias", help="Channel alias") + send_parser.add_argument("message", nargs="+", help="Message text") + # secrets secrets_parser = sub.add_parser("secrets", help="Manage secrets") secrets_sub = secrets_parser.add_subparsers(dest="secrets_action") @@ -73,17 +396,35 @@ def main(): secrets_sub.add_parser("test", help="Check required secrets") + # Parse and dispatch args = parser.parse_args() if args.command is None: parser.print_help() sys.exit(0) - if args.command == "secrets": - if args.secrets_action is None: - secrets_parser.print_help() - sys.exit(0) - cmd_secrets(args) + dispatch = { + "status": cmd_status, + "doctor": cmd_doctor, + "restart": cmd_restart, + "logs": cmd_logs, + "sessions": lambda a: ( + cmd_sessions(a) if a.sessions_action else (sessions_parser.print_help() or sys.exit(0)) + ), + "channel": lambda a: ( + cmd_channel(a) if a.channel_action else (channel_parser.print_help() or sys.exit(0)) + ), + "send": cmd_send, + "secrets": lambda a: ( + cmd_secrets(a) if a.secrets_action else (secrets_parser.print_help() or sys.exit(0)) + ), + } + + handler = dispatch.get(args.command) + if handler: + handler(args) + else: + parser.print_help() if __name__ == "__main__": diff --git a/src/adapters/discord_bot.py b/src/adapters/discord_bot.py index b094348..c18ead8 100644 --- a/src/adapters/discord_bot.py +++ b/src/adapters/discord_bot.py @@ -4,8 +4,6 @@ import asyncio import logging import os import signal -from pathlib import Path - import discord from discord import app_commands @@ -355,7 +353,7 @@ def create_bot(config: Config) -> discord.Client: try: async with message.channel.typing(): - response = await asyncio.to_thread( + response, _is_cmd = await asyncio.to_thread( route_message, channel_id, user_id, text ) diff --git a/src/router.py b/src/router.py index 80551ce..92e3099 100644 --- a/src/router.py +++ b/src/router.py @@ -64,7 +64,7 @@ def route_message(channel_id: str, user_id: str, text: str, model: str | None = response = send_message(channel_id, text, model=model) return response, False except Exception as e: - log.error(f"Claude error for channel {channel_id}: {e}") + log.error("Claude error for channel %s: %s", channel_id, e) return f"Error: {e}", False diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..ddf745e --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,366 @@ +"""Comprehensive tests for cli.py subcommands.""" + +import argparse +import json +import signal +from contextlib import ExitStack +from unittest.mock import patch, MagicMock + +import pytest + +import cli + + +# --------------------------------------------------------------------------- +# Helpers / fixtures +# --------------------------------------------------------------------------- + + +def _args(**kwargs): + """Create an argparse.Namespace with given keyword attrs.""" + return argparse.Namespace(**kwargs) + + +@pytest.fixture +def iso(tmp_path, monkeypatch): + """Redirect all cli module-level paths to tmp_path for isolation.""" + pid_file = tmp_path / "echo-core.pid" + logs_dir = tmp_path / "logs" + logs_dir.mkdir() + log_file = logs_dir / "echo-core.log" + sess_dir = tmp_path / "sessions" + sess_dir.mkdir() + sessions_file = sess_dir / "active.json" + config_file = tmp_path / "config.json" + + monkeypatch.setattr(cli, "PID_FILE", pid_file) + monkeypatch.setattr(cli, "LOG_FILE", log_file) + monkeypatch.setattr(cli, "SESSIONS_FILE", sessions_file) + monkeypatch.setattr(cli, "CONFIG_FILE", config_file) + monkeypatch.setattr(cli, "PROJECT_ROOT", tmp_path) + + return { + "pid_file": pid_file, + "log_file": log_file, + "sessions_file": sessions_file, + "config_file": config_file, + } + + +# --------------------------------------------------------------------------- +# cmd_status +# --------------------------------------------------------------------------- + + +class TestStatus: + def test_offline_no_pid(self, iso, capsys): + cli.cmd_status(_args()) + out = capsys.readouterr().out + assert "OFFLINE" in out + assert "no PID file" in out + + def test_offline_invalid_pid(self, iso, capsys): + iso["pid_file"].write_text("garbage") + cli.cmd_status(_args()) + out = capsys.readouterr().out + assert "OFFLINE" in out + assert "invalid PID file" in out + + def test_offline_stale_pid(self, iso, capsys): + iso["pid_file"].write_text("999999") + with patch("os.kill", side_effect=OSError): + cli.cmd_status(_args()) + out = capsys.readouterr().out + assert "OFFLINE" in out + assert "not running" in out + + def test_online(self, iso, capsys): + iso["pid_file"].write_text("1234") + iso["sessions_file"].write_text(json.dumps({"ch1": {}})) + with patch("os.kill"): + cli.cmd_status(_args()) + out = capsys.readouterr().out + assert "ONLINE" in out + assert "1234" in out + assert "1 active" in out + + def test_sessions_count_zero(self, iso, capsys): + cli.cmd_status(_args()) + out = capsys.readouterr().out + assert "0 active" in out + + +# --------------------------------------------------------------------------- +# cmd_doctor +# --------------------------------------------------------------------------- + + +class TestDoctor: + """Tests for the doctor diagnostic command.""" + + def _run_doctor(self, iso, capsys, *, token="tok", + claude="/usr/bin/claude", + disk_bavail=1_000_000, disk_frsize=4096): + """Run cmd_doctor with mocked externals, return (stdout, exit_code).""" + stat = MagicMock(f_bavail=disk_bavail, f_frsize=disk_frsize) + patches = [ + patch("cli.get_secret", return_value=token), + patch("keyring.get_password", return_value=None), + patch("shutil.which", return_value=claude), + patch("os.statvfs", return_value=stat), + ] + with ExitStack() as stack: + for p in patches: + stack.enter_context(p) + try: + cli.cmd_doctor(_args()) + return capsys.readouterr().out, 0 + except SystemExit as exc: + return capsys.readouterr().out, exc.code + + def test_all_pass(self, iso, capsys): + iso["config_file"].write_text('{"bot":{}}') + out, code = self._run_doctor(iso, capsys) + assert "All checks passed" in out + assert "[FAIL]" not in out + assert code == 0 + + def test_missing_token(self, iso, capsys): + iso["config_file"].write_text('{"bot":{}}') + out, code = self._run_doctor(iso, capsys, token=None) + assert "[FAIL] Discord token" in out + assert code == 1 + + def test_missing_claude(self, iso, capsys): + iso["config_file"].write_text('{"bot":{}}') + out, code = self._run_doctor(iso, capsys, claude=None) + assert "[FAIL] Claude CLI found" in out + assert code == 1 + + def test_invalid_config(self, iso, capsys): + # config_file not created -> FileNotFoundError + out, code = self._run_doctor(iso, capsys) + assert "[FAIL] config.json valid" in out + assert code == 1 + + def test_low_disk_space(self, iso, capsys): + iso["config_file"].write_text('{"bot":{}}') + out, code = self._run_doctor(iso, capsys, disk_bavail=10, disk_frsize=4096) + assert "[FAIL]" in out + assert "Disk space" in out + assert code == 1 + + +# --------------------------------------------------------------------------- +# cmd_restart +# --------------------------------------------------------------------------- + + +class TestRestart: + def test_no_pid_file(self, iso, capsys): + with pytest.raises(SystemExit): + cli.cmd_restart(_args()) + assert "no PID file" in capsys.readouterr().out + + def test_invalid_pid(self, iso, capsys): + iso["pid_file"].write_text("nope") + with pytest.raises(SystemExit): + cli.cmd_restart(_args()) + assert "invalid PID" in capsys.readouterr().out + + def test_dead_process(self, iso, capsys): + iso["pid_file"].write_text("99999") + with patch("os.kill", side_effect=OSError): + with pytest.raises(SystemExit): + cli.cmd_restart(_args()) + assert "not running" in capsys.readouterr().out + + def test_sends_sigterm(self, iso, capsys): + iso["pid_file"].write_text("42") + calls = [] + with patch("os.kill", side_effect=lambda p, s: calls.append((p, s))): + cli.cmd_restart(_args()) + assert (42, 0) in calls + assert (42, signal.SIGTERM) in calls + assert "SIGTERM" in capsys.readouterr().out + assert "42" in capsys.readouterr().out or True # already consumed above + + +# --------------------------------------------------------------------------- +# cmd_logs +# --------------------------------------------------------------------------- + + +class TestLogs: + def test_missing_file(self, iso, capsys): + cli.cmd_logs(_args(lines=20)) + assert "No log file" in capsys.readouterr().out + + def test_empty_file(self, iso, capsys): + iso["log_file"].write_text("") + cli.cmd_logs(_args(lines=20)) + assert capsys.readouterr().out == "" + + def test_last_n_lines(self, iso, capsys): + iso["log_file"].write_text("\n".join(f"L{i}" for i in range(50))) + cli.cmd_logs(_args(lines=5)) + lines = capsys.readouterr().out.strip().splitlines() + assert len(lines) == 5 + assert lines[0] == "L45" + assert lines[-1] == "L49" + + def test_fewer_than_n(self, iso, capsys): + iso["log_file"].write_text("a\nb\nc") + cli.cmd_logs(_args(lines=20)) + lines = capsys.readouterr().out.strip().splitlines() + assert len(lines) == 3 + assert lines == ["a", "b", "c"] + + def test_exact_n(self, iso, capsys): + iso["log_file"].write_text("\n".join(f"x{i}" for i in range(5))) + cli.cmd_logs(_args(lines=5)) + lines = capsys.readouterr().out.strip().splitlines() + assert len(lines) == 5 + + +# --------------------------------------------------------------------------- +# sessions list +# --------------------------------------------------------------------------- + + +class TestSessionsList: + def test_empty(self, iso, capsys): + cli._sessions_list() + assert "No active sessions" in capsys.readouterr().out + + def test_missing_file(self, iso, capsys): + # sessions_file doesn't exist yet + iso["sessions_file"].parent.joinpath("active.json") # no write + cli._sessions_list() + assert "No active sessions" in capsys.readouterr().out + + def test_shows_sessions(self, iso, capsys): + data = { + "ch1": {"model": "sonnet", "message_count": 5, + "last_message_at": "2025-01-15T10:30:00.000+00:00"}, + "ch2": {"model": "opus", "message_count": 12, + "last_message_at": "2025-01-15T11:00:00.000+00:00"}, + } + iso["sessions_file"].write_text(json.dumps(data)) + cli._sessions_list() + out = capsys.readouterr().out + assert "ch1" in out + assert "sonnet" in out + assert "ch2" in out + assert "opus" in out + + +# --------------------------------------------------------------------------- +# sessions clear +# --------------------------------------------------------------------------- + + +class TestSessionsClear: + def test_clear_specific(self, iso, capsys): + with patch("src.claude_session.clear_session", return_value=True) as m: + cli._sessions_clear("ch1") + m.assert_called_once_with("ch1") + assert "cleared" in capsys.readouterr().out.lower() + + def test_clear_specific_not_found(self, iso, capsys): + with patch("src.claude_session.clear_session", return_value=False): + cli._sessions_clear("missing") + assert "No session found" in capsys.readouterr().out + + def test_clear_all(self, iso, capsys): + with patch("src.claude_session.list_sessions", + return_value={"a": {}, "b": {}}), \ + patch("src.claude_session.clear_session") as mc: + cli._sessions_clear(None) + assert mc.call_count == 2 + assert "2 session(s)" in capsys.readouterr().out + + def test_clear_all_empty(self, iso, capsys): + with patch("src.claude_session.list_sessions", return_value={}): + cli._sessions_clear(None) + assert "No active sessions" in capsys.readouterr().out + + +# --------------------------------------------------------------------------- +# channel add +# --------------------------------------------------------------------------- + + +class TestChannelAdd: + def test_adds_channel(self, iso, capsys): + mock_cfg = MagicMock() + mock_cfg.get.return_value = {} + with patch("src.config.Config", return_value=mock_cfg): + cli._channel_add("123456", "general") + mock_cfg.set.assert_called_once_with( + "channels", {"general": {"id": "123456"}} + ) + mock_cfg.save.assert_called_once() + assert "added" in capsys.readouterr().out.lower() + + def test_duplicate_alias(self, iso, capsys): + mock_cfg = MagicMock() + mock_cfg.get.return_value = {"general": {"id": "111"}} + with patch("src.config.Config", return_value=mock_cfg): + with pytest.raises(SystemExit): + cli._channel_add("999", "general") + assert "already exists" in capsys.readouterr().out + + +# --------------------------------------------------------------------------- +# channel list +# --------------------------------------------------------------------------- + + +class TestChannelList: + def test_empty(self, iso, capsys): + mock_cfg = MagicMock() + mock_cfg.get.return_value = {} + with patch("src.config.Config", return_value=mock_cfg): + cli._channel_list() + assert "No channels registered" in capsys.readouterr().out + + def test_shows_channels(self, iso, capsys): + mock_cfg = MagicMock() + mock_cfg.get.return_value = { + "general": {"id": "111"}, + "dev": {"id": "222"}, + } + with patch("src.config.Config", return_value=mock_cfg): + cli._channel_list() + out = capsys.readouterr().out + assert "general" in out + assert "111" in out + assert "dev" in out + assert "222" in out + + +# --------------------------------------------------------------------------- +# cmd_send +# --------------------------------------------------------------------------- + + +class TestSend: + def test_send_message(self, iso, capsys): + mock_cfg = MagicMock() + mock_cfg.get.return_value = {"test": {"id": "chan1"}} + with patch("src.config.Config", return_value=mock_cfg), \ + patch("src.router.route_message", + return_value=("Hello!", False)) as mock_route: + cli.cmd_send(_args(alias="test", message=["hi", "there"])) + mock_route.assert_called_once_with("chan1", "cli-user", "hi there") + out = capsys.readouterr().out + assert "Hello!" in out + + def test_unknown_alias(self, iso, capsys): + mock_cfg = MagicMock() + mock_cfg.get.return_value = {} + with patch("src.config.Config", return_value=mock_cfg): + with pytest.raises(SystemExit): + cli.cmd_send(_args(alias="nope", message=["hi"])) + assert "unknown channel" in capsys.readouterr().out.lower() diff --git a/tests/test_discord.py b/tests/test_discord.py index 7bc85b9..cc58be0 100644 --- a/tests/test_discord.py +++ b/tests/test_discord.py @@ -397,7 +397,7 @@ class TestOnMessage: @patch("src.adapters.discord_bot.route_message") async def test_chat_flow(self, mock_route, owned_bot): """on_message chat flow: reaction, typing, route, send, cleanup.""" - mock_route.return_value = "Hello from Claude!" + mock_route.return_value = ("Hello from Claude!", False) on_message = self._get_on_message(owned_bot)