"""Command executors: run shell commands locally or over SSH.""" from __future__ import annotations import subprocess from typing import Tuple class ExecutorError(Exception): pass class LocalExecutor: """Run shell commands on the local machine.""" name = "localhost" def run(self, cmd: str, timeout: int = 30) -> Tuple[str, int]: """ Execute *cmd* in a shell and return (combined_output, exit_code). stdout and stderr are merged so callers get one string to inspect. """ try: result = subprocess.run( cmd, shell=True, capture_output=True, text=True, timeout=timeout, ) combined = (result.stdout + result.stderr).strip() return combined, result.returncode except subprocess.TimeoutExpired: return f"[TIMEOUT after {timeout}s]", 1 except Exception as exc: return f"[ERROR: {exc}]", 1 def close(self) -> None: pass # nothing to clean up class SSHExecutor: """Run shell commands on a remote host over SSH (requires paramiko).""" def __init__( self, host: str, port: int = 22, username: str = "root", password: str | None = None, key_file: str | None = None, ) -> None: try: import paramiko # noqa: F401 except ImportError as exc: raise ExecutorError( "paramiko is required for SSH mode: pip install paramiko" ) from exc import paramiko self.name = host self._client = paramiko.SSHClient() self._client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) self._client.connect( host, port=port, username=username, password=password, key_filename=key_file, timeout=10, ) def run(self, cmd: str, timeout: int = 30) -> Tuple[str, int]: """Execute *cmd* on the remote host and return (combined_output, exit_code).""" try: _, stdout, stderr = self._client.exec_command(cmd, timeout=timeout) exit_code = stdout.channel.recv_exit_status() out = stdout.read().decode(errors="replace") err = stderr.read().decode(errors="replace") return (out + err).strip(), exit_code except Exception as exc: return f"[ERROR: {exc}]", 1 def close(self) -> None: self._client.close()