diff --git a/src/pyra/cli.py b/src/pyra/cli.py index d4f53da..a6456c8 100644 --- a/src/pyra/cli.py +++ b/src/pyra/cli.py @@ -266,43 +266,144 @@ def daemon() -> None: _bootstrap_or_exit() +@daemon.command("run", hidden=True) +def daemon_run() -> None: + """Run daemon in foreground (used by service manager).""" + from pyra.daemon.core import run_foreground + run_foreground() + + @daemon.command("start") def daemon_start() -> None: """Start the Pyra daemon in the background.""" - console.print("[yellow]Daemon (Stage 6) is not yet implemented.[/yellow]") + from pyra.daemon.core import start_background + try: + start_background() + except FileNotFoundError: + console.print("[red]Error:[/red] Run [dim]pyra setup[/dim] first.") @daemon.command("stop") def daemon_stop() -> None: """Stop the running Pyra daemon.""" - console.print("[yellow]Daemon (Stage 6) is not yet implemented.[/yellow]") + _daemon_ipc("stop", success_msg="Daemon stopped.") @daemon.command("status") def daemon_status() -> None: """Show daemon status.""" - console.print("[yellow]Daemon (Stage 6) is not yet implemented.[/yellow]") + _daemon_ipc("status") @daemon.command("restart") def daemon_restart() -> None: """Restart the Pyra daemon.""" - console.print("[yellow]Daemon (Stage 6) is not yet implemented.[/yellow]") + import time + from pyra.daemon.core import start_background + _daemon_ipc("stop", success_msg=None) + time.sleep(1.5) + try: + start_background() + except FileNotFoundError: + console.print("[red]Error:[/red] Run [dim]pyra setup[/dim] first.") @daemon.command("install") def daemon_install() -> None: - """Install Pyra as a system service (launchd/systemd).""" - console.print("[yellow]Daemon service install (Stage 6) is not yet implemented.[/yellow]") + """Install Pyra as a system service (launchd/systemd/schtasks).""" + from pyra.daemon.service import detect_platform, install_service + try: + install_service() + console.print(f"[green]Service installed[/green] ({detect_platform()}).") + except Exception as exc: + console.print(f"[red]Install failed:[/red] {exc}") @daemon.command("uninstall") def daemon_uninstall() -> None: """Remove the Pyra system service.""" - console.print("[yellow]Daemon service uninstall (Stage 6) is not yet implemented.[/yellow]") + from pyra.daemon.service import uninstall_service + try: + uninstall_service() + console.print("[green]Service removed.[/green]") + except Exception as exc: + console.print(f"[red]Uninstall failed:[/red] {exc}") -@daemon.command("run", hidden=True) -def daemon_run() -> None: - """Run daemon in foreground (used by service manager).""" - console.print("[yellow]Daemon (Stage 6) is not yet implemented.[/yellow]") +def _daemon_ipc(cmd: str, *, success_msg: str | None = None) -> None: + """Send a command to the running daemon via IPC and render the response.""" + from pyra.config.manager import load_config + from pyra.daemon.ipc import ( + get_socket_path, + is_unix_socket, + get_port_file_path, + send_command, + ) + + try: + cfg = load_config() + except FileNotFoundError: + console.print("[red]Error:[/red] Run [dim]pyra setup[/dim] first.") + return + + if is_unix_socket(): + address = get_socket_path(cfg.daemon.socket_path) + else: + port = _read_windows_port() + if port is None: + console.print("[yellow]Daemon is not running.[/yellow]") + return + address = ("127.0.0.1", port) + + try: + resp = send_command(address, {"cmd": cmd}) + except (ConnectionRefusedError, FileNotFoundError, OSError): + console.print("[yellow]Daemon is not running.[/yellow]") + return + except ConnectionResetError: + console.print("[red]Permission denied:[/red] daemon rejected connection.") + return + except TimeoutError: + console.print("[red]Daemon did not respond in time.[/red]") + return + + if not resp.get("ok"): + console.print(f"[red]Error:[/red] {resp.get('data', {}).get('error', 'unknown')}") + return + + if cmd == "status": + _render_daemon_status(resp["data"]) + elif success_msg: + console.print(f"[green]{success_msg}[/green]") + + +def _read_windows_port() -> int | None: + from pyra.daemon.ipc import get_port_file_path + try: + return int(get_port_file_path().read_text().strip()) + except (FileNotFoundError, ValueError): + return None + + +def _render_daemon_status(data: dict) -> None: + from rich.table import Table + + uptime = data.get("uptime", 0.0) + pid = data.get("pid", "?") + tasks = data.get("tasks", []) + + hours, rem = divmod(int(uptime), 3600) + mins, secs = divmod(rem, 60) + uptime_str = f"{hours}h {mins}m {secs}s" if hours else f"{mins}m {secs}s" + + console.print(f"[bold green]Daemon running[/bold green] — PID {pid}, uptime {uptime_str}") + + if tasks: + table = Table("Task", "Alive", "Restarts", "Last error", show_header=True) + for t in tasks: + alive = "[green]yes[/green]" if t.get("alive") else "[red]no[/red]" + error = t.get("last_error") or "—" + table.add_row(t.get("name", "?"), alive, str(t.get("restart_count", 0)), error) + console.print(table) + else: + console.print("[dim]No plugin tasks registered.[/dim]")