feat(packaging): move the CLI into nexusos_cli and make the wheel self-sufficient
The CLI shipped from `management/`, which also holds desktop-only pieces (the Tk control panel, the XFCE panel wiring, the shell wrappers). Packaging that directory meant the wheel either dragged in tkinter or shipped a broken import. Split it: `nexusos_cli/` is what the wheel ships and what `nexus`/`ncp`/ `nexusos` dispatch to, `management/` keeps the desktop half. Alongside the move: * hatch_build.py decides the interface/web/dist include at build time. dist/ is gitignored, so a static force-include aborts `pip install -e .` on a fresh clone - before the reader reaches the `npm run build` step. Editable installs now skip a missing dist; wheels and sdists hard-error naming the command to run. * synapse/proc_util.py gives frontend_manager and ncp process inspection and termination without psutil, which became an optional extra when the wheel landed. It routes around Windows having no signals, where os.kill(pid, 15) is an unblockable TerminateProcess rather than a polite request. * nexusos_cli/monitor.py adds `ncp monitor`, an ASCII dashboard with no curses or rich dependency so it works in Termux, plain SSH and Windows Terminal. Collector and renderer are separate so tests feed fixtures, no stack needed. * tests/test_packaging_deps.py fails the gate when synapse or nexusos_cli import a distribution pyproject does not declare, and when an optional dependency is imported at module scope instead of lazily. * bin/check.sh now builds the wheel, twine-checks it, and asserts the compiled UI and seed playbooks are actually inside it. A wheel that builds but ships no dist/ serves a blank page, which only shows up after release. tests/test_nexus_api.py moves to tests/ with the module it covers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d579502a5b
commit
9104c724c4
@@ -1 +1,5 @@
|
||||
"""NexusOS command-line and desktop management helpers."""
|
||||
"""Desktop-only NexusOS management helpers (Tk control panel, XFCE panel).
|
||||
|
||||
The portable CLI lives in the `nexusos_cli` package - that is what the
|
||||
wheel ships and what `ncp`/`nexus`/`nexusos` dispatch to.
|
||||
"""
|
||||
|
||||
@@ -1,710 +0,0 @@
|
||||
"""Portable NexusOS command line used by the ``nexus`` and ``ncp`` scripts."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from synapse import nexus_config as config
|
||||
from synapse.nexus_config import settings
|
||||
|
||||
from . import ncp as services
|
||||
|
||||
|
||||
CONFIG_SCHEMA = {
|
||||
"api_url": "url",
|
||||
"memory_url": "url",
|
||||
"bind_host": "text",
|
||||
"backend_port": "port",
|
||||
"memory_port": "port",
|
||||
"provider": "provider",
|
||||
"provider_url": "url",
|
||||
"provider_timeout": "positive_int",
|
||||
"data_dir": "path",
|
||||
"models_dir": "path",
|
||||
"runtime_dir": "path",
|
||||
"memory_dir": "path",
|
||||
"memory_db": "path",
|
||||
}
|
||||
|
||||
LEGACY_TARGETS = {
|
||||
"-m": "memory",
|
||||
"--memory": "memory",
|
||||
"-b": "backend",
|
||||
"--backend": "backend",
|
||||
"-f": "frontend",
|
||||
"--frontend": "frontend",
|
||||
"-a": "ai",
|
||||
"--ai": "ai",
|
||||
}
|
||||
|
||||
|
||||
def _emit(payload, json_output: bool = False) -> None:
|
||||
if json_output:
|
||||
print(json.dumps(payload, indent=2, sort_keys=True))
|
||||
elif isinstance(payload, str):
|
||||
print(payload)
|
||||
else:
|
||||
for key, value in payload.items():
|
||||
print(f"{key}: {value}")
|
||||
|
||||
|
||||
def _http_ok(url: str, timeout: float = 1.0) -> bool:
|
||||
try:
|
||||
urllib.request.urlopen(url, timeout=timeout).read(1)
|
||||
return True
|
||||
except urllib.error.HTTPError:
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _is_termux() -> bool:
|
||||
prefix = os.getenv("PREFIX", "")
|
||||
return "com.termux" in prefix or bool(os.getenv("TERMUX_VERSION"))
|
||||
|
||||
|
||||
def _validate_config(key: str, raw: str):
|
||||
kind = CONFIG_SCHEMA[key]
|
||||
value = raw.strip()
|
||||
if kind == "url":
|
||||
parsed = urlparse(value)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
||||
raise ValueError(f"{key} must be an http(s) URL")
|
||||
return value.rstrip("/")
|
||||
if kind == "port":
|
||||
number = int(value)
|
||||
if not 1 <= number <= 65535:
|
||||
raise ValueError(f"{key} must be between 1 and 65535")
|
||||
return number
|
||||
if kind == "positive_int":
|
||||
number = int(value)
|
||||
if number <= 0:
|
||||
raise ValueError(f"{key} must be greater than zero")
|
||||
return number
|
||||
if kind == "provider":
|
||||
if value not in ("ollama", "ollama-remote"):
|
||||
raise ValueError("provider must be ollama or ollama-remote")
|
||||
return value
|
||||
if kind == "path":
|
||||
return str(Path(value).expanduser().resolve())
|
||||
if not value:
|
||||
raise ValueError(f"{key} cannot be empty")
|
||||
return value
|
||||
|
||||
|
||||
def cmd_init(args) -> int:
|
||||
config.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
copied = list(config.INITIALIZED_FILES) + config.init_state()
|
||||
payload = {
|
||||
"status": "initialized",
|
||||
"state_dir": str(settings.state_dir),
|
||||
"config_file": str(settings.config_file),
|
||||
"data_dir": str(settings.data_dir),
|
||||
"models_dir": str(settings.models_dir),
|
||||
"runtime_dir": str(settings.runtime_dir),
|
||||
"seeded_playbooks": len(copied),
|
||||
}
|
||||
_emit(payload, args.json)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_paths(args) -> int:
|
||||
payload = {
|
||||
"install_mode": "checkout" if settings.source_checkout else "wheel",
|
||||
"project_root": str(settings.project_root),
|
||||
"resource_root": str(settings.resource_root),
|
||||
"state_dir": str(settings.state_dir),
|
||||
"config_file": str(settings.config_file),
|
||||
"data_dir": str(settings.data_dir),
|
||||
"memory_db": str(settings.memory_db),
|
||||
"models_dir": str(settings.models_dir),
|
||||
"runtime_dir": str(settings.runtime_dir),
|
||||
"web_dist_dir": str(settings.web_dist_dir),
|
||||
}
|
||||
_emit(payload, args.json)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_config(args) -> int:
|
||||
values = config.read_user_config()
|
||||
if args.action == "path":
|
||||
print(config.CONFIG_FILE)
|
||||
return 0
|
||||
if args.action == "list":
|
||||
_emit(values, args.json)
|
||||
return 0
|
||||
if args.action == "get":
|
||||
if args.key not in CONFIG_SCHEMA:
|
||||
print(f"Unknown configuration key: {args.key}", file=sys.stderr)
|
||||
return 2
|
||||
value = values.get(args.key, getattr(settings, args.key, None))
|
||||
_emit({args.key: value}, args.json)
|
||||
return 0
|
||||
if args.action == "set":
|
||||
if args.key not in CONFIG_SCHEMA:
|
||||
print(f"Unknown configuration key: {args.key}", file=sys.stderr)
|
||||
print("Known keys: " + ", ".join(CONFIG_SCHEMA), file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
values[args.key] = _validate_config(args.key, args.value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
print(f"Invalid value: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
config.write_user_config(values)
|
||||
_emit({"updated": args.key, "value": values[args.key], "restart_required": True}, args.json)
|
||||
return 0
|
||||
if args.action == "unset":
|
||||
if args.key not in CONFIG_SCHEMA:
|
||||
print(f"Unknown configuration key: {args.key}", file=sys.stderr)
|
||||
return 2
|
||||
values.pop(args.key, None)
|
||||
config.write_user_config(values)
|
||||
_emit({"removed": args.key, "restart_required": True}, args.json)
|
||||
return 0
|
||||
return 2
|
||||
|
||||
|
||||
def _provider_payload() -> dict:
|
||||
url = settings.ollama_host.rstrip("/")
|
||||
return {
|
||||
"provider": settings.provider,
|
||||
"url": url,
|
||||
"managed_by_nexus": settings.manage_ollama,
|
||||
"reachable": _http_ok(url + "/api/tags", timeout=2.0),
|
||||
}
|
||||
|
||||
|
||||
def cmd_provider(args) -> int:
|
||||
if args.action == "show":
|
||||
_emit(_provider_payload(), args.json)
|
||||
return 0
|
||||
|
||||
values = config.read_user_config()
|
||||
if args.mode == "local":
|
||||
values["provider"] = "ollama"
|
||||
try:
|
||||
values["provider_url"] = _validate_config(
|
||||
"provider_url", args.url or "http://127.0.0.1:11434"
|
||||
)
|
||||
except ValueError as exc:
|
||||
print(f"Invalid value: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
else:
|
||||
if not args.url:
|
||||
print("Remote provider setup requires --url", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
values["provider_url"] = _validate_config("provider_url", args.url)
|
||||
except ValueError as exc:
|
||||
print(f"Invalid value: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
values["provider"] = "ollama-remote"
|
||||
config.write_user_config(values)
|
||||
_emit({
|
||||
"provider": values["provider"],
|
||||
"url": values["provider_url"],
|
||||
"restart_required": True,
|
||||
}, args.json)
|
||||
return 0
|
||||
|
||||
|
||||
def _check_import(module: str) -> bool:
|
||||
return importlib.util.find_spec(module) is not None
|
||||
|
||||
|
||||
def _writable(path: Path) -> bool:
|
||||
try:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
probe = path / ".nexus-write-test"
|
||||
probe.write_text("ok", encoding="utf-8")
|
||||
probe.unlink()
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def diagnostics() -> dict:
|
||||
checks: list[dict] = []
|
||||
|
||||
def add(name: str, ok: bool, detail: str, required: bool = True):
|
||||
checks.append({
|
||||
"name": name,
|
||||
"status": "pass" if ok else ("fail" if required else "warn"),
|
||||
"detail": detail,
|
||||
"required": required,
|
||||
})
|
||||
|
||||
add("python", sys.version_info >= (3, 11), sys.version.split()[0])
|
||||
add("state", _writable(settings.state_dir), str(settings.state_dir))
|
||||
add("database directory", _writable(settings.memory_db.parent), str(settings.memory_db.parent))
|
||||
add("web assets", (settings.web_dist_dir / "index.html").is_file(), str(settings.web_dist_dir))
|
||||
add(
|
||||
"provider mode",
|
||||
settings.provider in ("ollama", "ollama-remote"),
|
||||
settings.provider,
|
||||
)
|
||||
add(
|
||||
"service ports",
|
||||
all(1 <= port <= 65535 for port in (settings.backend_port, settings.memory_port)),
|
||||
f"backend={settings.backend_port}, memory={settings.memory_port}",
|
||||
)
|
||||
for module in ("fastapi", "uvicorn", "httpx", "pydantic", "yaml"):
|
||||
add(f"import:{module}", _check_import(module), module)
|
||||
|
||||
add("backend", _http_ok(settings.api_url + "/status"), settings.api_url, required=False)
|
||||
add("memory service", _http_ok(settings.memory_url + "/"), settings.memory_url, required=False)
|
||||
provider = _provider_payload()
|
||||
add("provider", provider["reachable"], provider["url"], required=False)
|
||||
if settings.manage_ollama:
|
||||
add("ollama executable", bool(services.ollama_bin()), services.ollama_bin() or "not on PATH", required=False)
|
||||
|
||||
for label, module in (
|
||||
("process control", "psutil"),
|
||||
("vector search", "sqlite_vec"),
|
||||
("voice transcription", "faster_whisper"),
|
||||
("PDF documents", "pypdf"),
|
||||
("Word documents", "docx"),
|
||||
):
|
||||
add(label, _check_import(module), module, required=False)
|
||||
|
||||
return {
|
||||
"ok": not any(c["status"] == "fail" for c in checks),
|
||||
"version": settings.version,
|
||||
"install_mode": "checkout" if settings.source_checkout else "wheel",
|
||||
"platform": sys.platform,
|
||||
"termux": _is_termux(),
|
||||
"checks": checks,
|
||||
}
|
||||
|
||||
|
||||
def _doctor_fix() -> None:
|
||||
config.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
config.init_state()
|
||||
index = settings.web_dist_dir / "index.html"
|
||||
npm = shutil.which("npm.cmd" if os.name == "nt" else "npm")
|
||||
if settings.source_checkout and not index.exists() and npm:
|
||||
subprocess.run([npm, "run", "build"], cwd=str(settings.frontend_source_dir), check=False)
|
||||
|
||||
|
||||
def cmd_doctor(args) -> int:
|
||||
if args.fix:
|
||||
_doctor_fix()
|
||||
result = diagnostics()
|
||||
if args.json:
|
||||
_emit(result, True)
|
||||
else:
|
||||
print(f"NexusOS {result['version']} diagnostics ({result['install_mode']})\n")
|
||||
marks = {"pass": "OK", "warn": "WARN", "fail": "FAIL"}
|
||||
for check in result["checks"]:
|
||||
print(f" {marks[check['status']]:<4} {check['name']:<20} {check['detail']}")
|
||||
print("\nCore runtime is ready." if result["ok"] else "\nCore runtime has required failures.")
|
||||
return 0 if result["ok"] else 1
|
||||
|
||||
|
||||
def service_status() -> dict:
|
||||
payload = {}
|
||||
for key in ("backend", "memory", "frontend"):
|
||||
svc = services.SERVICES[key]
|
||||
pid = services.read_pid(svc)
|
||||
payload[key] = {
|
||||
"running": services.alive(pid) or _http_ok(svc.url),
|
||||
"pid": pid if services.alive(pid) else None,
|
||||
"url": svc.url,
|
||||
}
|
||||
payload["provider"] = _provider_payload()
|
||||
return payload
|
||||
|
||||
|
||||
def cmd_status(args) -> int:
|
||||
payload = service_status()
|
||||
if args.json:
|
||||
_emit(payload, True)
|
||||
return 0
|
||||
print("Nexus Service Status:\n")
|
||||
for key in ("backend", "memory", "frontend"):
|
||||
info = payload[key]
|
||||
suffix = f" (PID {info['pid']})" if info["pid"] else ""
|
||||
print(f" {key:<10} {'RUNNING' if info['running'] else 'STOPPED'}{suffix} {info['url']}")
|
||||
p = payload["provider"]
|
||||
print(f" provider {'RUNNING' if p['reachable'] else 'STOPPED'} {p['provider']} @ {p['url']}")
|
||||
return 0
|
||||
|
||||
|
||||
def _target_flag(target: str | None):
|
||||
return {
|
||||
"memory": "--memory",
|
||||
"backend": "--backend",
|
||||
"frontend": "--frontend",
|
||||
"ai": "--ai",
|
||||
}.get(target or "all")
|
||||
|
||||
|
||||
def cmd_start(args) -> int:
|
||||
services.cmd_start(_target_flag(args.target))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_stop(args) -> int:
|
||||
services.cmd_stop(_target_flag(args.target))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_refresh(args) -> int:
|
||||
services.cmd_stop(None)
|
||||
services.cmd_start(None)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_serve(args) -> int:
|
||||
host = args.host or settings.bind_host
|
||||
if host not in ("127.0.0.1", "localhost", "::1") and not args.allow_lan:
|
||||
print("Refusing an unauthenticated LAN bind. Add --allow-lan to acknowledge the exposure.", file=sys.stderr)
|
||||
return 2
|
||||
if _http_ok(f"http://127.0.0.1:{args.port}/"):
|
||||
print(f"Port {args.port} is already serving HTTP.", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
settings.backend_port = args.port
|
||||
settings.memory_port = args.memory_port
|
||||
settings.bind_host = host
|
||||
settings.api_url = f"http://127.0.0.1:{args.port}"
|
||||
settings.memory_url = f"http://127.0.0.1:{args.memory_port}"
|
||||
os.environ["NEXUS_BACKEND_PORT"] = str(args.port)
|
||||
os.environ["NEXUS_MEMORY_PORT"] = str(args.memory_port)
|
||||
os.environ["NEXUS_BIND_HOST"] = host
|
||||
for origin_host in ("localhost", "127.0.0.1"):
|
||||
for port in (args.port, args.memory_port):
|
||||
origin = f"http://{origin_host}:{port}"
|
||||
if origin not in config.ALLOWED_ORIGINS:
|
||||
config.ALLOWED_ORIGINS.append(origin)
|
||||
if args.allow_lan:
|
||||
os.environ.setdefault("NEXUS_ALLOWED_HOSTS", "*")
|
||||
os.environ.setdefault("NEXUS_ALLOWED_ORIGINS", "*")
|
||||
config.ALLOWED_HOSTS[:] = ["*"]
|
||||
config.ALLOWED_ORIGINS[:] = ["*"]
|
||||
|
||||
memory_proc = None
|
||||
memory_log = None
|
||||
try:
|
||||
if not args.no_memory and not _http_ok(settings.memory_url + "/"):
|
||||
log_path = settings.runtime_dir / "memory.log"
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
memory_log = open(log_path, "ab")
|
||||
memory_proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "synapse.memory.service:app",
|
||||
"--host", host, "--port", str(args.memory_port)],
|
||||
stdout=memory_log, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
|
||||
)
|
||||
print(f"Memory service starting on {host}:{args.memory_port} (log: {log_path})")
|
||||
print(f"NexusOS serving on http://{host}:{args.port}")
|
||||
import uvicorn
|
||||
uvicorn.run(
|
||||
"synapse.main:sio_app", host=host, port=args.port,
|
||||
reload=bool(args.reload and settings.source_checkout),
|
||||
log_level=args.log_level,
|
||||
)
|
||||
finally:
|
||||
if memory_proc is not None and memory_proc.poll() is None:
|
||||
memory_proc.terminate()
|
||||
try:
|
||||
memory_proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
memory_proc.kill()
|
||||
if memory_log is not None:
|
||||
memory_log.close()
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_open(args) -> int:
|
||||
if not _http_ok(settings.api_url + "/status") and not args.no_start:
|
||||
services.cmd_start(None)
|
||||
url = args.url or settings.api_url
|
||||
opener = shutil.which("termux-open-url") if _is_termux() else None
|
||||
opened = False
|
||||
if opener:
|
||||
opened = subprocess.run([opener, url], check=False).returncode == 0
|
||||
else:
|
||||
opened = webbrowser.open(url)
|
||||
print(f"{'Opened' if opened else 'NexusOS is available at'} {url}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_web(args) -> int:
|
||||
"""Preserve ``ncp web`` for desktop checkouts; wheels use the browser UI."""
|
||||
if settings.source_checkout:
|
||||
return services.cmd_web()
|
||||
return cmd_open(argparse.Namespace(url=None, no_start=False))
|
||||
|
||||
|
||||
def cmd_panel(_args) -> int:
|
||||
if not settings.source_checkout:
|
||||
print("The legacy Tk control panel is only available in a desktop source install.", file=sys.stderr)
|
||||
return 2
|
||||
return services.main(["panel"])
|
||||
|
||||
|
||||
def cmd_backup(args) -> int:
|
||||
if not settings.source_checkout:
|
||||
print("Backup is a source-checkout command; wheel state should be backed up from nexus paths.", file=sys.stderr)
|
||||
return 2
|
||||
if args.check:
|
||||
return services.sync_py("backup", "--check")
|
||||
if args.full:
|
||||
return services.sync_py("backup", "--full")
|
||||
return services.sync_py("backup")
|
||||
|
||||
|
||||
def cmd_restore(args) -> int:
|
||||
if not settings.source_checkout:
|
||||
print("Restore is a source-checkout command; reinstall the wheel and restore its state directory.", file=sys.stderr)
|
||||
return 2
|
||||
return services.cmd_restore("--check" if args.check else None)
|
||||
|
||||
|
||||
def cmd_nvidia_reqs(_args) -> int:
|
||||
if not settings.source_checkout:
|
||||
print("nvidia-reqs is only available in a desktop source install.", file=sys.stderr)
|
||||
return 2
|
||||
return subprocess.run(
|
||||
[sys.executable, str(settings.project_root / "bin" / "gen-nvidia-reqs.py")]
|
||||
).returncode
|
||||
|
||||
|
||||
def cmd_logs(args) -> int:
|
||||
keys = ("backend", "memory", "frontend") if args.target == "all" else (args.target,)
|
||||
paths = [services.SERVICES[key].log_file for key in keys]
|
||||
for path in paths:
|
||||
print(f"=== {path.name} ===")
|
||||
services._tail(path, args.lines)
|
||||
if not args.follow:
|
||||
return 0
|
||||
offsets = {path: path.stat().st_size if path.exists() else 0 for path in paths}
|
||||
try:
|
||||
while True:
|
||||
for path in paths:
|
||||
if not path.exists():
|
||||
continue
|
||||
size = path.stat().st_size
|
||||
if size < offsets[path]:
|
||||
offsets[path] = 0
|
||||
if size > offsets[path]:
|
||||
with open(path, encoding="utf-8", errors="replace") as stream:
|
||||
stream.seek(offsets[path])
|
||||
sys.stdout.write(stream.read())
|
||||
sys.stdout.flush()
|
||||
offsets[path] = stream.tell()
|
||||
time.sleep(0.5)
|
||||
except KeyboardInterrupt:
|
||||
print()
|
||||
return 130
|
||||
|
||||
|
||||
def cmd_clean(args) -> int:
|
||||
for path in (settings.runtime_dir / "pids", settings.runtime_dir / "logs"):
|
||||
if path.is_dir():
|
||||
for file in path.glob("*"):
|
||||
if file.is_file():
|
||||
file.unlink(missing_ok=True)
|
||||
for pattern in ("*.log", "*.pid"):
|
||||
for file in settings.runtime_dir.glob(pattern):
|
||||
file.unlink(missing_ok=True)
|
||||
print(f"Cleaned runtime files under {settings.runtime_dir}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_update(args) -> int:
|
||||
if settings.source_checkout:
|
||||
services.cmd_update()
|
||||
return 0
|
||||
command = [sys.executable, "-m", "pip", "install", "--upgrade", "nexusos-ai"]
|
||||
if args.pre:
|
||||
command.append("--pre")
|
||||
return subprocess.run(command).returncode
|
||||
|
||||
|
||||
def cmd_models(args) -> int:
|
||||
import httpx
|
||||
|
||||
base = settings.ollama_host.rstrip("/")
|
||||
try:
|
||||
if args.action == "list":
|
||||
response = httpx.get(base + "/api/tags", timeout=5.0)
|
||||
response.raise_for_status()
|
||||
models = response.json().get("models", [])
|
||||
if args.json:
|
||||
_emit(models, True)
|
||||
elif not models:
|
||||
print("No models installed.")
|
||||
else:
|
||||
for model in models:
|
||||
print(f"{model.get('name', ''):<36} {model.get('size', 0) / 1024**3:5.1f} GB")
|
||||
elif args.action == "available":
|
||||
services.cmd_models("available", None)
|
||||
elif args.action in ("pull", "install"):
|
||||
with httpx.stream("POST", base + "/api/pull", json={"name": args.name}, timeout=None) as response:
|
||||
response.raise_for_status()
|
||||
for line in response.iter_lines():
|
||||
if line:
|
||||
try:
|
||||
item = json.loads(line)
|
||||
status = item.get("status") or item.get("error")
|
||||
if status:
|
||||
print(status)
|
||||
except ValueError:
|
||||
print(line)
|
||||
elif args.action in ("remove", "rm"):
|
||||
response = httpx.request("DELETE", base + "/api/delete", json={"name": args.name}, timeout=30.0)
|
||||
response.raise_for_status()
|
||||
print(f"Removed {args.name}")
|
||||
return 0
|
||||
except httpx.HTTPError as exc:
|
||||
print(f"Provider request failed at {base}: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def cmd_api(args) -> int:
|
||||
from . import nexus_api
|
||||
nexus_api.BASE = args.api_url or settings.api_url
|
||||
rest = list(args.rest)
|
||||
if args.command == "chat" and rest[:1] == ["send"]:
|
||||
rest.pop(0)
|
||||
if args.command == "history" and rest[:1] == ["list"]:
|
||||
rest.pop(0)
|
||||
return nexus_api.main([args.command, *rest], prog=f"nexus {args.command}")
|
||||
|
||||
|
||||
def _add_json(parser) -> None:
|
||||
parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")
|
||||
|
||||
|
||||
def _port(value: str) -> int:
|
||||
try:
|
||||
return _validate_config("backend_port", value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise argparse.ArgumentTypeError(str(exc)) from exc
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="nexus", description="NexusOS local AI runtime and API client")
|
||||
parser.add_argument("--version", action="version", version=f"NexusOS {settings.version}")
|
||||
parser.add_argument("--api-url", help="override the NexusOS backend URL for this command")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
p = sub.add_parser("init", help="create user state and seed default playbooks"); _add_json(p); p.set_defaults(fn=cmd_init)
|
||||
p = sub.add_parser("paths", help="show resolved package and writable paths"); _add_json(p); p.set_defaults(fn=cmd_paths)
|
||||
|
||||
p = sub.add_parser("config", help="manage persistent CLI/runtime configuration")
|
||||
p.add_argument("action", choices=["list", "get", "set", "unset", "path"])
|
||||
p.add_argument("key", nargs="?"); p.add_argument("value", nargs="?"); _add_json(p); p.set_defaults(fn=cmd_config)
|
||||
|
||||
p = sub.add_parser("provider", help="configure the Ollama-compatible model provider")
|
||||
provider_sub = p.add_subparsers(dest="action", required=True)
|
||||
show = provider_sub.add_parser("show"); _add_json(show); show.set_defaults(fn=cmd_provider)
|
||||
use = provider_sub.add_parser("use"); use.add_argument("mode", choices=["local", "remote"])
|
||||
use.add_argument("--url"); _add_json(use); use.set_defaults(fn=cmd_provider)
|
||||
|
||||
p = sub.add_parser("doctor", help="check the core runtime and optional capabilities")
|
||||
p.add_argument("--fix", action="store_true"); _add_json(p); p.set_defaults(fn=cmd_doctor)
|
||||
p = sub.add_parser("status", help="show service and provider status"); _add_json(p); p.set_defaults(fn=cmd_status)
|
||||
|
||||
p = sub.add_parser("serve", help="run NexusOS in the foreground")
|
||||
p.add_argument("--host"); p.add_argument("--port", type=_port, default=settings.backend_port)
|
||||
p.add_argument("--memory-port", type=_port, default=settings.memory_port)
|
||||
p.add_argument("--no-memory", action="store_true"); p.add_argument("--allow-lan", action="store_true")
|
||||
p.add_argument("--reload", action="store_true"); p.add_argument("--log-level", default="info")
|
||||
p.set_defaults(fn=cmd_serve)
|
||||
|
||||
for name, fn, help_text in (
|
||||
("start", cmd_start, "start services in the background"),
|
||||
("stop", cmd_stop, "stop background services"),
|
||||
):
|
||||
p = sub.add_parser(name, help=help_text)
|
||||
p.add_argument("target", nargs="?", choices=["all", "backend", "memory", "frontend", "ai"], default="all")
|
||||
p.set_defaults(fn=fn)
|
||||
sub.add_parser("restart", aliases=["refresh"], help="restart all services").set_defaults(fn=cmd_refresh)
|
||||
sub.add_parser("kill", help="force-stop NexusOS-owned processes").set_defaults(fn=lambda _a: services.cmd_kill() or 0)
|
||||
|
||||
p = sub.add_parser("open", help="open the web interface")
|
||||
p.add_argument("--url"); p.add_argument("--no-start", action="store_true"); p.set_defaults(fn=cmd_open)
|
||||
sub.add_parser("web", help="legacy desktop alias for open").set_defaults(fn=cmd_web)
|
||||
sub.add_parser("panel", help="launch the legacy desktop control panel").set_defaults(fn=cmd_panel)
|
||||
p = sub.add_parser("logs", help="read or follow service logs")
|
||||
p.add_argument("target", nargs="?", choices=["all", "backend", "memory", "frontend"], default="all")
|
||||
p.add_argument("--lines", type=int, choices=range(1, 10001), default=50, metavar="1..10000")
|
||||
p.add_argument("--follow", "-f", action="store_true"); p.set_defaults(fn=cmd_logs)
|
||||
sub.add_parser("clean", help="remove runtime logs and stale PID files").set_defaults(fn=cmd_clean)
|
||||
p = sub.add_parser("update", help="update dependencies or the installed wheel"); p.add_argument("--pre", action="store_true"); p.set_defaults(fn=cmd_update)
|
||||
|
||||
p = sub.add_parser("backup", help="back up a desktop source checkout")
|
||||
p.add_argument("--full", "-f", action="store_true"); p.add_argument("--check", "-c", action="store_true")
|
||||
p.set_defaults(fn=cmd_backup)
|
||||
p = sub.add_parser("restore", help="restore a desktop source checkout")
|
||||
p.add_argument("--check", "-c", action="store_true"); p.set_defaults(fn=cmd_restore)
|
||||
sub.add_parser("nvidia-reqs", help="regenerate source-checkout NVIDIA requirements").set_defaults(fn=cmd_nvidia_reqs)
|
||||
|
||||
p = sub.add_parser("models", help="list, pull, and remove provider models")
|
||||
model_sub = p.add_subparsers(dest="action", required=True)
|
||||
item = model_sub.add_parser("list"); _add_json(item); item.set_defaults(fn=cmd_models)
|
||||
model_sub.add_parser("available", aliases=["search"]).set_defaults(fn=cmd_models, action="available")
|
||||
item = model_sub.add_parser("pull", aliases=["install"]); item.add_argument("name"); item.set_defaults(fn=cmd_models, action="pull")
|
||||
item = model_sub.add_parser("remove", aliases=["rm"]); item.add_argument("name"); item.set_defaults(fn=cmd_models, action="remove")
|
||||
|
||||
for command in ("chat", "memory", "playbook", "history"):
|
||||
p = sub.add_parser(command, add_help=False, help=f"use the {command} API from the terminal")
|
||||
p.add_argument("rest", nargs=argparse.REMAINDER)
|
||||
p.set_defaults(fn=cmd_api)
|
||||
return parser
|
||||
|
||||
|
||||
def _normalize_legacy_argv(argv) -> list[str]:
|
||||
"""Translate the old shell CLI spelling before argparse sees it."""
|
||||
normalized = list(argv or [])
|
||||
if normalized == ["help"]:
|
||||
return ["--help"]
|
||||
if normalized and normalized[0] in ("start", "stop", "logs"):
|
||||
normalized[1:] = [LEGACY_TARGETS.get(value, value) for value in normalized[1:]]
|
||||
if normalized[:2] == ["backup", "full"]:
|
||||
normalized[1] = "--full"
|
||||
elif len(normalized) > 1 and normalized[0] == "backup" and normalized[1] in ("check", "--claude"):
|
||||
normalized[1] = "--check"
|
||||
elif len(normalized) > 1 and normalized[0] == "restore":
|
||||
if normalized[1] in ("check", "--claude"):
|
||||
normalized[1] = "--check"
|
||||
elif normalized[1] in ("full", "-f", "--full"):
|
||||
normalized.pop(1)
|
||||
return normalized
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(_normalize_legacy_argv(argv))
|
||||
if args.command == "config":
|
||||
if args.action in ("get", "unset") and not args.key:
|
||||
parser.error(f"config {args.action} requires KEY")
|
||||
if args.action == "set" and (not args.key or args.value is None):
|
||||
parser.error("config set requires KEY VALUE")
|
||||
try:
|
||||
result = args.fn(args)
|
||||
return result if isinstance(result, int) else 0
|
||||
except KeyboardInterrupt:
|
||||
print()
|
||||
return 130
|
||||
|
||||
|
||||
def entrypoint() -> int:
|
||||
return main(sys.argv[1:])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(entrypoint())
|
||||
+1
-1
@@ -25,7 +25,7 @@ REM delegate to the stdlib-only bin/sync.py. Mirrors the same fallback in ncp.ps
|
||||
set "PY=%ROOT%\Promethean\Scripts\python.exe"
|
||||
if not exist "%PY%" set "PY=python"
|
||||
pushd "%ROOT%"
|
||||
"%PY%" -m management.cli %*
|
||||
"%PY%" -m nexusos_cli.cli %*
|
||||
set "NEXUS_EXIT=%ERRORLEVEL%"
|
||||
popd
|
||||
exit /b %NEXUS_EXIT%
|
||||
|
||||
@@ -1,940 +0,0 @@
|
||||
#!/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 dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from synapse.nexus_config import SOURCE_CHECKOUT, settings
|
||||
|
||||
ROOT = settings.project_root
|
||||
PID_DIR = settings.runtime_dir / "pids"
|
||||
LOG_DIR = settings.runtime_dir
|
||||
FRONTEND_DIR = settings.frontend_source_dir
|
||||
OLLAMA_BIN = ROOT / "ollama" / "bin" / ("ollama.exe" if os.name == "nt" else "ollama")
|
||||
OLLAMA_MODELS_DIR = settings.models_dir
|
||||
|
||||
WINDOWS = os.name == "nt"
|
||||
_VENV_PYTHON = ROOT / "Promethean" / ("Scripts/python.exe" if WINDOWS else "bin/python3")
|
||||
PYTHON = Path(os.getenv("NEXUS_PYTHON", "")) if os.getenv("NEXUS_PYTHON") else (
|
||||
_VENV_PYTHON if _VENV_PYTHON.exists() else Path(sys.executable)
|
||||
)
|
||||
|
||||
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:
|
||||
return None
|
||||
|
||||
|
||||
# -- services ------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class Service:
|
||||
key: str
|
||||
label: str
|
||||
port: int
|
||||
cwd: Path
|
||||
patterns: list
|
||||
_argv: object = None
|
||||
pid_file: Path = field(init=False)
|
||||
log_file: Path = field(init=False)
|
||||
|
||||
def __post_init__(self):
|
||||
self.pid_file = PID_DIR / f"{self.key}.pid"
|
||||
self.log_file = LOG_DIR / f"{self.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
|
||||
|
||||
|
||||
# Bind loopback by default: the backend REST API is unauthenticated, so
|
||||
# binding 0.0.0.0 handed the full admin+data plane to any host on the LAN. Set
|
||||
# NEXUS_BIND_HOST=0.0.0.0 to opt into LAN exposure once real auth is in place.
|
||||
BIND_HOST = settings.bind_host
|
||||
|
||||
|
||||
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", BIND_HOST,
|
||||
"--port", str(port)]
|
||||
|
||||
|
||||
SERVICES = {
|
||||
"memory": Service("memory", "NEXUS MEMORY SERVICE", settings.memory_port, settings.state_dir,
|
||||
["uvicorn synapse.memory"],
|
||||
lambda: _uvicorn("synapse.memory.service:app", settings.memory_port)),
|
||||
"backend": Service("backend", "NEXUS BACKEND SERVICE", settings.backend_port, settings.state_dir,
|
||||
["uvicorn synapse.main"],
|
||||
lambda: _uvicorn("synapse.main:sio_app", settings.backend_port)),
|
||||
"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()
|
||||
if pid is None:
|
||||
return False
|
||||
if ps is not None:
|
||||
return ps.pid_exists(pid)
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
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:
|
||||
if ps is not None:
|
||||
cmd = " ".join(ps.Process(pid).cmdline())
|
||||
elif os.name != "nt":
|
||||
cmd = (Path(f"/proc/{pid}/cmdline").read_bytes()
|
||||
.replace(b"\0", b" ").decode(errors="replace"))
|
||||
else:
|
||||
return False
|
||||
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 its services and
|
||||
# only then waits on each, so by the time a service'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
|
||||
if ps is None:
|
||||
if os.name == "nt":
|
||||
return 0
|
||||
for entry in Path("/proc").iterdir():
|
||||
if not entry.name.isdigit() or int(entry.name) == me:
|
||||
continue
|
||||
try:
|
||||
cmd = (entry / "cmdline").read_bytes().replace(b"\0", b" ").decode(errors="replace")
|
||||
if any(pattern in cmd for pattern in patterns):
|
||||
os.kill(int(entry.name), 9 if force else 15)
|
||||
hit += 1
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return hit
|
||||
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()
|
||||
if ps is None:
|
||||
return False
|
||||
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:
|
||||
if ps is not None:
|
||||
proc = ps.Process(pid)
|
||||
for child in proc.children(recursive=True):
|
||||
try:
|
||||
child.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
proc.terminate()
|
||||
elif os.name != "nt":
|
||||
os.kill(pid, 15)
|
||||
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."""
|
||||
if not settings.manage_ollama:
|
||||
running = http_ok(settings.ollama_host.rstrip("/") + "/api/tags", timeout=3)
|
||||
print(
|
||||
f"REMOTE OLLAMA {'REACHABLE' if running else 'UNREACHABLE'} "
|
||||
f"({settings.ollama_host})"
|
||||
)
|
||||
return
|
||||
print("Starting OLLAMA...")
|
||||
url = settings.api_url + "/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."""
|
||||
if not settings.manage_ollama:
|
||||
print(f"REMOTE OLLAMA IS EXTERNALLY MANAGED ({settings.ollama_host})")
|
||||
return
|
||||
req = urllib.request.Request(settings.api_url + "/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 ("--backend", "-b"):
|
||||
launch(SERVICES["backend"]); wait_for_port(SERVICES["backend"])
|
||||
start_ollama()
|
||||
elif target in ("--frontend", "-f"):
|
||||
if not SOURCE_CHECKOUT:
|
||||
print("Vite source is unavailable in wheel installs; the backend serves the bundled UI.")
|
||||
return
|
||||
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["backend"])
|
||||
wait_for_port(SERVICES["backend"])
|
||||
t_services = time.perf_counter()
|
||||
start_ollama(background=True)
|
||||
if SOURCE_CHECKOUT and not WINDOWS:
|
||||
launch(SERVICES["frontend"])
|
||||
t_bg = time.perf_counter()
|
||||
print("\nBoot timing:")
|
||||
print(f" 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 ("--backend", "-b"):
|
||||
stop_ollama(); stop_service(SERVICES["backend"])
|
||||
elif target in ("--frontend", "-f"):
|
||||
stop_service(SERVICES["frontend"])
|
||||
elif target in ("--ai", "-a"):
|
||||
stop_ollama()
|
||||
elif target in (None, "", "all"):
|
||||
stop_ollama()
|
||||
stop_service(SERVICES["backend"])
|
||||
stop_service(SERVICES["frontend"])
|
||||
else:
|
||||
show_help()
|
||||
|
||||
|
||||
def cmd_kill() -> None:
|
||||
print("Force-killing all Nexus processes...")
|
||||
targets = [
|
||||
(settings.backend_port, "SYNAPSE"),
|
||||
(settings.memory_port, "MEMORY"),
|
||||
(5173, "INTERFACE"),
|
||||
]
|
||||
patterns = ["uvicorn synapse", "npm run dev", "vite --host"]
|
||||
if settings.manage_ollama:
|
||||
targets.append((11434, "OLLAMA"))
|
||||
patterns.append("ollama serve")
|
||||
for port, name in targets:
|
||||
if kill_port(port):
|
||||
print(f" KILLED: {name} (:{port})")
|
||||
else:
|
||||
print(f" NOT RUNNING: {name} (:{port})")
|
||||
kill_matching(patterns, 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"])
|
||||
print("\nFrontend:")
|
||||
one("Vite ", SERVICES["frontend"])
|
||||
print("\nModel server:")
|
||||
running = http_ok(settings.ollama_host.rstrip("/") + "/api/tags")
|
||||
mode = "remote" if not settings.manage_ollama else "local"
|
||||
print(f" Ollama ({mode}) : {'RUNNING' if running else 'STOPPED'} ({settings.ollama_host})")
|
||||
|
||||
|
||||
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 = {"backend": "BACKEND", "frontend": "FRONTEND"}
|
||||
|
||||
|
||||
def cmd_logs(target) -> None:
|
||||
named = {"--frontend": "frontend", "-f": "frontend",
|
||||
"--backend": "backend", "-b": "backend"}
|
||||
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(("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|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...")
|
||||
mark((ROOT / "synapse" / "memory").is_dir(),
|
||||
"Memory module directory found", "Missing memory module directory")
|
||||
mark(importable("from synapse.memory.curator import extract_for_conversation"),
|
||||
"Memory curator importable", "Memory curator 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_upgrade() -> int:
|
||||
"""Pull + rebuild + restart the backend. This is what the web UI's "install
|
||||
update" button runs, so it must be spawned DETACHED from the backend - it
|
||||
stops the very server that asked for it.
|
||||
|
||||
Ollama is deliberately left alone (stop_service, not cmd_stop): it is a
|
||||
separate process on :11434, the restore does not touch a present binary, and
|
||||
reloading a multi-GB model is the slowest part of a restart.
|
||||
"""
|
||||
print("Stopping backend...")
|
||||
stop_service(SERVICES["backend"])
|
||||
# --no-desktop: an in-app update should not rewrite XFCE panels/theme.
|
||||
rc = sync_py("restore", "--no-desktop")
|
||||
print("\nRestarting backend...")
|
||||
launch(SERVICES["backend"])
|
||||
ok = wait_for_port(SERVICES["backend"])
|
||||
print("Backend is back up." if ok else "Backend did not come back - see runtime/backend.log")
|
||||
return rc if ok else 1
|
||||
|
||||
|
||||
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(settings.ollama_host.rstrip("/") + "/api/tags"):
|
||||
print("Ollama is not running. Start the backend first with: ncp start -b")
|
||||
return
|
||||
raw = urllib.request.urlopen(settings.ollama_host.rstrip("/") + "/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
|
||||
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 <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["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 (backend + frontend)
|
||||
--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
|
||||
--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
|
||||
--frontend,-f Frontend logs
|
||||
--backend, -b Backend logs
|
||||
|
||||
doctor [--fix] Run Nexus diagnostics (--fix applies safe repairs)
|
||||
update Update Nexus dependencies
|
||||
upgrade Pull, rebuild and restart the backend (what the UI's update button runs)
|
||||
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(fix="--fix" in rest)
|
||||
elif cmd == "update":
|
||||
cmd_update()
|
||||
elif cmd == "upgrade":
|
||||
return cmd_upgrade()
|
||||
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)
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# ncp - Linux entry point. The CLI itself is management/cli.py, which runs
|
||||
# ncp - Linux entry point. The CLI itself is nexusos_cli/cli.py, which runs
|
||||
# unchanged on Windows too (see management/ncp.cmd); 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.
|
||||
@@ -14,4 +14,4 @@ PY="$NEXUS_ROOT/Promethean/bin/python3"
|
||||
[ -x "$PY" ] || PY=python3
|
||||
|
||||
cd "$NEXUS_ROOT"
|
||||
exec "$PY" -m management.cli "$@"
|
||||
exec "$PY" -m nexusos_cli.cli "$@"
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Backend for `ncp` subcommands (chat/memory/playbook/history).
|
||||
|
||||
Not a second CLI — `ncp` is the only entrypoint. This just hits the same REST
|
||||
API the web UI uses, so the terminal matches the general features. httpx and
|
||||
argparse only (both already present); no curses, no framework.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
import httpx
|
||||
|
||||
from synapse.nexus_config import settings
|
||||
|
||||
BASE = settings.api_url
|
||||
|
||||
|
||||
def _client():
|
||||
return httpx.Client(base_url=BASE, timeout=None)
|
||||
|
||||
|
||||
def _die_if_down(exc: Exception):
|
||||
if isinstance(exc, (httpx.ConnectError, httpx.ConnectTimeout)):
|
||||
sys.exit(f"Backend not reachable at {BASE}. Start it: ncp start -b")
|
||||
raise exc
|
||||
|
||||
|
||||
def iter_chunks(lines):
|
||||
"""Yield ('kind', payload) from raw SSE lines. kind is 'chunk' for reply
|
||||
text, else the event name ('meta'/'done'/'error'/'title'/...). Pure so the
|
||||
stream parsing is unit-testable without a live server (see test_nexus_api.py)."""
|
||||
event = "message"
|
||||
for line in lines:
|
||||
if line == "": # blank line ends an event block
|
||||
event = "message"
|
||||
continue
|
||||
if line.startswith("event:"):
|
||||
event = line[6:].strip()
|
||||
elif line.startswith("data:"):
|
||||
data = line[5:].strip()
|
||||
if event in ("message", ""):
|
||||
yield "chunk", json.loads(data) # server json-encodes each token
|
||||
else:
|
||||
yield event, data
|
||||
|
||||
|
||||
def cmd_chat(args):
|
||||
body = {"message": " ".join(args.message)}
|
||||
if args.model:
|
||||
body["model"] = args.model
|
||||
try:
|
||||
with _client() as c, c.stream("POST", "/chat/stream", json=body) as r:
|
||||
r.raise_for_status()
|
||||
for kind, payload in iter_chunks(r.iter_lines()):
|
||||
if kind == "chunk":
|
||||
sys.stdout.write(payload)
|
||||
sys.stdout.flush()
|
||||
elif kind == "escalating":
|
||||
sys.stderr.write("\n[escalating to Claude…]\n")
|
||||
elif kind == "error":
|
||||
sys.exit("\n" + json.loads(payload).get("detail", "chat failed"))
|
||||
elif kind == "done":
|
||||
break
|
||||
print()
|
||||
except Exception as e:
|
||||
_die_if_down(e)
|
||||
|
||||
|
||||
def cmd_memory(args):
|
||||
try:
|
||||
with _client() as c:
|
||||
if args.action == "list":
|
||||
items = c.get("/memory").json()["items"]
|
||||
if not items:
|
||||
print("No memory facts.")
|
||||
return
|
||||
section = None
|
||||
for m in items:
|
||||
if m["section"] != section:
|
||||
section = m["section"]
|
||||
print(f"\n## {section}")
|
||||
print(f" {m['id'][:8]} {m['text']}")
|
||||
elif args.action == "add":
|
||||
m = c.post("/memory", json={"text": " ".join(args.rest),
|
||||
"section": args.section}).json()
|
||||
print(f"added {m['id'][:8]} to {m['section']}")
|
||||
elif args.action == "rm":
|
||||
c.delete(f"/memory/{args.rest[0]}").raise_for_status()
|
||||
print("deleted")
|
||||
except Exception as e:
|
||||
_die_if_down(e)
|
||||
|
||||
|
||||
def cmd_playbook(args):
|
||||
try:
|
||||
with _client() as c:
|
||||
if args.action == "list":
|
||||
pbs = c.get("/playbooks").json()["playbooks"]
|
||||
for i, p in enumerate(pbs):
|
||||
mark = "* " if i == 0 else " " # first = active system prompt
|
||||
tags = f" [{', '.join(p['tags'])}]" if p["tags"] else ""
|
||||
print(f"{mark}{p['id'][:8]} {p['title']}{tags}")
|
||||
elif args.action == "show":
|
||||
p = c.get(f"/playbooks/{args.rest[0]}").json()
|
||||
print(f"# {p['title']}\n\nGoal: {p['goal']}\n\n{p['instructions']}")
|
||||
except Exception as e:
|
||||
_die_if_down(e)
|
||||
|
||||
|
||||
def cmd_history(args):
|
||||
try:
|
||||
with _client() as c:
|
||||
params = {"q": args.query} if args.query else {}
|
||||
convs = c.get("/conversations", params=params).json()["conversations"]
|
||||
for cv in convs[:args.limit]:
|
||||
title = cv.get("title") or cv.get("preview") or "(untitled)"
|
||||
print(f" {cv['id'][:8]} {title}")
|
||||
except Exception as e:
|
||||
_die_if_down(e)
|
||||
|
||||
|
||||
def main(argv=None, prog="nexus"):
|
||||
p = argparse.ArgumentParser(prog=prog)
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
c = sub.add_parser("chat"); c.add_argument("message", nargs="+")
|
||||
c.add_argument("--model"); c.set_defaults(fn=cmd_chat)
|
||||
|
||||
m = sub.add_parser("memory"); m.add_argument("action", choices=["list", "add", "rm"])
|
||||
m.add_argument("rest", nargs="*"); m.add_argument("--section", default="General")
|
||||
m.set_defaults(fn=cmd_memory)
|
||||
|
||||
pb = sub.add_parser("playbook"); pb.add_argument("action", choices=["list", "show"])
|
||||
pb.add_argument("rest", nargs="*"); pb.set_defaults(fn=cmd_playbook)
|
||||
|
||||
h = sub.add_parser("history"); h.add_argument("query", nargs="?")
|
||||
h.add_argument("--limit", type=int, default=20); h.set_defaults(fn=cmd_history)
|
||||
|
||||
args = p.parse_args(argv)
|
||||
result = args.fn(args)
|
||||
return result if isinstance(result, int) else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,27 +0,0 @@
|
||||
"""Pin the SSE parser in nexus_api. Run: python management/test_nexus_api.py"""
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
try:
|
||||
from management.nexus_api import iter_chunks
|
||||
except ModuleNotFoundError: # direct: python management/test_nexus_api.py
|
||||
from nexus_api import iter_chunks
|
||||
|
||||
# A realistic /chat/stream frame: two token chunks, a meta block, then done.
|
||||
lines = [
|
||||
'data: "Hello"', "",
|
||||
'data: " world"', "",
|
||||
"event: meta", 'data: {"model":"mistral"}', "",
|
||||
"event: done", "data: {}", "",
|
||||
]
|
||||
out = list(iter_chunks(lines))
|
||||
assert out == [
|
||||
("chunk", "Hello"),
|
||||
("chunk", " world"),
|
||||
("meta", '{"model":"mistral"}'),
|
||||
("done", "{}"),
|
||||
], out
|
||||
# error frame surfaces as its own kind, not a chunk
|
||||
assert list(iter_chunks(["event: error", 'data: {"detail":"boom"}'])) == [
|
||||
("error", '{"detail":"boom"}')
|
||||
]
|
||||
print("ok")
|
||||
Reference in New Issue
Block a user