#!/usr/bin/env python3 """ Cortex / Inara — install & configure ===================================== Run this on any machine to set up or refresh the Cortex service. Safe to re-run (idempotent). Usage: python3 install.py # install / update python3 install.py --check # status check only, no changes """ import argparse import os import subprocess import sys from pathlib import Path # ── Paths ───────────────────────────────────────────────────────────────────── PROJECT_ROOT = Path(__file__).parent.resolve() CORTEX_DIR = PROJECT_ROOT / "cortex" VENV_DIR = CORTEX_DIR / ".venv" VENV_PYTHON = VENV_DIR / "bin" / "python" VENV_PIP = VENV_DIR / "bin" / "pip" REQUIREMENTS = CORTEX_DIR / "requirements.txt" ENV_FILE = CORTEX_DIR / ".env" SERVICE_NAME = "cortex" SERVICE_FILE = Path.home() / ".config" / "systemd" / "user" / f"{SERVICE_NAME}.service" CLAUDE_CREDS = Path.home() / ".claude" / ".credentials.json" GEMINI_CREDS = Path.home() / ".gemini" / "oauth_creds.json" BACKUP_SCRIPT = PROJECT_ROOT / "backup.sh" BACKUP_SVC_FILE = Path.home() / ".config" / "systemd" / "user" / "cortex-backup.service" BACKUP_TMR_FILE = Path.home() / ".config" / "systemd" / "user" / "cortex-backup.timer" # ── ANSI helpers ────────────────────────────────────────────────────────────── RESET = "\033[0m" BOLD = "\033[1m" GREEN = "\033[32m" YELLOW = "\033[33m" RED = "\033[31m" CYAN = "\033[36m" def ok(msg): print(f" {GREEN}✓{RESET} {msg}") def warn(msg): print(f" {YELLOW}!{RESET} {msg}") def fail(msg): print(f" {RED}✗{RESET} {msg}") def info(msg): print(f" {CYAN}→{RESET} {msg}") def header(msg): print(f"\n{BOLD}{msg}{RESET}") # ── Shell helpers ───────────────────────────────────────────────────────────── def run(cmd, *, check=True, capture=False): """Run a shell command. Returns CompletedProcess.""" return subprocess.run( cmd, shell=isinstance(cmd, str), check=check, capture_output=capture, text=True, ) def run_quiet(cmd): """Run and return (returncode, stdout, stderr).""" r = subprocess.run(cmd, shell=isinstance(cmd, str), capture_output=True, text=True) return r.returncode, r.stdout.strip(), r.stderr.strip() # ── Step implementations ────────────────────────────────────────────────────── def check_python(): header("Python") v = sys.version_info if v < (3, 11): fail(f"Python {v.major}.{v.minor} detected — 3.11+ recommended") sys.exit(1) ok(f"Python {v.major}.{v.minor}.{v.micro} ({sys.executable})") def check_project(): header("Project directory") if not CORTEX_DIR.is_dir(): fail(f"cortex/ not found at {CORTEX_DIR}") fail("Is agents_sync synced on this machine? Run: syncthing") sys.exit(1) ok(f"Project root: {PROJECT_ROOT}") ok(f"cortex/ {CORTEX_DIR}") def setup_venv(dry_run=False): header("Python venv") venv_ok = VENV_DIR.is_dir() and VENV_PYTHON.exists() if VENV_DIR.is_dir() and not VENV_PYTHON.exists(): # Directory was synced by Syncthing but binaries are missing/stale if dry_run: warn(f"venv directory exists but Python binary missing — would recreate") warn(f" (likely synced by Syncthing from another machine)") return warn("venv directory exists but Python binary missing — recreating …") import shutil shutil.rmtree(VENV_DIR) venv_ok = False if not venv_ok: if dry_run: warn(f"venv missing — would create {VENV_DIR}") return info(f"Creating venv at {VENV_DIR} …") run([sys.executable, "-m", "venv", str(VENV_DIR)]) ok("venv created") else: ok(f"venv OK: {VENV_DIR}") # Always (re-)install requirements so updates are picked up if not dry_run: info("Installing / updating requirements …") run([str(VENV_PYTHON), "-m", "pip", "install", "--quiet", "--upgrade", "pip"]) run([str(VENV_PYTHON), "-m", "pip", "install", "--quiet", "-r", str(REQUIREMENTS)]) ok("Requirements installed") else: ok(f"requirements.txt: {REQUIREMENTS}") def check_env_file(): header(".env file") if ENV_FILE.exists(): ok(f"{ENV_FILE}") # Parse for a couple of interesting values (no secret printing) env = {} for line in ENV_FILE.read_text().splitlines(): line = line.strip() if line and not line.startswith("#") and "=" in line: k, _, v = line.partition("=") env[k.strip()] = v.strip() agent = env.get("AGENT_NAME", "(not set)") local_url = env.get("LOCAL_API_URL", "(not set)") info(f"AGENT_NAME = {agent}") info(f"LOCAL_API_URL = {local_url}") if "localhost" not in local_url and "127.0.0.1" not in local_url: warn("LOCAL_API_URL is not localhost — if Open WebUI/Ollama runs on " "this machine, update it to http://localhost:") else: fail(f"{ENV_FILE} not found") warn("Syncthing may not have delivered it yet, or you need to copy it manually:") warn(f" scp scott@:{ENV_FILE} {ENV_FILE}") def install_systemd_service(dry_run=False): header("systemd user service") unit_content = f"""\ [Unit] Description=Cortex / Inara LLM Gateway After=network.target [Service] Type=simple WorkingDirectory={CORTEX_DIR} ExecStart={VENV_DIR}/bin/uvicorn main:app --host 0.0.0.0 --port 8000 Restart=on-failure RestartSec=5 EnvironmentFile=-{ENV_FILE} TimeoutStopSec=15 [Install] WantedBy=default.target """ if dry_run: if SERVICE_FILE.exists(): existing = SERVICE_FILE.read_text() if existing == unit_content: ok(f"Service file up to date: {SERVICE_FILE}") else: warn(f"Service file differs from expected: {SERVICE_FILE}") else: warn(f"Service file missing — would write: {SERVICE_FILE}") return SERVICE_FILE.parent.mkdir(parents=True, exist_ok=True) existing = SERVICE_FILE.read_text() if SERVICE_FILE.exists() else "" if existing == unit_content: ok(f"Service file unchanged: {SERVICE_FILE}") else: SERVICE_FILE.write_text(unit_content) ok(f"Service file written: {SERVICE_FILE}") run(["systemctl", "--user", "daemon-reload"]) ok("daemon-reload done") def enable_linger(dry_run=False): """Enable systemd linger so the service survives logout.""" header("Linger (run without login session)") username = os.environ.get("USER", os.environ.get("LOGNAME", "")) code, out, _ = run_quiet(f"loginctl show-user {username} --property=Linger") if "Linger=yes" in out: ok(f"Linger already enabled for {username}") else: if dry_run: warn(f"Linger not enabled — would run: loginctl enable-linger {username}") else: run(["loginctl", "enable-linger", username]) ok(f"Linger enabled for {username}") def manage_service(dry_run=False): header("Service enable / start") code, _, _ = run_quiet(["systemctl", "--user", "is-enabled", SERVICE_NAME]) enabled = (code == 0) code2, state, _ = run_quiet(["systemctl", "--user", "is-active", SERVICE_NAME]) active = (code2 == 0) if dry_run: ok(f"enabled: {enabled} active: {active}") return if not enabled: run(["systemctl", "--user", "enable", SERVICE_NAME]) ok("Service enabled") else: ok("Service already enabled") if active: info("Service running — restarting to pick up any changes …") run(["systemctl", "--user", "restart", SERVICE_NAME]) ok("Service restarted") else: run(["systemctl", "--user", "start", SERVICE_NAME]) ok("Service started") def check_cli_auth(): header("LLM CLI authentication") # Claude if CLAUDE_CREDS.exists(): ok(f"Claude credentials found: {CLAUDE_CREDS}") else: fail(f"Claude not authenticated — run: claude") warn(" (completes an OAuth flow in your browser)") # Gemini if GEMINI_CREDS.exists(): ok(f"Gemini credentials found: {GEMINI_CREDS}") else: fail(f"Gemini not authenticated — run: gemini") warn(" (completes an OAuth flow in your browser)") def setup_backup_timer(dry_run=False): header("Backup (restic systemd timer)") code, _, _ = run_quiet(["which", "restic"]) if code != 0: warn("restic not found — skipping backup timer setup") warn(" Install with: sudo pacman -S restic (or your distro's package manager)") return ok("restic found") svc_content = f"""\ [Unit] Description=Cortex home backup (restic) After=network.target [Service] Type=oneshot ExecStart={BACKUP_SCRIPT} """ tmr_content = """\ [Unit] Description=Cortex home backup — daily at 03:00 [Timer] OnCalendar=*-*-* 03:00:00 Persistent=true [Install] WantedBy=timers.target """ if dry_run: if BACKUP_SVC_FILE.exists() and BACKUP_TMR_FILE.exists(): ok(f"Timer already installed") code2, out, _ = run_quiet(["systemctl", "--user", "is-enabled", "cortex-backup.timer"]) ok(f"Timer enabled: {code2 == 0}") else: warn("Backup timer not installed — would write service + timer units") return SERVICE_FILE.parent.mkdir(parents=True, exist_ok=True) for path, content, label in [ (BACKUP_SVC_FILE, svc_content, "service"), (BACKUP_TMR_FILE, tmr_content, "timer"), ]: existing = path.read_text() if path.exists() else "" if existing == content: ok(f"Backup {label} file unchanged") else: path.write_text(content) ok(f"Backup {label} file written: {path}") run(["systemctl", "--user", "daemon-reload"]) run(["systemctl", "--user", "enable", "--now", "cortex-backup.timer"]) ok("Backup timer enabled (daily 03:00)") code2, out, _ = run_quiet(["systemctl", "--user", "list-timers", "cortex-backup.timer", "--no-pager"]) for line in out.splitlines()[:4]: info(line) def print_status(): header("Service status") code, out, _ = run_quiet(["systemctl", "--user", "status", SERVICE_NAME, "--no-pager", "-l"]) # Print the first 15 lines of status for line in out.splitlines()[:15]: print(f" {line}") print() # ── Main ────────────────────────────────────────────────────────────────────── def main(): parser = argparse.ArgumentParser( description="Install / configure the Cortex service.") parser.add_argument("--check", action="store_true", help="Status check only — make no changes") args = parser.parse_args() dry = args.check mode = "CHECK MODE (no changes)" if dry else "INSTALL / UPDATE" print(f"\n{BOLD}{'─'*52}{RESET}") print(f"{BOLD} Cortex / Inara — {mode}{RESET}") print(f"{BOLD}{'─'*52}{RESET}") check_python() check_project() setup_venv(dry_run=dry) check_env_file() install_systemd_service(dry_run=dry) enable_linger(dry_run=dry) if not dry: manage_service(dry_run=dry) check_cli_auth() setup_backup_timer(dry_run=dry) if not dry: print_status() print(f"\n{BOLD}Done.{RESET}\n") print(" Logs: journalctl --user -u cortex -f") print(" Swagger: http://localhost:8000/docs") print(" Backup: ./backup.sh (also runs daily at 03:00)") print(" Snapshots: RESTIC_REPOSITORY=~/backups/cortex-home-restic \\") print(" RESTIC_PASSWORD_FILE=~/.config/cortex/restic-password \\") print(" restic snapshots") print() if __name__ == "__main__": main()