package / wheel (pull_request) Waiting to run
_finish_stream appended the completed reply to self.history - whatever list that name currently pointed at - not to the conversation the stream was actually answering. /new reassigns self.history to a fresh list; a stream still running when that happens finished by silently appending the old conversation's trailing reply onto the new one, which then rides along in that new conversation's next /chat/stream history payload. The conversation_id was already captured by closure for this exact reason (see the tool-denial path); self.history needed the same treatment.
849 lines
32 KiB
Python
849 lines
32 KiB
Python
"""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",
|
|
"bind_host": "text",
|
|
"backend_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 = {
|
|
"-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",
|
|
1 <= settings.backend_port <= 65535,
|
|
f"backend={settings.backend_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)
|
|
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", "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", "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 cmd_tui(args) -> int:
|
|
"""Interactive Hermes/OpenClaw-style chat TUI (requires nexusos-ai[tui])."""
|
|
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
|
print(
|
|
"The TUI needs a terminal. Use: nexus chat send \"…\"\n"
|
|
"Or run `nexus` in an interactive shell.",
|
|
file=sys.stderr,
|
|
)
|
|
return 2
|
|
try:
|
|
from .tui_app import run_tui
|
|
except ImportError as exc:
|
|
print(str(exc), file=sys.stderr)
|
|
return 2
|
|
api = getattr(args, "api_url", None) or settings.api_url
|
|
return run_tui(api_url=api)
|
|
|
|
|
|
def _target_flag(target: str | None):
|
|
return {
|
|
"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.bind_host = host
|
|
settings.api_url = f"http://127.0.0.1:{args.port}"
|
|
os.environ["NEXUS_BACKEND_PORT"] = str(args.port)
|
|
os.environ["NEXUS_BIND_HOST"] = host
|
|
for origin_host in ("localhost", "127.0.0.1"):
|
|
origin = f"http://{origin_host}:{args.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)
|
|
origin = f"http://{_origin_host(name)}:{args.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."
|
|
)
|
|
|
|
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,
|
|
)
|
|
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", "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. "
|
|
"With no subcommand, opens the interactive TUI (needs nexusos-ai[tui])."
|
|
),
|
|
)
|
|
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")
|
|
# Bare `nexus` → TUI. Subcommands remain for scripts and one-shots.
|
|
sub = parser.add_subparsers(dest="command", required=False)
|
|
|
|
p = sub.add_parser("tui", help="interactive chat TUI (default when no subcommand)")
|
|
p.set_defaults(fn=cmd_tui)
|
|
|
|
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("--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", "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", "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()
|
|
argv = _normalize_legacy_argv(argv)
|
|
args = parser.parse_args(argv)
|
|
if not getattr(args, "command", None):
|
|
# Bare `nexus` / `ncp` / `nexusos` → interactive TUI.
|
|
args.command = "tui"
|
|
args.fn = cmd_tui
|
|
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())
|