Files
NexusOS/nexusos_cli/cli.py
T
Jon Wingender 35f7461ca4
package / wheel (pull_request) Waiting to run
fix(tui): stop a stream outlived by /new from leaking into the next conversation
_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.
2026-08-26 14:01:19 -05:00

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())