tooling: install script, workspace file, and dev-restart helper
- install.py — idempotent setup script (venv, systemd service, linger, auth checks); supports --check for read-only status inspection - .stignore — exclude .venv and runtime dirs from Syncthing so each host maintains its own machine-local venv - Cortex_and_Inara.code-workspace — VS Code workspace (service, personas, docs folders; launch config for uvicorn --reload) - dev-restart.sh — SSH wrapper to restart Cortex on the gaming laptop and tail logs; supports restart / logs / status subcommands Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
5
.stignore
Normal file
5
.stignore
Normal file
@@ -0,0 +1,5 @@
|
||||
// Machine-local — never sync across hosts
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
cortex/data/
|
||||
75
Cortex_and_Inara.code-workspace
Normal file
75
Cortex_and_Inara.code-workspace
Normal file
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
26
dev-restart.sh
Executable file
26
dev-restart.sh
Executable file
@@ -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
|
||||
301
install.py
Executable file
301
install.py
Executable file
@@ -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:<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 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()
|
||||
Reference in New Issue
Block a user