diff --git a/.stignore b/.stignore new file mode 100644 index 0000000..68df8c1 --- /dev/null +++ b/.stignore @@ -0,0 +1,5 @@ +// Machine-local — never sync across hosts +.venv/ +__pycache__/ +*.pyc +cortex/data/ diff --git a/Cortex_and_Inara.code-workspace b/Cortex_and_Inara.code-workspace new file mode 100644 index 0000000..b77e858 --- /dev/null +++ b/Cortex_and_Inara.code-workspace @@ -0,0 +1,75 @@ +{ + "folders": [ + { + "name": "cortex (service)", + "path": "cortex" + }, + { + "name": "home (personas)", + "path": "home" + }, + { + "name": "documentation", + "path": "documentation" + }, + { + "name": "docs (integrations)", + "path": "docs" + }, + { + "name": "project root", + "path": "." + } + ], + "settings": { + "files.exclude": { + "**/__pycache__": true, + "**/*.pyc": true, + "cortex/.venv": true, + "cortex/data": true + }, + "search.exclude": { + "**/__pycache__": true, + "cortex/.venv": true, + "cortex/data": true, + "home/**/sessions": true, + "home/**/session_data": true + }, + "[python]": { + "editor.formatOnSave": false + }, + "editor.rulers": [100], + "files.associations": { + "*.env": "dotenv", + "*.env.default": "dotenv" + } + }, + "extensions": { + "recommendations": [ + "ms-python.python", + "ms-python.vscode-pylance", + "humao.rest-client", + "tamasfe.even-better-toml" + ] + }, + "launch": { + "version": "0.2.0", + "configurations": [ + { + "name": "Cortex (uvicorn dev)", + "type": "python", + "request": "launch", + "module": "uvicorn", + "args": [ + "main:app", + "--host", "0.0.0.0", + "--port", "8000", + "--reload" + ], + "cwd": "${workspaceFolder:cortex (service)}", + "envFile": "${workspaceFolder:cortex (service)}/.env", + "justMyCode": false + } + ] + } +} diff --git a/dev-restart.sh b/dev-restart.sh new file mode 100755 index 0000000..ada7dbe --- /dev/null +++ b/dev-restart.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# dev-restart.sh — restart Cortex on the gaming laptop and tail logs +# Usage: +# ./dev-restart.sh restart and show last 30 log lines +# ./dev-restart.sh logs tail live logs (ctrl-c to stop) +# ./dev-restart.sh status show service status only + +# "scott-lt-i7-rtx" or "192.168.32.19" +CORTEX_HOST="scott-lt-i7-rtx" # hostname or IP of the machine running Cortex +SERVICE="cortex" + +case "${1:-restart}" in + logs) + echo "→ Tailing $SERVICE logs on $CORTEX_HOST (ctrl-c to stop)" + ssh "$CORTEX_HOST" "journalctl --user -u $SERVICE -f --no-pager" + ;; + status) + ssh "$CORTEX_HOST" "systemctl --user status $SERVICE --no-pager -l" + ;; + restart|*) + echo "→ Restarting $SERVICE on $CORTEX_HOST …" + ssh "$CORTEX_HOST" "systemctl --user restart $SERVICE" + echo "→ Last 30 log lines:" + ssh "$CORTEX_HOST" "journalctl --user -u $SERVICE --no-pager -n 30" + ;; +esac diff --git a/install.py b/install.py new file mode 100755 index 0000000..2a9f92d --- /dev/null +++ b/install.py @@ -0,0 +1,301 @@ +#!/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" + +# ── 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 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() + + 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() + + +if __name__ == "__main__": + main()