"""CLI entry point for the crg-daemon multi-repo watcher. Usage: crg-daemon start [--foreground] crg-daemon stop crg-daemon restart [--foreground] crg-daemon status crg-daemon logs [--repo ALIAS] [--follow] [--lines N] crg-daemon add [--alias ALIAS] crg-daemon remove """ from __future__ import annotations import argparse import logging import os import signal import subprocess import sys import time logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Subcommand handlers # --------------------------------------------------------------------------- def _handle_start(args: argparse.Namespace) -> None: """Start the daemon process.""" from .daemon import WatchDaemon, is_daemon_running, load_config if is_daemon_running(): print("Error: Daemon is already running.") sys.exit(1) config = load_config() daemon = WatchDaemon(config=config) daemon.start() if not args.foreground: daemon.daemonize() daemon.run_forever() def _handle_stop(_args: argparse.Namespace) -> None: """Stop the running daemon process.""" from .daemon import clear_pid, is_daemon_running, read_pid if not is_daemon_running(): print("Daemon is not running.") sys.exit(1) pid = read_pid() if pid is None: print("Error: Could not read daemon PID.") sys.exit(1) print(f"Stopping daemon (PID {pid})...") try: os.kill(pid, signal.SIGTERM) except ProcessLookupError: clear_pid() print("Daemon stopped (process already gone).") return except PermissionError: print(f"Error: Permission denied sending signal to PID {pid}.") sys.exit(1) # Wait up to 5 seconds for process to die for _ in range(50): try: os.kill(pid, 0) except ProcessLookupError: break time.sleep(0.1) else: # Still alive after 5s — send SIGKILL print("Daemon did not stop gracefully, sending SIGKILL...") try: os.kill(pid, signal.SIGKILL) except ProcessLookupError: pass clear_pid() print("Daemon stopped.") def _handle_restart(args: argparse.Namespace) -> None: """Restart the daemon (stop + start).""" from .daemon import is_daemon_running if is_daemon_running(): _handle_stop(args) else: print("Daemon is not running, starting fresh.") _handle_start(args) def _handle_status(_args: argparse.Namespace) -> None: """Show daemon status and configuration.""" from .daemon import is_daemon_running, load_config, load_state, pid_alive, read_pid config = load_config() running = is_daemon_running() if running: pid = read_pid() print(f"Daemon: running (PID {pid})") else: print("Daemon: not running") print(f"Name: {config.session_name}") print(f"Log dir: {config.log_dir}") print(f"Poll: {config.poll_interval}s") print() if not config.repos: print("No repositories configured.") print("Use: crg-daemon add [--alias NAME]") return # Header alias_width = max(len(r.alias) for r in config.repos) alias_width = max(alias_width, 5) # minimum "Alias" header width if running: state = load_state() print(f" {'Alias':<{alias_width}} {'Status':<8} {'PID':<8} Path") print(f" {'-' * alias_width} {'-' * 8} {'-' * 8} {'-' * 40}") for repo in config.repos: entry = state.get(repo.alias, {}) child_pid: int | None = entry.get("pid") alive = child_pid is not None and pid_alive(child_pid) status_str = "alive" if alive else "dead" pid_str = str(child_pid) if child_pid is not None else "-" print(f" {repo.alias:<{alias_width}} {status_str:<8} {pid_str:<8} {repo.path}") else: print(f" {'Alias':<{alias_width}} Path") print(f" {'-' * alias_width} {'-' * 40}") for repo in config.repos: print(f" {repo.alias:<{alias_width}} {repo.path}") def _handle_logs(args: argparse.Namespace) -> None: """Show daemon or per-repo log files.""" from .daemon import load_config config = load_config() if args.repo: log_file = config.log_dir / f"{args.repo}.log" else: log_file = config.log_dir / "daemon.log" if not log_file.exists(): print(f"Log file not found: {log_file}") sys.exit(1) if args.follow: try: subprocess.run(["tail", "-f", str(log_file)], check=False) except KeyboardInterrupt: pass return # Read last N lines lines_count = args.lines try: text = log_file.read_text(encoding="utf-8", errors="replace") except OSError as exc: print(f"Error reading log file: {exc}") sys.exit(1) lines = text.splitlines() tail = lines[-lines_count:] if len(lines) > lines_count else lines for line in tail: print(line) def _handle_add(args: argparse.Namespace) -> None: """Add a repository to the daemon config.""" from .daemon import add_repo_to_config, is_daemon_running try: add_repo_to_config(args.path, alias=args.alias) except ValueError as exc: print(f"Error: {exc}") sys.exit(1) # Find the repo we just added to show confirmation alias = args.alias or os.path.basename(os.path.abspath(args.path)) print(f"Added repository: {args.path} (alias: {alias})") if is_daemon_running(): print("Daemon will pick up the change automatically.") def _handle_remove(args: argparse.Namespace) -> None: """Remove a repository from the daemon config.""" from .daemon import is_daemon_running, load_config, remove_repo_from_config config_before = load_config() count_before = len(config_before.repos) config_after = remove_repo_from_config(args.path_or_alias) count_after = len(config_after.repos) if count_before == count_after: print(f"No repository matching '{args.path_or_alias}' found in config.") sys.exit(1) print(f"Removed repository: {args.path_or_alias}") if is_daemon_running(): print("Daemon will pick up the change automatically.") # --------------------------------------------------------------------------- # Main entry point # --------------------------------------------------------------------------- def main() -> None: """Entry point for the crg-daemon CLI.""" logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") ap = argparse.ArgumentParser( prog="crg-daemon", description="Multi-repo watch daemon for code-review-graph", ) sub = ap.add_subparsers(dest="command") # start start_cmd = sub.add_parser("start", help="Start the daemon") start_cmd.add_argument( "--foreground", action="store_true", help="Run in the foreground instead of daemonizing", ) # stop sub.add_parser("stop", help="Stop the daemon") # restart restart_cmd = sub.add_parser("restart", help="Restart the daemon") restart_cmd.add_argument( "--foreground", action="store_true", help="Run in the foreground instead of daemonizing", ) # status sub.add_parser("status", help="Show daemon status and configuration") # logs logs_cmd = sub.add_parser("logs", help="Show daemon or per-repo logs") logs_cmd.add_argument( "--repo", default=None, metavar="ALIAS", help="Show logs for a specific repo (by alias)", ) logs_cmd.add_argument( "--follow", "-f", action="store_true", help="Follow log output (tail -f)", ) logs_cmd.add_argument( "--lines", "-n", type=int, default=50, help="Number of lines to show (default: 50)", ) # add add_cmd = sub.add_parser("add", help="Add a repository to the daemon config") add_cmd.add_argument("path", help="Path to the repository") add_cmd.add_argument( "--alias", default=None, help="Short alias for the repository (default: directory name)", ) # remove remove_cmd = sub.add_parser("remove", help="Remove a repository from the daemon config") remove_cmd.add_argument("path_or_alias", help="Repository path or alias to remove") args = ap.parse_args() if not args.command: ap.print_help() sys.exit(0) handlers: dict[str, object] = { "start": _handle_start, "stop": _handle_stop, "restart": _handle_restart, "status": _handle_status, "logs": _handle_logs, "add": _handle_add, "remove": _handle_remove, } handler = handlers.get(args.command) if handler is None: ap.print_help() sys.exit(1) handler(args) # type: ignore[operator] if __name__ == "__main__": main()