Scheduler class, cron/jobs.json, Discord /cron commands, CLI cron subcommand, job lifecycle management. 88 new tests (281 total). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
574 lines
18 KiB
Python
Executable File
574 lines
18 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Echo Core CLI tool."""
|
|
|
|
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
|
|
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_cron(args):
|
|
"""Handle cron subcommand."""
|
|
if args.cron_action == "list":
|
|
_cron_list()
|
|
elif args.cron_action == "run":
|
|
_cron_run(args.name)
|
|
elif args.cron_action == "add":
|
|
tools = [t.strip() for t in args.tools.split(",")] if args.tools else []
|
|
if args.prompt == "-":
|
|
prompt = sys.stdin.read().strip()
|
|
else:
|
|
prompt = args.prompt
|
|
_cron_add(args.name, args.expression, args.channel, prompt,
|
|
args.model, tools)
|
|
elif args.cron_action == "remove":
|
|
_cron_remove(args.name)
|
|
elif args.cron_action == "enable":
|
|
_cron_enable(args.name)
|
|
elif args.cron_action == "disable":
|
|
_cron_disable(args.name)
|
|
|
|
|
|
def _cron_list():
|
|
"""List scheduled jobs in tabular format."""
|
|
from src.scheduler import Scheduler
|
|
s = Scheduler()
|
|
s._jobs = s._load_jobs()
|
|
jobs = s.list_jobs()
|
|
if not jobs:
|
|
print("No scheduled jobs.")
|
|
return
|
|
|
|
print(f"{'Name':<24} {'Cron':<16} {'Channel':<10} {'Model':<8} {'Enabled':<8} {'Last Status':<12} {'Next Run'}")
|
|
print(f"{'-'*24} {'-'*16} {'-'*10} {'-'*8} {'-'*8} {'-'*12} {'-'*20}")
|
|
|
|
for job in jobs:
|
|
name = job.get("name", "?")
|
|
cron = job.get("cron", "?")
|
|
channel = job.get("channel", "?")
|
|
model = job.get("model", "?")
|
|
enabled = "yes" if job.get("enabled") else "no"
|
|
last_status = job.get("last_status") or "-"
|
|
next_run = job.get("next_run") or "-"
|
|
if next_run != "-" and len(next_run) > 19:
|
|
next_run = next_run[:19].replace("T", " ")
|
|
print(f"{name:<24} {cron:<16} {channel:<10} {model:<8} {enabled:<8} {last_status:<12} {next_run}")
|
|
|
|
|
|
def _cron_run(name: str):
|
|
"""Force-run a job and print output."""
|
|
import asyncio
|
|
from src.scheduler import Scheduler
|
|
|
|
async def _run():
|
|
s = Scheduler()
|
|
s._jobs = s._load_jobs()
|
|
return await s.run_job(name)
|
|
|
|
try:
|
|
result = asyncio.run(_run())
|
|
print(result)
|
|
except KeyError as exc:
|
|
print(f"Error: {exc}")
|
|
sys.exit(1)
|
|
|
|
|
|
def _cron_add(name: str, cron: str, channel: str, prompt: str,
|
|
model: str, tools: list[str]):
|
|
"""Add a new cron job."""
|
|
from src.scheduler import Scheduler
|
|
s = Scheduler()
|
|
s._jobs = s._load_jobs()
|
|
try:
|
|
job = s.add_job(name, cron, channel, prompt, model, tools or None)
|
|
print(f"Job '{job['name']}' added (cron: {job['cron']}, channel: {job['channel']}, model: {job['model']})")
|
|
except ValueError as exc:
|
|
print(f"Error: {exc}")
|
|
sys.exit(1)
|
|
|
|
|
|
def _cron_remove(name: str):
|
|
"""Remove a cron job."""
|
|
from src.scheduler import Scheduler
|
|
s = Scheduler()
|
|
s._jobs = s._load_jobs()
|
|
if s.remove_job(name):
|
|
print(f"Job '{name}' removed.")
|
|
else:
|
|
print(f"Job '{name}' not found.")
|
|
|
|
|
|
def _cron_enable(name: str):
|
|
"""Enable a cron job."""
|
|
from src.scheduler import Scheduler
|
|
s = Scheduler()
|
|
s._jobs = s._load_jobs()
|
|
if s.enable_job(name):
|
|
print(f"Job '{name}' enabled.")
|
|
else:
|
|
print(f"Job '{name}' not found.")
|
|
|
|
|
|
def _cron_disable(name: str):
|
|
"""Disable a cron job."""
|
|
from src.scheduler import Scheduler
|
|
s = Scheduler()
|
|
s._jobs = s._load_jobs()
|
|
if s.disable_job(name):
|
|
print(f"Job '{name}' disabled.")
|
|
else:
|
|
print(f"Job '{name}' not found.")
|
|
|
|
|
|
def cmd_secrets(args):
|
|
"""Handle secrets subcommand."""
|
|
if args.secrets_action == "set":
|
|
if args.file:
|
|
path = Path(args.file)
|
|
if not path.exists():
|
|
print(f"Error: file {args.file} not found")
|
|
sys.exit(1)
|
|
value = path.read_text().strip()
|
|
set_secret(args.name, value)
|
|
path.unlink() # Delete source file after storing
|
|
print(f"Secret '{args.name}' set from file (file deleted)")
|
|
else:
|
|
value = getpass.getpass(f"Enter value for '{args.name}': ")
|
|
set_secret(args.name, value)
|
|
print(f"Secret '{args.name}' set")
|
|
|
|
elif args.secrets_action == "list":
|
|
names = list_secrets()
|
|
if not names:
|
|
print("No secrets stored")
|
|
else:
|
|
for name in names:
|
|
print(f" - {name}")
|
|
|
|
elif args.secrets_action == "delete":
|
|
if delete_secret(args.name):
|
|
print(f"Secret '{args.name}' deleted")
|
|
else:
|
|
print(f"Secret '{args.name}' not found")
|
|
|
|
elif args.secrets_action == "test":
|
|
results = check_secrets()
|
|
for name, exists in results.items():
|
|
print(f" {name}: {'OK' if exists else 'MISSING'}")
|
|
if all(results.values()):
|
|
print("\nAll required secrets present.")
|
|
else:
|
|
print("\nWARNING: Some required secrets are missing!")
|
|
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")
|
|
|
|
set_p = secrets_sub.add_parser("set", help="Set a secret")
|
|
set_p.add_argument("name", help="Secret name")
|
|
set_p.add_argument("--file", help="Read value from file (file deleted after)")
|
|
|
|
secrets_sub.add_parser("list", help="List secret names")
|
|
|
|
del_p = secrets_sub.add_parser("delete", help="Delete a secret")
|
|
del_p.add_argument("name", help="Secret name")
|
|
|
|
secrets_sub.add_parser("test", help="Check required secrets")
|
|
|
|
# cron
|
|
cron_parser = sub.add_parser("cron", help="Manage scheduled jobs")
|
|
cron_sub = cron_parser.add_subparsers(dest="cron_action")
|
|
|
|
cron_sub.add_parser("list", help="List scheduled jobs")
|
|
|
|
cron_run_p = cron_sub.add_parser("run", help="Force-run a job")
|
|
cron_run_p.add_argument("name", help="Job name")
|
|
|
|
cron_add_p = cron_sub.add_parser("add", help="Add a scheduled job")
|
|
cron_add_p.add_argument("name", help="Job name")
|
|
cron_add_p.add_argument("expression", help="Cron expression (e.g. '30 6 * * *')")
|
|
cron_add_p.add_argument("--channel", required=True, help="Channel alias")
|
|
cron_add_p.add_argument("--prompt", required=True, help="Prompt text (use '-' for stdin)")
|
|
cron_add_p.add_argument("--model", default="sonnet", help="Model (default: sonnet)")
|
|
cron_add_p.add_argument("--tools", default=None, help="Comma-separated allowed tools")
|
|
|
|
cron_remove_p = cron_sub.add_parser("remove", help="Remove a job")
|
|
cron_remove_p.add_argument("name", help="Job name")
|
|
|
|
cron_enable_p = cron_sub.add_parser("enable", help="Enable a job")
|
|
cron_enable_p.add_argument("name", help="Job name")
|
|
|
|
cron_disable_p = cron_sub.add_parser("disable", help="Disable a job")
|
|
cron_disable_p.add_argument("name", help="Job name")
|
|
|
|
# Parse and dispatch
|
|
args = parser.parse_args()
|
|
|
|
if args.command is None:
|
|
parser.print_help()
|
|
sys.exit(0)
|
|
|
|
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,
|
|
"cron": lambda a: (
|
|
cmd_cron(a) if a.cron_action else (cron_parser.print_help() or sys.exit(0))
|
|
),
|
|
"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__":
|
|
main()
|