stage-7: CLI tool with full subcommands
echo status/doctor/restart/logs/sessions/channel/send commands, symlink at ~/.local/bin/echo. QA fix: discord chat handler tuple unpacking bug. 32 new tests (193 total). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
353
cli.py
353
cli.py
@@ -3,14 +3,294 @@
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import getpass
|
import getpass
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import signal
|
||||||
import sys
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Add project root to 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
|
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):
|
def cmd_secrets(args):
|
||||||
"""Handle secrets subcommand."""
|
"""Handle secrets subcommand."""
|
||||||
@@ -54,10 +334,53 @@ def cmd_secrets(args):
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Argument parser
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(prog="echo", description="Echo Core CLI")
|
parser = argparse.ArgumentParser(prog="echo", description="Echo Core CLI")
|
||||||
sub = parser.add_subparsers(dest="command")
|
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
|
||||||
secrets_parser = sub.add_parser("secrets", help="Manage secrets")
|
secrets_parser = sub.add_parser("secrets", help="Manage secrets")
|
||||||
secrets_sub = secrets_parser.add_subparsers(dest="secrets_action")
|
secrets_sub = secrets_parser.add_subparsers(dest="secrets_action")
|
||||||
@@ -73,17 +396,35 @@ def main():
|
|||||||
|
|
||||||
secrets_sub.add_parser("test", help="Check required secrets")
|
secrets_sub.add_parser("test", help="Check required secrets")
|
||||||
|
|
||||||
|
# Parse and dispatch
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.command is None:
|
if args.command is None:
|
||||||
parser.print_help()
|
parser.print_help()
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
if args.command == "secrets":
|
dispatch = {
|
||||||
if args.secrets_action is None:
|
"status": cmd_status,
|
||||||
secrets_parser.print_help()
|
"doctor": cmd_doctor,
|
||||||
sys.exit(0)
|
"restart": cmd_restart,
|
||||||
cmd_secrets(args)
|
"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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
from discord import app_commands
|
from discord import app_commands
|
||||||
|
|
||||||
@@ -355,7 +353,7 @@ def create_bot(config: Config) -> discord.Client:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
async with message.channel.typing():
|
async with message.channel.typing():
|
||||||
response = await asyncio.to_thread(
|
response, _is_cmd = await asyncio.to_thread(
|
||||||
route_message, channel_id, user_id, text
|
route_message, channel_id, user_id, text
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
response = send_message(channel_id, text, model=model)
|
||||||
return response, False
|
return response, False
|
||||||
except Exception as e:
|
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
|
return f"Error: {e}", False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
366
tests/test_cli.py
Normal file
366
tests/test_cli.py
Normal file
@@ -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()
|
||||||
@@ -397,7 +397,7 @@ class TestOnMessage:
|
|||||||
@patch("src.adapters.discord_bot.route_message")
|
@patch("src.adapters.discord_bot.route_message")
|
||||||
async def test_chat_flow(self, mock_route, owned_bot):
|
async def test_chat_flow(self, mock_route, owned_bot):
|
||||||
"""on_message chat flow: reaction, typing, route, send, cleanup."""
|
"""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)
|
on_message = self._get_on_message(owned_bot)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user