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
@@ -0,0 +1,849 @@
|
||||
"""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 = {
|
||||
"home": "path",
|
||||
"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
|
||||
|
||||
|
||||
# Keys whose value decides where the SQLite database is looked up. Changing one
|
||||
# does not move the file, so the next start would quietly open a fresh, empty
|
||||
# database and the user's conversations and memories would look deleted.
|
||||
_DB_LOCATION_KEYS = ("memory_db", "memory_dir", "data_dir", "home")
|
||||
|
||||
|
||||
def _relocation_warning(key: str, value) -> str | None:
|
||||
"""Warn when a config change points the database somewhere with no data."""
|
||||
if key not in _DB_LOCATION_KEYS:
|
||||
return None
|
||||
current = settings.memory_db
|
||||
if not current.is_file():
|
||||
return None
|
||||
if key == "memory_db":
|
||||
new_db = Path(str(value))
|
||||
elif key == "memory_dir":
|
||||
new_db = Path(str(value)) / current.name
|
||||
else:
|
||||
# data_dir/home only decide the DB location when nothing more specific
|
||||
# does, and only in the layout where the DB actually sits under them -
|
||||
# a source checkout keeps it in synapse/memory/ regardless.
|
||||
existing = config.read_user_config()
|
||||
if "memory_dir" in existing or "memory_db" in existing:
|
||||
return None
|
||||
anchor = settings.data_dir if key == "data_dir" else settings.state_dir
|
||||
try:
|
||||
tail = current.resolve().relative_to(anchor.resolve())
|
||||
except ValueError:
|
||||
return None
|
||||
new_db = Path(str(value)) / tail
|
||||
if new_db.resolve() == current.resolve() or new_db.is_file():
|
||||
return None
|
||||
return (
|
||||
f"{current} holds your existing conversations and memories, but this "
|
||||
f"change points NexusOS at {new_db}, which does not exist yet - it will "
|
||||
"start with an empty database. Stop NexusOS and move the .db (plus any "
|
||||
"-wal/-shm files) to the new path to keep your history."
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
payload = {"updated": args.key, "value": values[args.key], "restart_required": True}
|
||||
moved = _relocation_warning(args.key, values[args.key])
|
||||
if moved:
|
||||
payload["warning"] = moved
|
||||
config.write_user_config(values)
|
||||
_emit(payload, args.json)
|
||||
if moved and not args.json:
|
||||
print(f"\nWARNING: {moved}", file=sys.stderr)
|
||||
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"),
|
||||
("web search", "duckduckgo_search"),
|
||||
):
|
||||
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 cmd_monitor(args) -> int:
|
||||
from .monitor import run_monitor
|
||||
return run_monitor(
|
||||
interval=getattr(args, "interval", 1.5),
|
||||
once=bool(getattr(args, "once", False) or getattr(args, "json", False)),
|
||||
json_output=bool(getattr(args, "json", False)),
|
||||
)
|
||||
|
||||
|
||||
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 _lan_hostnames(host: str) -> list[str]:
|
||||
"""Every name/address a --allow-lan bind should accept in a Host header.
|
||||
|
||||
A wildcard bind answers on all interfaces, so enumerate them; an explicit
|
||||
address answers only as itself. The machine hostname comes along because
|
||||
that is what people actually type."""
|
||||
import socket
|
||||
|
||||
names: list[str] = []
|
||||
|
||||
def add(value: str) -> None:
|
||||
if value and value not in names:
|
||||
names.append(value)
|
||||
|
||||
if host in ("0.0.0.0", "::", "*"):
|
||||
hostname = socket.gethostname()
|
||||
add(hostname)
|
||||
add(hostname.split(".")[0] + ".local")
|
||||
for family in (socket.AF_INET, socket.AF_INET6):
|
||||
try:
|
||||
for info in socket.getaddrinfo(hostname, None, family):
|
||||
add(info[4][0])
|
||||
except OSError:
|
||||
pass
|
||||
# getaddrinfo(hostname) misses the routable address on hosts that map
|
||||
# their own name to loopback; a connectionless UDP socket finds it.
|
||||
for probe, family in (("8.8.8.8", socket.AF_INET), ("2001:4860:4860::8888", socket.AF_INET6)):
|
||||
sock = socket.socket(family, socket.SOCK_DGRAM)
|
||||
try:
|
||||
sock.connect((probe, 80))
|
||||
add(sock.getsockname()[0])
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
sock.close()
|
||||
else:
|
||||
add(host.strip("[]"))
|
||||
# A Host header carries an IPv6 literal bracketed; allow both spellings so
|
||||
# the check matches however the client wrote it.
|
||||
for value in list(names):
|
||||
if ":" in value:
|
||||
add(f"[{value}]")
|
||||
return names
|
||||
|
||||
|
||||
def _origin_host(name: str) -> str:
|
||||
"""Origin-safe spelling: IPv6 literals must be bracketed in a URL."""
|
||||
if ":" in name and not name.startswith("["):
|
||||
return f"[{name}]"
|
||||
return name
|
||||
|
||||
|
||||
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:
|
||||
# Widen to the addresses this bind actually answers on - NOT "*".
|
||||
# ALLOWED_HOSTS drives TrustedHostMiddleware, which is the DNS-rebinding
|
||||
# defense: with "*" any site the user browses could resolve a name it
|
||||
# controls to this machine and drive the unauthenticated API. Naming the
|
||||
# real addresses keeps that check doing its job. An explicitly exported
|
||||
# NEXUS_ALLOWED_HOSTS still wins, for anyone who needs the old blanket.
|
||||
names = _lan_hostnames(host)
|
||||
for name in names:
|
||||
if name not in config.ALLOWED_HOSTS:
|
||||
config.ALLOWED_HOSTS.append(name)
|
||||
for port in (args.port, args.memory_port):
|
||||
origin = f"http://{_origin_host(name)}:{port}"
|
||||
if origin not in config.ALLOWED_ORIGINS:
|
||||
config.ALLOWED_ORIGINS.append(origin)
|
||||
os.environ.setdefault("NEXUS_ALLOWED_HOSTS", ",".join(config.ALLOWED_HOSTS))
|
||||
os.environ.setdefault("NEXUS_ALLOWED_ORIGINS", ",".join(config.ALLOWED_ORIGINS))
|
||||
print(
|
||||
"LAN exposure enabled for: " + ", ".join(names)
|
||||
+ "\nThe REST API is unauthenticated - anyone who can reach this port"
|
||||
" has full admin and data access."
|
||||
)
|
||||
|
||||
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("monitor", help="ASCII dashboard for services, resources, and tool stats")
|
||||
p.add_argument("--once", action="store_true", help="print one frame and exit")
|
||||
p.add_argument("--interval", type=float, default=1.5, help="refresh seconds (live mode)")
|
||||
_add_json(p)
|
||||
p.set_defaults(fn=cmd_monitor)
|
||||
|
||||
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"]
|
||||
# start/stop only. `logs` registers -f as the short form of --follow, so
|
||||
# translating it here would silently rewrite `logs -f` to `logs frontend`
|
||||
# - a tail of the wrong file instead of a follow, with no error.
|
||||
if normalized and normalized[0] in ("start", "stop"):
|
||||
normalized[1:] = [LEGACY_TARGETS.get(value, value) for value in normalized[1:]]
|
||||
elif normalized[:1] == ["logs"]:
|
||||
normalized[1:] = [
|
||||
value if value in ("-f", "--follow") else 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())
|
||||
Reference in New Issue
Block a user