Sync from upstream: ncp is Python now, runs on Windows too

management/ncp.py replaces the bash CLI's logic; nexus-cli.sh and the new
ncp.ps1 are thin wrappers, so Linux keeps its entry point and Windows gains one.
psutil handles process and port work on both platforms.

install-windows.ps1 registers ncp in the PowerShell profile. The panel VPN
switch now resolves its WireGuard connection through NetworkManager instead of
a hardcoded name, and the .ps1 ASCII guard globs rather than naming files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jon
2026-07-22 11:34:15 -05:00
co-authored by Claude Opus 4.8
parent bcad43502c
commit ca8bc7425c
6 changed files with 845 additions and 681 deletions
+28
View File
@@ -0,0 +1,28 @@
# ncp - Windows entry point. The CLI itself is management\ncp.py, the same file
# the Linux box runs; this only picks an interpreter and forwards the arguments.
#
# Register it once per machine by adding this to your PowerShell profile
# (notepad $PROFILE):
#
# function ncp { & "$HOME\nexus-core\management\ncp.ps1" @args }
#
# ASCII only, no exceptions: PowerShell 5.1 decodes BOM-less files as ANSI, so a
# single Unicode dash eats a quote and the script dies at parse time. A test in
# tests/test_smoke.py fails if any .ps1 in this repo gains a non-ASCII byte.
$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
# Prefer the venv interpreter (ncp.py needs psutil for service management), but
# fall back to system Python so backup/restore still work before the venv is
# built - those delegate to the stdlib-only bin\sync.py.
$Py = Join-Path $Root "Promethean\Scripts\python.exe"
if (-not (Test-Path $Py)) {
$Py = (Get-Command python -ErrorAction SilentlyContinue).Source
}
if (-not $Py) {
Write-Error "No Python found. Run install-windows.ps1 first."
exit 1
}
& $Py (Join-Path $Root "management\ncp.py") @args
exit $LASTEXITCODE
+711
View File
@@ -0,0 +1,711 @@
#!/usr/bin/env python3
"""ncp - the NexusOS management CLI, one implementation for Linux and Windows.
This replaces the logic that lived in management/nexus-cli.sh. That script was
bash + pkill + fuser + /proc, so the Windows box had no `ncp` at all - the same
split that made bin/install.sh rot until it was rsyncing from a path retired
months earlier. Same fix as bin/sync.py: the portable half is Python, and the
per-platform pieces are small and explicit.
nexus-cli.sh is now a two-line wrapper, so `ncp`, launch_nexus.sh,
bin/restore-linux.sh, controlpanel.py, nexus-popup.py and nexus-app.sh all keep
calling what they always called.
psutil does the process work (cmdlines, pattern sweeps, port owners). It is
declared in requirements-base.txt AND requirements-wsl.txt, so both boxes have
it - but it is imported lazily so `ncp backup`/`restore` still run before the
venv exists, exactly as they did when they shelled out to sync.py.
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
import time
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
PID_DIR = ROOT / "runtime" / "pids"
LOG_DIR = ROOT / "runtime"
FRONTEND_DIR = ROOT / "interface" / "web"
OLLAMA_BIN = ROOT / "ollama" / "bin" / ("ollama.exe" if os.name == "nt" else "ollama")
OLLAMA_MODELS_DIR = ROOT / "models"
WINDOWS = os.name == "nt"
PYTHON = ROOT / "Promethean" / ("Scripts/python.exe" if WINDOWS else "bin/python3")
PID_DIR.mkdir(parents=True, exist_ok=True)
LOG_DIR.mkdir(parents=True, exist_ok=True)
# The output carries em-dashes and check marks (matching what nexus-cli.sh
# printed). On Windows a redirected stdout defaults to cp1252, which raises
# UnicodeEncodeError on both - so pin UTF-8 rather than degrade the output.
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8", errors="replace")
except (AttributeError, ValueError):
pass
# -- small helpers -------------------------------------------------------------
def http_ok(url: str, timeout: float = 1.0) -> bool:
"""True if the URL answers at all. Any HTTP status counts - a 404 still
proves something is listening, which is all the port checks care about."""
try:
urllib.request.urlopen(url, timeout=timeout).read(1)
return True
except urllib.error.HTTPError:
return True
except Exception:
return False
def npm() -> str | None:
"""npm, resolving through nvm. The bash version sourced ~/.nvm/nvm.sh; a
non-login shell has neither, so fall back to globbing the nvm install."""
found = shutil.which("npm") # resolves npm.cmd on Windows
if found:
return found
versions = sorted((Path.home() / ".nvm" / "versions" / "node").glob("*/bin/npm"))
return str(versions[-1]) if versions else None
def _psutil():
try:
import psutil
return psutil
except ImportError:
sys.exit("psutil is missing - run ./install.sh (or install-windows.ps1) to rebuild the venv.")
# -- services ------------------------------------------------------------------
class Service:
def __init__(self, key, label, port, cwd, patterns, argv=None):
self.key, self.label, self.port = key, label, port
self.cwd, self.patterns, self._argv = cwd, patterns, argv
self.pid_file = PID_DIR / f"{key}.pid"
self.log_file = LOG_DIR / f"{key}.log"
@property
def url(self) -> str:
return f"http://localhost:{self.port}/"
def argv(self):
return self._argv() if callable(self._argv) else self._argv
def _uvicorn(app: str, port: int):
return [str(PYTHON), "-m", "uvicorn", app, "--host", "0.0.0.0",
"--port", str(port), "--reload"]
SERVICES = {
"memory": Service("memory", "NEXUS MEMORY SERVICE", 8001, ROOT,
["uvicorn synapse.memory"],
lambda: _uvicorn("synapse.memory.service:app", 8001)),
"backend": Service("backend", "NEXUS BACKEND SERVICE", 8000, ROOT,
["uvicorn synapse.main"],
lambda: _uvicorn("synapse.main:sio_app", 8000)),
"frontend": Service("frontend", "NEXUS FRONTEND SERVICE", 5173, FRONTEND_DIR,
["vite --host", "npm run dev"],
lambda: [npm(), "run", "dev", "--", "--host", "0.0.0.0"]),
}
def read_pid(svc: Service):
try:
return int(svc.pid_file.read_text().strip())
except (OSError, ValueError):
return None
def alive(pid) -> bool:
ps = _psutil()
return pid is not None and ps.pid_exists(pid)
def pid_is_ours(pid, patterns) -> bool:
"""True only if the live PID's command line matches one of the service
patterns. PID files outlive reboots and the OS recycles the number onto an
unrelated process - often a desktop-session one - so a bare liveness check
is not enough. TERMing a recycled PID can log the user out."""
ps = _psutil()
try:
cmd = " ".join(ps.Process(pid).cmdline())
except Exception:
return False
return any(p in cmd for p in patterns)
def launch(svc: Service) -> None:
pid = read_pid(svc)
if alive(pid) and pid_is_ours(pid, svc.patterns):
return
argv = svc.argv()
if argv[0] is None:
print(f" {svc.label}: npm not found - install Node, or run ./install.sh")
return
svc.log_file.write_text("")
with open(svc.log_file, "ab") as log:
# Detach so the service outlives this process, on both platforms.
kwargs = ({"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP
| getattr(subprocess, "DETACHED_PROCESS", 0)}
if WINDOWS else {"start_new_session": True})
proc = subprocess.Popen(argv, cwd=str(svc.cwd), stdout=log,
stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
**kwargs)
svc.pid_file.write_text(str(proc.pid))
def check(svc: Service) -> bool:
pid = read_pid(svc)
if alive(pid):
print(f"{svc.label} STARTED")
return True
print(f"{svc.label} FAILED TO START")
try:
tail = svc.log_file.read_text(errors="replace").splitlines()[-8:]
for line in tail:
print(f" {line}")
except OSError:
pass
svc.pid_file.unlink(missing_ok=True)
return False
def wait_for_port(svc: Service, timeout: int = 30) -> bool:
if http_ok(svc.url):
print(f" {svc.label} already running (:{svc.port})")
return True
for _ in range(timeout):
time.sleep(1)
if http_ok(svc.url):
print(f"{svc.label} STARTED")
return True
print(f" {svc.label} timed out after {timeout}s")
return check(svc)
def wait_for_port_close(port: int, timeout: int = 15) -> bool:
for _ in range(timeout):
if not http_ok(f"http://localhost:{port}/"):
return True
time.sleep(1)
return False
def kill_matching(patterns, force=False) -> None:
"""The pkill -f sweep: catches reparented grandchildren (npm -> sh -> node
vite), uvicorn --reload workers, and instances started outside this CLI."""
ps = _psutil()
me = os.getpid()
for proc in ps.process_iter(["pid", "cmdline"]):
if proc.info["pid"] == me:
continue
cmd = " ".join(proc.info["cmdline"] or [])
if any(p in cmd for p in patterns):
try:
proc.kill() if force else proc.terminate()
except Exception:
pass
def kill_port(port: int) -> bool:
"""Whatever holds the port IS the service - this is the backstop that makes
stop reliable regardless of process-tree shape or PID-file accuracy."""
ps = _psutil()
killed = False
try:
conns = ps.net_connections(kind="inet")
except Exception:
return False
for conn in conns:
if conn.laddr and conn.laddr.port == port and conn.status == "LISTEN" and conn.pid:
try:
ps.Process(conn.pid).kill()
killed = True
except Exception:
pass
return killed
def stop_service(svc: Service) -> bool:
"""Three escalating passes, with the PORT as the source of truth for
whether the service is actually down."""
pid = read_pid(svc)
if pid is not None:
if alive(pid) and pid_is_ours(pid, svc.patterns):
ps = _psutil()
try:
proc = ps.Process(pid)
for child in proc.children(recursive=True):
try:
child.terminate()
except Exception:
pass
proc.terminate()
except Exception:
pass
svc.pid_file.unlink(missing_ok=True)
kill_matching(svc.patterns)
if not wait_for_port_close(svc.port):
kill_port(svc.port)
kill_matching(svc.patterns, force=True)
wait_for_port_close(svc.port)
if http_ok(svc.url):
print(f"{svc.label} STILL RUNNING (:{svc.port}) — try 'ncp kill'")
return False
print(f"{svc.label} STOPPED")
return True
# -- ollama --------------------------------------------------------------------
def start_ollama() -> None:
"""Driven through the backend endpoint (the path the control panel uses)
rather than launching the binary, because OllamaManager owns model and GPU
selection. Requires the backend to be up."""
print("Starting OLLAMA...")
req = urllib.request.Request("http://localhost:8000/ollama/start", method="POST")
try:
urllib.request.urlopen(req, timeout=30).read(1)
print("NEXUS OLLAMA STARTED")
except Exception:
print(" OLLAMA start request failed (backend not reachable on :8000)")
def stop_ollama() -> None:
"""Prefer the backend endpoint for a clean OllamaManager shutdown; if the
backend is already down, kill `ollama serve` directly so it never lingers
holding VRAM/RAM. Must run BEFORE the backend is torn down."""
req = urllib.request.Request("http://localhost:8000/ollama/stop", method="POST")
try:
urllib.request.urlopen(req, timeout=5).read(1)
print("NEXUS OLLAMA STOPPED")
return
except Exception:
pass
kill_matching(["ollama serve"])
print("NEXUS OLLAMA STOPPED (direct)")
# -- commands ------------------------------------------------------------------
def cmd_start(target) -> None:
if target in ("--memory", "-m"):
launch(SERVICES["memory"]); wait_for_port(SERVICES["memory"])
elif target in ("--backend", "-b"):
launch(SERVICES["backend"]); wait_for_port(SERVICES["backend"])
elif target in ("--frontend", "-f"):
launch(SERVICES["frontend"]); wait_for_port(SERVICES["frontend"])
elif target in ("--ai", "-a"):
start_ollama()
elif target in (None, "", "all"):
# Memory + backend in parallel, both ready before the frontend starts.
launch(SERVICES["memory"])
launch(SERVICES["backend"])
wait_for_port(SERVICES["memory"])
wait_for_port(SERVICES["backend"])
launch(SERVICES["frontend"])
wait_for_port(SERVICES["frontend"])
else:
show_help()
def cmd_stop(target) -> None:
if target in ("--memory", "-m"):
stop_service(SERVICES["memory"])
elif target in ("--backend", "-b"):
stop_ollama(); stop_service(SERVICES["backend"])
elif target in ("--frontend", "-f"):
stop_service(SERVICES["frontend"])
elif target in (None, "", "all"):
stop_service(SERVICES["memory"])
stop_ollama()
stop_service(SERVICES["backend"])
stop_service(SERVICES["frontend"])
else:
show_help()
def cmd_kill() -> None:
print("Force-killing all Nexus processes...")
for port, name in ((8000, "SYNAPSE"), (8001, "MEMORY"),
(5173, "INTERFACE"), (11434, "OLLAMA")):
if kill_port(port):
print(f" KILLED: {name} (:{port})")
else:
print(f" NOT RUNNING: {name} (:{port})")
kill_matching(["uvicorn synapse", "npm run dev", "vite --host", "ollama serve"],
force=True)
for pid_file in PID_DIR.glob("*.pid"):
pid_file.unlink(missing_ok=True)
print("Done.")
def cmd_status() -> None:
print("Nexus Service Status:\n")
def one(name, svc):
pid = read_pid(svc)
if alive(pid):
print(f" {name}: RUNNING (PID {pid})")
elif http_ok(svc.url):
print(f" {name}: RUNNING (port :{svc.port}, PID stale — consider ncp kill)")
else:
print(f" {name}: STOPPED")
print("Backend:")
one("Synapse ", SERVICES["backend"])
one("Memory service", SERVICES["memory"])
print("\nFrontend:")
one("Vite ", SERVICES["frontend"])
print("\nModel server:")
running = http_ok("http://localhost:11434/api/tags")
print(f" Ollama : {'RUNNING (:11434)' if running else 'STOPPED'}")
def _tail(path: Path, n: int) -> None:
try:
for line in path.read_text(errors="replace").splitlines()[-n:]:
print(line)
except OSError:
print(f" (no log at {path})")
LOG_HEADINGS = {"memory": "MEMORY SERVICE", "backend": "BACKEND", "frontend": "FRONTEND"}
def cmd_logs(target) -> None:
named = {"--frontend": "frontend", "-f": "frontend",
"--backend": "backend", "-b": "backend",
"--memory": "memory", "-m": "memory"}
if target in named:
key = named[target]
print(f"=== {LOG_HEADINGS[key]} LOGS ===")
_tail(SERVICES[key].log_file, 50)
elif target in (None, "", "all"):
for i, key in enumerate(("memory", "backend", "frontend")):
if i:
print()
print(f"=== {LOG_HEADINGS[key]} LOGS ===")
_tail(SERVICES[key].log_file, 30)
else:
print(f"Unknown logs target: '{target}'")
print("Usage: ncp logs [frontend|backend|memory|all]")
def cmd_doctor() -> None:
def mark(ok, good, bad):
print(f" {'' if ok else ''} {good if ok else bad}")
print("Running Nexus Diagnostics...\n")
print("Checking directories...")
mark(ROOT.is_dir(), "Nexus root found", "Missing Nexus root")
mark(FRONTEND_DIR.is_dir(), "Frontend directory found", "Missing frontend directory")
print("\nChecking Python venv...")
if PYTHON.exists():
ver = subprocess.run([str(PYTHON), "--version"], capture_output=True,
text=True).stdout.strip()
mark(True, f"Promethean venv found ({ver})", "")
else:
mark(False, "", f"Promethean venv missing at {PYTHON}")
print("\nChecking Node & npm...")
def version(exe):
if not exe:
return ""
try:
return subprocess.run([exe, "-v"], capture_output=True, text=True).stdout.strip()
except OSError:
return ""
node, npm_path = shutil.which("node"), npm()
mark(bool(node), f"Node installed ({version(node)})", "Node missing")
mark(bool(npm_path), f"npm installed ({version(npm_path)})", "npm missing")
mark((FRONTEND_DIR / "node_modules").is_dir(),
"Frontend node_modules installed",
"Frontend node_modules missing (run: ncp update)")
print("\nChecking Uvicorn...")
uvicorn_ok = subprocess.run([str(PYTHON), "-m", "uvicorn", "--version"],
capture_output=True).returncode == 0
mark(uvicorn_ok, "Uvicorn installed", "Uvicorn missing")
def importable(stmt):
return subprocess.run([str(PYTHON), "-c", stmt], cwd=str(ROOT),
capture_output=True).returncode == 0
print("\nChecking backend service...")
mark(importable("from synapse.main import sio_app"),
"Backend module importable", "Backend module failed to import")
print("\nChecking memory service...")
mark((ROOT / "synapse" / "memory").is_dir(),
"Memory module directory found", "Missing memory module directory")
mark(importable("from synapse.memory.service import app"),
"Memory service module importable", "Memory service module failed to import")
mark(os.access(ROOT / "synapse" / "memory", os.W_OK),
"Memory database directory writable", "Memory database directory not writable")
print("\nChecking Ollama...")
mark(OLLAMA_BIN.exists(), f"Ollama binary found ({OLLAMA_BIN})",
f"Ollama binary missing at {OLLAMA_BIN}")
mark(OLLAMA_MODELS_DIR.is_dir(), f"Ollama models directory found ({OLLAMA_MODELS_DIR})",
f"Ollama models directory missing at {OLLAMA_MODELS_DIR}")
print()
cmd_status()
def cmd_update() -> None:
print("Updating Project Nexus...\n")
print("Skipping git pull — update only refreshes dependencies.\n")
# Same overlay choice sync.py makes, so update and restore cannot install
# different PyTorch builds on the same host.
sys.path.insert(0, str(ROOT / "bin"))
import importlib.util
spec = importlib.util.spec_from_file_location("sync", ROOT / "bin" / "sync.py")
sync = importlib.util.module_from_spec(spec)
spec.loader.exec_module(sync)
req = sync.requirements()
print(f"Updating backend Python dependencies ({req})...")
subprocess.run([str(PYTHON), "-m", "pip", "install", "-r", req], cwd=str(ROOT))
print("\nUpdating frontend dependencies...")
npm_path = npm()
if npm_path and FRONTEND_DIR.is_dir():
subprocess.run([npm_path, "install"], cwd=str(FRONTEND_DIR))
else:
print("npm or frontend directory missing — skipping npm install.")
print("\nRunning post-update diagnostics...")
cmd_doctor()
def cmd_clean() -> None:
print("Cleaning Nexus runtime files...\n")
print("Removing PID files...")
for f in PID_DIR.glob("*.pid"):
f.unlink(missing_ok=True)
print("Removing logs...")
for f in LOG_DIR.glob("*.log"):
f.unlink(missing_ok=True)
print("Removing Python cache...")
for d in ROOT.rglob("__pycache__"):
shutil.rmtree(d, ignore_errors=True)
print("Removing Node/Vite cache...")
for d in FRONTEND_DIR.rglob(".vite"):
shutil.rmtree(d, ignore_errors=True)
print("\nCleanup complete.")
MODEL_CATALOG = [
("gemma3:1b", "~815 MB", "Google Gemma 3 — fast, lightweight"),
("gemma3:4b", "~3.3 GB", "Google Gemma 3 — balanced"),
("gemma3:12b", "~8.1 GB", "Google Gemma 3 — capable"),
("llama3.2:1b", "~1.3 GB", "Meta Llama 3.2 — fast, lightweight"),
("llama3.2:3b", "~2.0 GB", "Meta Llama 3.2 — compact, capable"),
("llama3.1:8b", "~4.7 GB", "Meta Llama 3.1 — strong general use"),
("mistral:latest", "~4.1 GB", "Mistral 7B — solid all-rounder"),
("mistral-nemo", "~7.1 GB", "Mistral Nemo 12B — strong reasoning"),
("qwen2.5:3b", "~2.0 GB", "Alibaba Qwen 2.5 — great at code"),
("qwen2.5:7b", "~4.7 GB", "Alibaba Qwen 2.5 — strong coder"),
("phi4-mini", "~2.5 GB", "Microsoft Phi-4 Mini — efficient"),
("phi4:14b", "~8.9 GB", "Microsoft Phi-4 — strong reasoning"),
("deepseek-r1:7b", "~4.7 GB", "DeepSeek R1 — reasoning model"),
("deepseek-r1:14b", "~9.0 GB", "DeepSeek R1 — strong reasoning"),
("codellama:7b", "~3.8 GB", "Meta Code Llama — code focused"),
("nomic-embed-text", "~274 MB", "Text embeddings model"),
]
def cmd_models(action, name) -> None:
if action == "list":
if not http_ok("http://localhost:11434/api/tags"):
print("Ollama is not running. Start the backend first with: ncp start -b")
return
raw = urllib.request.urlopen("http://localhost:11434/api/tags", timeout=5).read()
models = json.loads(raw).get("models", [])
print("Installed models:\n")
if not models:
print(" No models installed.")
return
for m in models:
mb = m["size"] // 1024 // 1024
size = f"{mb / 1024:.1f} GB" if mb >= 1024 else f"{mb} MB"
print(f" {m['name']:<35} {size}")
elif action in ("available", "search"):
print("Available models (via Ollama library):\n")
print(f" {'MODEL':<30} {'SIZE':<10} DESCRIPTION")
print(f" {'-----':<30} {'----':<10} -----------")
for model, size, desc in MODEL_CATALOG:
print(f" {model:<30} {size:<10} {desc}")
print("\nInstall any model with: ncp models install <model>")
print("Browse more at: https://ollama.com/library")
elif action == "install":
if not name:
print("Usage: ncp models install <model>")
print("Run 'ncp models available' to see options.")
return
if not OLLAMA_BIN.exists():
print(f"Ollama binary not found at {OLLAMA_BIN}")
return
print(f"Pulling '{name}' into {OLLAMA_MODELS_DIR} ...\n")
subprocess.run([str(OLLAMA_BIN), "pull", name],
env={**os.environ, "OLLAMA_MODELS": str(OLLAMA_MODELS_DIR)})
print("\nDone. Run 'ncp models list' to verify.")
else:
print("Usage: ncp models <list|available|install <model>>")
def sync_py(*args) -> int:
"""sync.py is stdlib-only, so system python works before the venv exists."""
py = str(PYTHON) if PYTHON.exists() else sys.executable
return subprocess.run([py, str(ROOT / "bin" / "sync.py"), *args]).returncode
def cmd_restore(flag) -> int:
if flag in ("-c", "--claude", "check"):
return sync_py("restore", "--check") # dry run: no prompt, changes nothing
print("This pulls the latest backup from Gitea and rebuilds the environment")
print("(venv, npm, theme, panel). Local commits must be pushed or stashed first.")
if input("Continue? [y/N] ").strip().lower() != "y":
print("Restore cancelled.")
return 0
return sync_py("restore")
def cmd_web() -> int:
"""Per-platform UI launcher. On Linux nexus-app.sh already raises an existing
window, acts as a viewer when the stack is up, and only stops services it
started. Windows uses the pywebview window launch_nexus.ps1 opens."""
if WINDOWS:
if not http_ok(SERVICES["backend"].url):
launch(SERVICES["memory"])
launch(SERVICES["backend"])
wait_for_port(SERVICES["backend"])
return subprocess.run([str(PYTHON), str(ROOT / "bin" / "nexus_window.py")]).returncode
return subprocess.run([str(ROOT / "management" / "nexus-app.sh")]).returncode
def show_help() -> None:
print("""Nexus Command Tree
Usage: ncp <command> [options]
Commands:
panel Launch the Nexus Control Panel (tkinter)
web Open the web interface (starts the stack if needed)
chat <message> Send a message, stream the reply
memory list List memory facts
add <text> Add a fact (--section <name>)
rm <id> Delete a fact by id
playbook list List playbooks (* = active)
show <id> Print a playbook's goal + instructions
history [query] Recent conversations (optional keyword)
start Start ALL Nexus services (memory + backend + frontend)
--memory, -m Start only the memory service
--frontend,-f Start only the frontend
--backend, -b Start only the backend
--ai, -a Start the AI (Ollama) — manual; not started by default
stop Stop ALL Nexus services
--memory, -m Stop only the memory service
--frontend,-f Stop only the frontend
--backend, -b Stop only the backend
kill Force-kill all Nexus processes by port (nuclear option)
refresh Restart all services
status Show service status
logs Show logs for all services
--memory, -m Memory service logs
--frontend,-f Frontend logs
--backend, -b Backend logs
doctor Run Nexus diagnostics
update Update Nexus dependencies
clean Remove runtime files and caches
models
list List installed models
available Show models available to install
install <name> Pull a model into Nexus
backup Backup Nexus to Gitea (git commit + push)
backup -f, full Backup + snapshot live desktop wiring/notes
backup -c Dry run: what a backup would commit/push
restore Restore Nexus from Gitea (git pull + rebuild)
restore -c Dry run: what a restore would apply
help, -h Show this help message""")
def main(argv) -> int:
cmd = argv[0] if argv else ""
rest = argv[1:]
arg = rest[0] if rest else None
if cmd in ("help", "-h", ""):
show_help()
elif cmd == "panel":
return subprocess.run([str(PYTHON), str(ROOT / "management" / "controlpanel.py")]).returncode
elif cmd == "web":
return cmd_web()
elif cmd in ("chat", "memory", "playbook", "history"):
return subprocess.run([str(PYTHON), str(ROOT / "management" / "nexus_api.py"),
cmd, *rest]).returncode
elif cmd == "start":
cmd_start(arg)
elif cmd == "stop":
cmd_stop(arg)
elif cmd == "refresh":
cmd_stop(None)
cmd_start(None)
elif cmd == "kill":
cmd_kill()
elif cmd == "status":
cmd_status()
elif cmd == "logs":
cmd_logs(arg)
elif cmd == "doctor":
cmd_doctor()
elif cmd == "update":
cmd_update()
elif cmd == "clean":
cmd_clean()
elif cmd == "nvidia-reqs":
return subprocess.run([str(PYTHON), str(ROOT / "bin" / "gen-nvidia-reqs.py")]).returncode
elif cmd == "backup":
if arg in ("-f", "full"):
return sync_py("backup", "--full")
if arg in ("-c", "--claude", "check"):
return sync_py("backup", "--check")
if arg in (None, ""):
return sync_py("backup")
print("Usage: ncp backup [-f|full|-c]")
elif cmd == "restore":
if arg in (None, "", "-f", "full", "-c", "--claude", "check"):
return cmd_restore(arg)
print("Usage: ncp restore [-c]")
elif cmd == "models":
cmd_models(arg, rest[1] if len(rest) > 1 else None)
else:
print(f"Unknown Nexus command: '{cmd}'")
print("Use 'ncp help' for available commands.")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+12 -671
View File
@@ -1,675 +1,16 @@
#!/usr/bin/env bash
# Load nvm so npm/node resolve to the managed version
export NVM_DIR="$HOME/.nvm"
# shellcheck source=/dev/null
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
NEXUS_ROOT="$HOME/nexus-core"
PID_DIR="$NEXUS_ROOT/runtime/pids"
LOG_DIR="$NEXUS_ROOT/runtime"
PYTHON="$NEXUS_ROOT/Promethean/bin/python3"
FRONTEND_DIR="$NEXUS_ROOT/interface/web"
mkdir -p "$PID_DIR" "$LOG_DIR"
BACKEND_PID="$PID_DIR/backend.pid"
MEMORY_PID="$PID_DIR/memory.pid"
FRONTEND_PID="$PID_DIR/frontend.pid"
BACKEND_LOG="$LOG_DIR/backend.log"
MEMORY_LOG="$LOG_DIR/memory.log"
FRONTEND_LOG="$LOG_DIR/frontend.log"
# -----------------------------
# INTERNAL HELPERS
# -----------------------------
_launch() {
# _launch <name> <pid_file> <log_file> <work_dir> <cmd> [args...]
local name="$1" pid_file="$2" log_file="$3" work_dir="$4"
shift 4
if [ -f "$pid_file" ] && kill -0 "$(cat "$pid_file")" 2>/dev/null; then
return 0
fi
: > "$log_file"
( cd "$work_dir" && exec "$@" ) >> "$log_file" 2>&1 &
echo $! > "$pid_file"
}
_check() {
# _check <label> <pid_file> <log_file>
# label should be the full service name, e.g. "NEXUS BACKEND SERVICE"
local label="$1" pid_file="$2" log_file="$3"
local pid
pid=$(cat "$pid_file" 2>/dev/null)
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
echo "$label STARTED"
return 0
else
echo "$label FAILED TO START"
[ -s "$log_file" ] && tail -8 "$log_file" | sed 's/^/ /'
rm -f "$pid_file"
return 1
fi
}
_wait_for_port() {
# _wait_for_port <name> <port> <pid_file> <log_file>
# Polls until the service responds on the given port, then prints ready.
# On timeout, falls back to _check for the error log.
local name="$1" port="$2" pid_file="$3" log_file="$4"
local timeout=30 i=0
if curl -s --max-time 1 "http://localhost:$port/" > /dev/null 2>&1; then
echo " $name already running (:$port)"
return 0
fi
while ! curl -s --max-time 1 "http://localhost:$port/" > /dev/null 2>&1; do
i=$((i + 1))
if [ "$i" -ge "$timeout" ]; then
echo " $name timed out after ${timeout}s"
_check "$name" "$pid_file" "$log_file"
return 1
fi
sleep 1
done
echo "$name STARTED"
}
_pid_is_ours() {
# _pid_is_ours <pid> <pattern> [pattern2]
# True only if the live PID's command line matches one of the service
# patterns. PID files outlive reboots; the OS recycles the number onto an
# unrelated process (often a desktop-session process), so a bare `kill -0`
# liveness check is not enough — TERMing a recycled PID can log the user out.
local pid="$1" p1="$2" p2="$3" cmd
[ -r "/proc/$pid/cmdline" ] || return 1
cmd=$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null) || return 1
[ -n "$p1" ] && [[ "$cmd" == *"$p1"* ]] && return 0
[ -n "$p2" ] && [[ "$cmd" == *"$p2"* ]] && return 0
return 1
}
_stop_service() {
# _stop_service <label> <pid_file> <port> <pattern> [extra_pattern]
#
# PID-based stopping is unreliable here: the frontend PID file tracks npm,
# but the server is a `node vite` GRANDCHILD (npm -> sh -c vite -> node)
# that reparents and survives a parent kill; uvicorn --reload leaves a
# worker child holding the port. So we stop in three escalating passes and
# treat the PORT as the source of truth for whether the service is down.
local label="$1" pid_file="$2" port="$3" pat="$4" pat2="$5"
# 1. Graceful: SIGTERM the tracked PID and its direct children.
if [ -f "$pid_file" ]; then
local pid; pid=$(cat "$pid_file" 2>/dev/null)
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null \
&& _pid_is_ours "$pid" "$pat" "$pat2"; then
pkill -TERM -P "$pid" 2>/dev/null || true
kill -TERM "$pid" 2>/dev/null || true
fi
rm -f "$pid_file"
fi
# 2. Sweep stragglers by command line — catches reparented grandchildren,
# --reload workers, and instances started outside this script.
[ -n "$pat" ] && pkill -TERM -f "$pat" 2>/dev/null
[ -n "$pat2" ] && pkill -TERM -f "$pat2" 2>/dev/null
# 3. Backstop: whatever still holds the port IS the service — kill it by
# port. This is what makes stop reliable no matter how the process tree
# was shaped or whether the PID file was accurate.
if [ -n "$port" ]; then
if ! _wait_for_port_close "$port"; then
fuser -k "${port}/tcp" 2>/dev/null
[ -n "$pat" ] && pkill -KILL -f "$pat" 2>/dev/null
[ -n "$pat2" ] && pkill -KILL -f "$pat2" 2>/dev/null
_wait_for_port_close "$port" || true
fi
if curl -s --max-time 1 "http://localhost:$port/" >/dev/null 2>&1; then
echo "$label STILL RUNNING (:$port) — try 'ncp kill'"
return 1
fi
fi
echo "$label STOPPED"
return 0
}
_wait_for_port_close() {
# _wait_for_port_close <port>
# Waits until the port stops responding. Silent — caller prints the result.
local port="$1" timeout=15 i=0
while curl -s --max-time 1 "http://localhost:$port/" > /dev/null 2>&1; do
i=$((i + 1))
[ "$i" -ge "$timeout" ] && return 1
sleep 1
done
return 0
}
_status() {
# _status <name> <pid_file> [port]
local name="$1" pid_file="$2" port="${3:-}"
if [ -f "$pid_file" ] && kill -0 "$(cat "$pid_file")" 2>/dev/null; then
echo " $name: RUNNING (PID $(cat "$pid_file"))"
elif [ -n "$port" ] && curl -s --max-time 1 "http://localhost:$port/" > /dev/null 2>&1; then
echo " $name: RUNNING (port :$port, PID stale — consider ncp kill)"
else
echo " $name: STOPPED"
fi
}
# -----------------------------
# KILL COMMAND
# -----------------------------
kill_services() {
echo "Force-killing all Nexus processes..."
local ports=(8000 8001 5173 11434)
local names=("SYNAPSE" "MEMORY" "INTERFACE" "OLLAMA")
for i in "${!ports[@]}"; do
port="${ports[$i]}"
name="${names[$i]}"
if fuser -k "${port}/tcp" 2>/dev/null; then
echo " KILLED: ${name} (:${port})"
else
echo " NOT RUNNING: ${name} (:${port})"
fi
done
pkill -9 -f "uvicorn synapse" 2>/dev/null || true
pkill -9 -f "npm run dev" 2>/dev/null || true
pkill -9 -f "vite --host" 2>/dev/null || true
pkill -9 -f "ollama serve" 2>/dev/null || true
rm -f "$PID_DIR"/*.pid
echo "Done."
}
# -----------------------------
# START COMMANDS
# -----------------------------
start_ollama() {
# Ollama runs as its own `ollama serve` process, but the backend's
# OllamaManager owns model/GPU selection — so drive it through the backend
# endpoint (the same path the control panel uses) rather than launching the
# binary directly. Requires the backend to be up.
echo "Starting OLLAMA..."
if curl -s --max-time 30 -X POST http://localhost:8000/ollama/start > /dev/null 2>&1; then
echo "NEXUS OLLAMA STARTED"
else
echo " OLLAMA start request failed (backend not reachable on :8000)"
fi
}
start_memory() {
_launch "NEXUS MEMORY SERVICE" "$MEMORY_PID" "$MEMORY_LOG" "$NEXUS_ROOT" \
"$PYTHON" -m uvicorn synapse.memory.service:app --host 0.0.0.0 --port 8001 --reload
_wait_for_port "NEXUS MEMORY SERVICE" 8001 "$MEMORY_PID" "$MEMORY_LOG"
}
start_backend() {
_launch "NEXUS BACKEND SERVICE" "$BACKEND_PID" "$BACKEND_LOG" "$NEXUS_ROOT" \
"$PYTHON" -m uvicorn synapse.main:sio_app --host 0.0.0.0 --port 8000 --reload
_wait_for_port "NEXUS BACKEND SERVICE" 8000 "$BACKEND_PID" "$BACKEND_LOG"
# AI is manual now — start it with `start --ai` or the web UI button.
}
start_frontend() {
_launch "NEXUS FRONTEND SERVICE" "$FRONTEND_PID" "$FRONTEND_LOG" "$FRONTEND_DIR" \
npm run dev -- --host 0.0.0.0
_wait_for_port "NEXUS FRONTEND SERVICE" 5173 "$FRONTEND_PID" "$FRONTEND_LOG"
}
start_all() {
# Memory + backend launch in parallel, wait for both before starting frontend
_launch "NEXUS MEMORY SERVICE" "$MEMORY_PID" "$MEMORY_LOG" "$NEXUS_ROOT" \
"$PYTHON" -m uvicorn synapse.memory.service:app --host 0.0.0.0 --port 8001 --reload
_launch "NEXUS BACKEND SERVICE" "$BACKEND_PID" "$BACKEND_LOG" "$NEXUS_ROOT" \
"$PYTHON" -m uvicorn synapse.main:sio_app --host 0.0.0.0 --port 8000 --reload
_wait_for_port "NEXUS MEMORY SERVICE" 8001 "$MEMORY_PID" "$MEMORY_LOG"
_wait_for_port "NEXUS BACKEND SERVICE" 8000 "$BACKEND_PID" "$BACKEND_LOG"
# AI is manual now — start it with `start --ai` or the web UI button.
_launch "NEXUS FRONTEND SERVICE" "$FRONTEND_PID" "$FRONTEND_LOG" "$FRONTEND_DIR" \
npm run dev -- --host 0.0.0.0
_wait_for_port "NEXUS FRONTEND SERVICE" 5173 "$FRONTEND_PID" "$FRONTEND_LOG"
}
# -----------------------------
# STOP COMMANDS
# -----------------------------
stop_ollama() {
# Prefer the backend endpoint for a clean OllamaManager shutdown; if the
# backend is already down, kill `ollama serve` directly so it never lingers
# holding VRAM/RAM. Must run BEFORE the backend is torn down.
if curl -s --max-time 5 -X POST http://localhost:8000/ollama/stop > /dev/null 2>&1; then
echo "NEXUS OLLAMA STOPPED"
elif pgrep -f "ollama serve" > /dev/null 2>&1; then
pkill -TERM -f "ollama serve" 2>/dev/null
echo "NEXUS OLLAMA STOPPED (direct)"
fi
}
stop_memory() {
_stop_service "NEXUS MEMORY SERVICE" "$MEMORY_PID" 8001 "uvicorn synapse.memory"
}
stop_backend() {
stop_ollama
_stop_service "NEXUS BACKEND SERVICE" "$BACKEND_PID" 8000 "uvicorn synapse.main"
}
stop_frontend() {
_stop_service "NEXUS FRONTEND SERVICE" "$FRONTEND_PID" 5173 "vite --host" "npm run dev"
}
stop_all() {
stop_memory
stop_backend
stop_frontend
}
# -----------------------------
# STATUS COMMAND
# -----------------------------
status_services() {
echo "Nexus Service Status:"
echo
echo "Backend:"
_status "Synapse " "$BACKEND_PID" 8000
_status "Memory service" "$MEMORY_PID" 8001
echo
echo "Frontend:"
_status "Vite " "$FRONTEND_PID" 5173
echo
echo "Model server:"
if curl -s --max-time 1 "http://localhost:11434/api/tags" > /dev/null 2>&1; then
echo " Ollama : RUNNING (:11434)"
else
echo " Ollama : STOPPED"
fi
}
# -----------------------------
# LOGS COMMAND
# -----------------------------
show_logs() {
case "$1" in
--frontend|-f)
echo "=== FRONTEND LOGS ==="
tail -n 50 "$FRONTEND_LOG"
;;
--backend|-b)
echo "=== BACKEND LOGS ==="
tail -n 50 "$BACKEND_LOG"
;;
--memory|-m)
echo "=== MEMORY SERVICE LOGS ==="
tail -n 50 "$MEMORY_LOG"
;;
""|all)
echo "=== MEMORY SERVICE LOGS ==="
tail -n 30 "$MEMORY_LOG"
echo
echo "=== BACKEND LOGS ==="
tail -n 30 "$BACKEND_LOG"
echo
echo "=== FRONTEND LOGS ==="
tail -n 30 "$FRONTEND_LOG"
;;
*)
echo "Unknown logs target: '$1'"
echo "Usage: ncp logs [frontend|backend|memory|all]"
;;
esac
}
# -----------------------------
# DOCTOR COMMAND
# -----------------------------
doctor() {
echo "Running Nexus Diagnostics..."
echo
echo "Checking directories..."
[ -d "$NEXUS_ROOT" ] && echo " ✔ Nexus root found" || echo " ✘ Missing Nexus root"
[ -d "$FRONTEND_DIR" ] && echo " ✔ Frontend directory found" || echo " ✘ Missing frontend directory"
echo
echo "Checking Python venv..."
[ -x "$PYTHON" ] && echo " ✔ Promethean venv found ($($PYTHON --version 2>&1))" || echo " ✘ Promethean venv missing at $PYTHON"
echo
echo "Checking Node & npm..."
command -v node >/dev/null && echo " ✔ Node installed ($(node -v))" || echo " ✘ Node missing"
command -v npm >/dev/null && echo " ✔ npm installed ($(npm -v))" || echo " ✘ npm missing"
[ -d "$FRONTEND_DIR/node_modules" ] && echo " ✔ Frontend node_modules installed" || echo " ✘ Frontend node_modules missing (run: ncp update)"
echo
echo "Checking Uvicorn..."
"$PYTHON" -m uvicorn --version >/dev/null 2>&1 && echo " ✔ Uvicorn installed" || echo " ✘ Uvicorn missing"
echo
echo "Checking backend service..."
( cd "$NEXUS_ROOT" && "$PYTHON" -c "from synapse.main import sio_app" ) >/dev/null 2>&1 \
&& echo " ✔ Backend module importable" \
|| echo " ✘ Backend module failed to import"
echo
echo "Checking memory service..."
[ -d "$NEXUS_ROOT/synapse/memory" ] && echo " ✔ Memory module directory found" || echo " ✘ Missing memory module directory"
( cd "$NEXUS_ROOT" && "$PYTHON" -c "from synapse.memory.service import app" ) >/dev/null 2>&1 \
&& echo " ✔ Memory service module importable" \
|| echo " ✘ Memory service module failed to import"
[ -w "$NEXUS_ROOT/synapse/memory" ] && echo " ✔ Memory database directory writable" || echo " ✘ Memory database directory not writable"
echo
echo "Checking Ollama..."
[ -x "$OLLAMA_BIN" ] && echo " ✔ Ollama binary found ($OLLAMA_BIN)" || echo " ✘ Ollama binary missing at $OLLAMA_BIN"
[ -d "$OLLAMA_MODELS_DIR" ] && echo " ✔ Ollama models directory found ($OLLAMA_MODELS_DIR)" || echo " ✘ Ollama models directory missing at $OLLAMA_MODELS_DIR"
echo
status_services
}
# -----------------------------
# HELP COMMAND
# -----------------------------
show_help() {
echo "Nexus Command Tree"
echo
echo "Usage: ncp <command> [options]"
echo
echo "Commands:"
echo " panel Launch the Nexus Control Panel"
echo
echo " chat <message> Send a message, stream the reply"
echo " memory list List memory facts"
echo " add <text> Add a fact (--section <name>)"
echo " rm <id> Delete a fact by id"
echo " playbook list List playbooks (* = active)"
echo " show <id> Print a playbook's goal + instructions"
echo " history [query] Recent conversations (optional keyword)"
echo
echo " start Start ALL Nexus services (memory + backend + frontend)"
echo " --memory, -m Start only the memory service"
echo " --frontend,-f Start only the frontend"
echo " --backend, -b Start only the backend"
echo " --ai, -a Start the AI (Ollama) — manual; not started by default"
echo
echo " stop Stop ALL Nexus services"
echo " --memory, -m Stop only the memory service"
echo " --frontend,-f Stop only the frontend"
echo " --backend, -b Stop only the backend"
echo
echo " kill Force-kill all Nexus processes by port (nuclear option)"
echo " refresh Restart all services"
echo
echo " status Show service status"
echo " logs Show logs for all services"
echo " --memory, -m Memory service logs"
echo " --frontend,-f Frontend logs"
echo " --backend, -b Backend logs"
echo
echo " doctor Run Nexus diagnostics"
echo " update Update Nexus dependencies"
echo " clean Remove runtime files and caches"
echo
echo " models"
echo " list List installed models"
echo " available Show models available to install"
echo " install <name> Pull a model into Nexus"
echo
echo " backup Backup Nexus to Gitea (git commit + push)"
echo " backup -f, full Backup + snapshot live desktop wiring/notes"
echo " backup -c Dry run: what a backup would commit/push"
echo " restore Restore Nexus from Gitea (git pull + rebuild)"
echo " restore -c Dry run: what a restore would apply"
echo
echo " help, -h Show this help message"
}
# -----------------------------
# UPDATE COMMAND
# -----------------------------
update_nexus() {
echo "Updating Project Nexus..."
echo
cd "$NEXUS_ROOT" || exit 1
echo "Skipping git pull — update only refreshes dependencies."
echo
echo "Updating backend Python dependencies..."
if [ -f "requirements-amd.txt" ]; then
"$PYTHON" -m pip install -r requirements-amd.txt
else
echo "No requirements-amd.txt found."
fi
echo
echo "Updating frontend dependencies..."
if [ -d "$FRONTEND_DIR" ]; then
cd "$FRONTEND_DIR"
npm install
else
echo "Frontend directory missing — skipping npm install."
fi
echo
echo "Running post-update diagnostics..."
doctor
}
# -----------------------------
# BACKUP / RESTORE COMMANDS
# -----------------------------
# Backup/restore go through git → Gitea now (bin/sync.py), not rsync-to-router,
# so no SSH/WireGuard reachability gate is needed — git handles its own
# connectivity and errors over HTTPS. sync.py is the same entry point the Windows
# box uses; it calls bin/restore-linux.sh and bin/backup-linux.sh for the XFCE
# desktop half, which only runs here.
# ncp - Linux entry point. The CLI itself is management/ncp.py, which runs
# unchanged on Windows too (see management/ncp.ps1); this stays a shell script
# because ~/.bashrc, launch_nexus.sh, bin/restore-linux.sh, controlpanel.py,
# bin/panel/nexus-popup.py and management/nexus-app.sh all invoke this path.
#
# sync.py is stdlib-only, so system python3 works when the venv isn't built yet.
sync_py() {
local py="$PYTHON"
[ -x "$py" ] || py=python3
"$py" "$NEXUS_ROOT/bin/sync.py" "$@"
}
# Resolved from this file rather than $HOME/nexus-core, so a clone in a scratch
# directory drives itself instead of reaching into the real install.
NEXUS_ROOT="$(cd "$(dirname "$(realpath "$0")")/.." && pwd)"
restore_nexus() {
echo "This pulls the latest backup from Gitea and rebuilds the environment"
echo "(venv, npm, theme, panel). Local commits must be pushed or stashed first."
printf "Continue? [y/N] "
read -r confirm
if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then
echo "Restore cancelled."
return
fi
sync_py restore
}
# ncp.py needs psutil (venv), but its backup/restore path delegates to the
# stdlib-only bin/sync.py and must work before the venv is built.
PY="$NEXUS_ROOT/Promethean/bin/python3"
[ -x "$PY" ] || PY=python3
# -----------------------------
# MODELS COMMANDS
# -----------------------------
OLLAMA_BIN="$NEXUS_ROOT/ollama/bin/ollama"
OLLAMA_MODELS_DIR="$NEXUS_ROOT/models"
models_list() {
if ! curl -sf http://localhost:11434/api/tags > /dev/null 2>&1; then
echo "Ollama is not running. Start the backend first with: ncp start -b"
return 1
fi
echo "Installed models:"
echo
curl -s http://localhost:11434/api/tags | "$PYTHON" -c "
import sys, json
data = json.load(sys.stdin)
models = data.get('models', [])
if not models:
print(' No models installed.')
else:
for m in models:
size_mb = m['size'] // 1024 // 1024
size = f'{size_mb / 1024:.1f} GB' if size_mb >= 1024 else f'{size_mb} MB'
print(f' {m[\"name\"]:<35} {size}')
"
}
models_available() {
echo "Available models (via Ollama library):"
echo
printf " %-30s %-10s %s\n" "MODEL" "SIZE" "DESCRIPTION"
printf " %-30s %-10s %s\n" "-----" "----" "-----------"
printf " %-30s %-10s %s\n" "gemma3:1b" "~815 MB" "Google Gemma 3 — fast, lightweight"
printf " %-30s %-10s %s\n" "gemma3:4b" "~3.3 GB" "Google Gemma 3 — balanced"
printf " %-30s %-10s %s\n" "gemma3:12b" "~8.1 GB" "Google Gemma 3 — capable"
printf " %-30s %-10s %s\n" "llama3.2:1b" "~1.3 GB" "Meta Llama 3.2 — fast, lightweight"
printf " %-30s %-10s %s\n" "llama3.2:3b" "~2.0 GB" "Meta Llama 3.2 — compact, capable"
printf " %-30s %-10s %s\n" "llama3.1:8b" "~4.7 GB" "Meta Llama 3.1 — strong general use"
printf " %-30s %-10s %s\n" "mistral:latest" "~4.1 GB" "Mistral 7B — solid all-rounder"
printf " %-30s %-10s %s\n" "mistral-nemo" "~7.1 GB" "Mistral Nemo 12B — strong reasoning"
printf " %-30s %-10s %s\n" "qwen2.5:3b" "~2.0 GB" "Alibaba Qwen 2.5 — great at code"
printf " %-30s %-10s %s\n" "qwen2.5:7b" "~4.7 GB" "Alibaba Qwen 2.5 — strong coder"
printf " %-30s %-10s %s\n" "phi4-mini" "~2.5 GB" "Microsoft Phi-4 Mini — efficient"
printf " %-30s %-10s %s\n" "phi4:14b" "~8.9 GB" "Microsoft Phi-4 — strong reasoning"
printf " %-30s %-10s %s\n" "deepseek-r1:7b" "~4.7 GB" "DeepSeek R1 — reasoning model"
printf " %-30s %-10s %s\n" "deepseek-r1:14b" "~9.0 GB" "DeepSeek R1 — strong reasoning"
printf " %-30s %-10s %s\n" "codellama:7b" "~3.8 GB" "Meta Code Llama — code focused"
printf " %-30s %-10s %s\n" "nomic-embed-text" "~274 MB" "Text embeddings model"
echo
echo "Install any model with: ncp models install <model>"
echo "Browse more at: https://ollama.com/library"
}
models_install() {
local model="$1"
if [ -z "$model" ]; then
echo "Usage: ncp models install <model>"
echo "Run 'ncp models available' to see options."
return 1
fi
if [ ! -x "$OLLAMA_BIN" ]; then
echo "Ollama binary not found at $OLLAMA_BIN"
return 1
fi
echo "Pulling '$model' into $OLLAMA_MODELS_DIR ..."
echo
OLLAMA_MODELS="$OLLAMA_MODELS_DIR" "$OLLAMA_BIN" pull "$model"
echo
echo "Done. Run 'ncp models list' to verify."
}
# -----------------------------
# CLEAN COMMAND
# -----------------------------
clean_nexus() {
echo "Cleaning Nexus runtime files..."
echo
echo "Removing PID files..."
rm -f "$PID_DIR"/*.pid
echo "Removing logs..."
rm -f "$LOG_DIR"/*.log
echo "Removing Python cache..."
find "$NEXUS_ROOT" -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null
echo "Removing Node/Vite cache..."
find "$FRONTEND_DIR" -type d -name ".vite" -exec rm -rf {} + 2>/dev/null
echo
echo "Cleanup complete."
}
# -----------------------------
# COMMAND TREE ROUTER
# -----------------------------
subcommand="$1"
shift
case "$subcommand" in
panel) python3 "$NEXUS_ROOT/management/controlpanel.py" ;;
chat|memory|playbook|history)
"$PYTHON" "$NEXUS_ROOT/management/nexus_api.py" "$subcommand" "$@" ;;
start)
case "$1" in
--memory|-m) start_memory ;;
--frontend|-f) start_frontend ;;
--backend|-b) start_backend ;;
--ai|-a) start_ollama ;;
""|all) start_all ;;
*) show_help ;;
esac
;;
stop)
case "$1" in
--memory|-m) stop_memory ;;
--frontend|-f) stop_frontend ;;
--backend|-b) stop_backend ;;
""|all) stop_all ;;
*) show_help ;;
esac
;;
refresh) stop_all && start_all ;;
kill) kill_services ;;
status) status_services ;;
logs) show_logs "$1" ;;
doctor) doctor ;;
update) update_nexus ;;
clean) clean_nexus ;;
help|-h|"") show_help ;;
nvidia-reqs) "$PYTHON" "$NEXUS_ROOT/bin/gen-nvidia-reqs.py" ;;
backup)
case "$1" in
-f|full) sync_py backup --full ;;
-c|--claude|check) sync_py backup --check ;;
"") sync_py backup ;;
*) echo "Usage: ncp backup [-f|full|-c]" ;;
esac
;;
restore)
case "$1" in
-f|full) restore_nexus ;;
# Dry run: no confirmation prompt, it changes nothing.
-c|--claude|check) sync_py restore --check ;;
"") restore_nexus ;;
*) echo "Usage: ncp restore [-f|full|-c]" ;;
esac
;;
models)
case "$1" in
list) models_list ;;
available|search) models_available ;;
install) models_install "$2" ;;
*) echo "Usage: ncp models <list|available|install <model>>" ;;
esac
;;
*)
echo "Unknown Nexus command: '$subcommand'"
echo "Use 'ncp help' for available commands."
;;
esac
exec "$PY" "$NEXUS_ROOT/management/ncp.py" "$@"