#!/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-windows.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 ollama_bin() -> str | None: """The bundled binary if we have one, else whatever is on PATH. Only Linux ships a copy in the repo (bin/fetch-ollama.sh); on Windows the installer gets Ollama from winget, which puts it in %LOCALAPPDATA%\\Programs and on PATH. Checking only the bundled path made `ncp doctor` report a red "Ollama binary missing" on every Windows box, and made `ncp models install` refuse to run there at all. Mirrors _ollama_bin() in ollama_manager.py.""" if OLLAMA_BIN.exists(): return str(OLLAMA_BIN) return shutil.which("ollama") 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): # No --reload. It is a dev-loop flag: uvicorn's reloader runs a supervisor # that spawns the real server as a CHILD, so every service became two # processes - and on Windows that child, spawned from a parent with no # console, got handed a brand new console WINDOW. `ncp web` popped two black # terminals for what should have been a silent start. launch_nexus.sh still # passes --reload for the Linux dev loop, where a visible console is the # point; this launcher is the one users run. return [str(PYTHON), "-m", "uvicorn", app, "--host", "0.0.0.0", "--port", str(port)] 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. # CREATE_NO_WINDOW, not DETACHED_PROCESS: detached means the process has # NO console, and Windows then gives a console window to any console # program it starts in turn. CREATE_NO_WINDOW gives it a console that is # never shown, which its children inherit - so nothing flashes up. kwargs = ({"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP | getattr(subprocess, "CREATE_NO_WINDOW", 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 # Poll granularity for both waiters below. 1s steps used to mean every # start/stop paid up to a full second of dead latency per service on top of # however long the process actually took - three services in sequence could # lose several seconds to nothing but sleep(). 0.25s still amounts to one # cheap local HTTP HEAD every quarter second, not a busy-loop. _POLL_STEP = 0.25 def wait_for_port(svc: Service, timeout: int = 30) -> bool: if http_ok(svc.url): # "READY", not "already running": `ncp start` launches memory and backend # together and only then waits on each, so by the time the backend's turn # comes it is normally up - and reporting "already running" for a service # this same command started two seconds ago reads like a stale process. print(f" {svc.label} READY (:{svc.port})") return True for _ in range(int(timeout / _POLL_STEP)): time.sleep(_POLL_STEP) 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(int(timeout / _POLL_STEP)): if not http_ok(f"http://localhost:{port}/"): return True time.sleep(_POLL_STEP) return False def kill_matching(patterns, force=False) -> int: """The pkill -f sweep: catches reparented grandchildren (npm -> sh -> node vite), uvicorn --reload workers, and instances started outside this CLI. Returns how many processes it signalled, so callers can stay quiet when there was nothing to kill.""" ps = _psutil() me = os.getpid() hit = 0 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() hit += 1 except Exception: pass return hit 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(background: bool = False) -> 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. background=True (`ncp start`): the endpoint returns as soon as `ollama serve` is up and warms the model in a background task, so boot finishes in seconds and the model loads concurrently into the first chat. background=False (`ncp start --ai`): blocks until the model is warmed - weights read off disk into RAM/VRAM, routinely about a minute - so "started" means the AI can actually answer.""" print("Starting OLLAMA...") url = "http://localhost:8000/ollama/start" + ("?background=true" if background else "") req = urllib.request.Request(url, method="POST") try: urllib.request.urlopen(req, timeout=180).read(1) print("NEXUS OLLAMA WARMING (background)" if background else "NEXUS OLLAMA STARTED") except Exception as e: print(f" OLLAMA start failed: {e}") 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 # Silent when there was nothing to stop - the bash version only announced a # direct kill if `pgrep ollama serve` actually matched, and claiming to have # stopped a service that was never running is worse than saying nothing. if 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"]) start_ollama() 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"): # Bring the UI up first, then kick off Ollama and (on Linux) Vite in the # background without waiting on either — neither gates the app being # usable. The backend already serves the built interface/web/dist at # :8000 on both platforms (single-process design), and that's the URL # nexus-app.sh/ncp web actually opens; nothing points a user at :5173. # Vite only exists for whoever is hot-reload-editing the frontend, and # they'll open :5173 themselves once it's ready - polling for it here # just delayed "boot done" for a benefit nobody in the critical path # gets. Skipped outright on Windows, where it's not part of the normal # workflow at all and is the slow part of `ncp stop` to boot (npm's # cmd.exe -> node -> esbuild tree doesn't die from a plain terminate() # and falls through to the ~30s force-kill path). Still available on # demand via `ncp start --frontend`. t0 = time.perf_counter() launch(SERVICES["memory"]) launch(SERVICES["backend"]) wait_for_port(SERVICES["memory"]) wait_for_port(SERVICES["backend"]) t_services = time.perf_counter() start_ollama(background=True) if not WINDOWS: launch(SERVICES["frontend"]) t_bg = time.perf_counter() print("\nBoot timing:") print(f" services (memory+backend) : {t_services - t0:5.1f}s") print(f" ollama + frontend (bg kickoff) : {t_bg - t_services:5.1f}s") print(f" total to interactive : {t_bg - t0:5.1f}s") 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 _apply_fixes() -> None: """Opt-in safe repairs (ncp doctor --fix). Only idempotent, non-destructive actions: build the UI, install deps, fetch the Ollama binary. Anything that could lose data or needs a decision is left to the user with a hint.""" print("\nApplying safe fixes...\n") did = False web, npm_path = FRONTEND_DIR, npm() if not PYTHON.exists(): print(" ✘ Promethean venv missing — run ./install.sh to build it (skipped: heavy).") else: importable = subprocess.run( [str(PYTHON), "-c", "from synapse.main import sio_app"], cwd=str(ROOT), capture_output=True).returncode == 0 if not importable: print(" Backend import failed — reinstalling Python dependencies...") 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) subprocess.run([str(PYTHON), "-m", "pip", "install", "-r", sync.requirements()], cwd=str(ROOT)) did = True if npm_path and web.is_dir() and not (web / "node_modules").is_dir(): print(" node_modules missing — running npm install...") subprocess.run([npm_path, "install"], cwd=str(web)) did = True if npm_path and web.is_dir() and not (web / "dist" / "index.html").exists(): print(" Built UI missing — running npm run build...") subprocess.run([npm_path, "run", "build"], cwd=str(web)) did = True if not ollama_bin() and os.name != "nt" and (ROOT / "bin" / "fetch-ollama.sh").exists(): print(" Ollama binary missing — fetching...") subprocess.run(["bash", str(ROOT / "bin" / "fetch-ollama.sh")]) did = True print("\n" + ("Fixes applied — re-run 'ncp doctor' to confirm." if did else "Nothing to fix (all safe-repairable checks already pass).")) def cmd_doctor(fix: bool = False) -> 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 "" def node_ok(ver: str) -> bool: # Vite needs Node 20.19+ (or 22.12+); Debian's apt 'nodejs' is EOL 18. try: parts = ver.lstrip("v").split(".") major, minor = int(parts[0]), int(parts[1]) except (ValueError, IndexError): return False return major >= 21 or (major == 20 and minor >= 19) node, npm_path = shutil.which("node"), npm() node_ver = version(node) mark(bool(node), f"Node installed ({node_ver})", "Node missing") if node: mark(node_ok(node_ver), f"Node version OK ({node_ver} >= 20.19, Vite requirement)", f"Node {node_ver} too old for Vite (needs 20.19+). Install Node 20 " f"(re-run ./install.sh, or NodeSource), then rebuild with: ncp update") 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...") _obin = ollama_bin() mark(bool(_obin), f"Ollama binary found ({_obin})", f"Ollama binary missing (not at {OLLAMA_BIN}, not on PATH)") 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() if fix: _apply_fixes() 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 ") print("Browse more at: https://ollama.com/library") elif action == "install": if not name: print("Usage: ncp models install ") print("Run 'ncp models available' to see options.") return obin = ollama_bin() if not obin: print(f"Ollama binary not found at {OLLAMA_BIN}, and no 'ollama' on PATH") return print(f"Pulling '{name}' into {OLLAMA_MODELS_DIR} ...\n") subprocess.run([obin, "pull", name], env={**os.environ, "OLLAMA_MODELS": str(OLLAMA_MODELS_DIR)}) print("\nDone. Run 'ncp models list' to verify.") else: print("Usage: ncp models >") 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 [options] Commands: panel Launch the Nexus Control Panel (tkinter) web Open the web interface (starts the stack if needed) chat Send a message, stream the reply memory list List memory facts add Add a fact (--section ) rm Delete a fact by id playbook list List playbooks (* = active) show 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 only the AI (Ollama); `start`/`start -b` already include it 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 [--fix] Run Nexus diagnostics (--fix applies safe repairs) update Update Nexus dependencies clean Remove runtime files and caches models list List installed models available Show models available to install install 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(fix="--fix" in rest) 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__": try: sys.exit(main(sys.argv[1:])) except KeyboardInterrupt: # Ctrl+C is how you quit `ncp web` - it waits on the UI window. Without # this it printed a six-frame traceback ending in WaitForSingleObject, # which reads as a crash rather than "you pressed Ctrl+C". 130 is the # conventional exit status for SIGINT. print() sys.exit(130)