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