#!/usr/bin/env python3 """Interactive tool: plain English -> SSH commands via Ollama LLM. Usage: python3 scripts/ssh-planner.py "restart the media stack on atlantis" python3 scripts/ssh-planner.py --execute "check disk usage on all NAS boxes" python3 scripts/ssh-planner.py --dry-run "update packages on all debian hosts" """ import argparse import logging import subprocess import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).parent)) from lib.ollama import ollama_generate, ollama_available, OllamaUnavailableError log = logging.getLogger(__name__) HOST_ROLES = { "atlantis": "Primary NAS (Synology DS1823xs+), media stack, arr suite, Docker host", "calypso": "Secondary NAS (Synology DS920+), AdGuard DNS, Headscale, Authentik SSO", "nuc": "Intel NUC, lightweight services, concord", "homelab-vm": "Main VM (192.168.0.210), Prometheus, Grafana, monitoring stack", "rpi5": "Raspberry Pi 5, Uptime Kuma monitoring", "olares": "Olares k3s host (192.168.0.145), RTX 5090, Jellyfin, Ollama", "guava": "TrueNAS SCALE, additional storage and compute", "seattle": "Remote VM, lightweight services", "setillo": "GL.iNet router, network edge", "matrix-ubuntu": "Matrix/Synapse, NPM, CrowdSec (192.168.0.154)", } def build_context() -> str: lines = [] for host, role in HOST_ROLES.items(): lines.append(f" - {host}: {role}") return "\n".join(lines) def generate_plan(request: str) -> str: context = build_context() prompt = ( f"Given these homelab hosts and their roles:\n{context}\n\n" f"Generate SSH commands to accomplish: {request}\n\n" "Reply as a numbered list. Each line: HOST: COMMAND — DESCRIPTION\n" "For multi-step tasks, order the steps correctly.\n" "Use the host names exactly as listed above for SSH targets.\n" "Do not wrap in code blocks." ) return ollama_generate(prompt, num_predict=1500) def parse_plan_lines(plan_text: str) -> list[dict]: """Best-effort parse of 'N. HOST: COMMAND — DESCRIPTION' lines.""" import re steps = [] for line in plan_text.splitlines(): line = line.strip() if not line: continue # Try: N. HOST: command — description or N. HOST: command - description m = re.match(r"^\d+\.\s*(\S+):\s*(.+?)(?:\s[—–-]\s(.+))?$", line) if m: host = m.group(1).strip() command = m.group(2).strip() description = m.group(3).strip() if m.group(3) else "" steps.append({"host": host, "command": command, "description": description}) return steps def display_plan(plan_text: str, steps: list[dict]) -> None: print("\n" + "=" * 60) print("SSH EXECUTION PLAN") print("=" * 60) if steps: for i, step in enumerate(steps, 1): host = step["host"] cmd = step["command"] desc = step["description"] print(f"\n {i}. [{host}]") print(f" $ ssh {host} {cmd!r}") if desc: print(f" -> {desc}") else: # Couldn't parse structured steps — show raw print(plan_text) print("\n" + "=" * 60) def execute_steps(steps: list[dict]) -> None: for i, step in enumerate(steps, 1): host = step["host"] cmd = step["command"] print(f"\n[{i}/{len(steps)}] Executing on {host}: {cmd}") result = subprocess.run( ["ssh", host, cmd], capture_output=True, text=True, timeout=60, ) if result.stdout.strip(): print(result.stdout.strip()) if result.stderr.strip(): print(f" STDERR: {result.stderr.strip()}") if result.returncode != 0: print(f" WARNING: exit code {result.returncode}") answer = input(" Continue? [Y/n] ").strip().lower() if answer == "n": print("Aborted.") return print("\nAll steps completed.") def main() -> None: parser = argparse.ArgumentParser(description="Plain English -> SSH commands via LLM") parser.add_argument("request", help="Plain English description of what to do") parser.add_argument("--execute", action="store_true", help="Prompt to execute the generated commands") parser.add_argument("--dry-run", action="store_true", help="Show plan only (default behavior without --execute)") parser.add_argument("--verbose", "-v", action="store_true", help="Enable debug logging") args = parser.parse_args() logging.basicConfig( level=logging.DEBUG if args.verbose else logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", ) if not ollama_available(): log.error("Ollama is not reachable — aborting") sys.exit(1) log.info("Generating SSH plan for: %s", args.request) plan_text = generate_plan(args.request) steps = parse_plan_lines(plan_text) display_plan(plan_text, steps) if args.dry_run or not args.execute: return if not steps: print("Could not parse structured steps from LLM output. Cannot execute.") return answer = input("\nExecute these commands? [y/N] ").strip().lower() if answer != "y": print("Aborted.") return execute_steps(steps) if __name__ == "__main__": main()