- backup.sh — backs up home/ (persona data, memory, tasks, crons) to a local restic repo; auto-generates password on first run, prunes to 7d/4w/6m retention; excludes sessions/ and session_data/ - install.py — setup_backup_timer() installs cortex-backup.service + cortex-backup.timer (daily 03:00, Persistent=true); skips gracefully if restic is not installed Password lives at ~/.config/cortex/restic-password (chmod 600, not in git). Repo defaults to ~/backups/cortex-home-restic; override via RESTIC_REPOSITORY. Per-user encrypted backup is a noted future feature. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
377 lines
12 KiB
Python
Executable File
377 lines
12 KiB
Python
Executable File
#!/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:<port>")
|
|
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@<source-host>:{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()
|