feat: add portable NexusOS CLI and packaging

This commit is contained in:
2026-08-26 08:11:39 -05:00
parent 2ebe93b4f7
commit d579502a5b
21 changed files with 1624 additions and 94 deletions
+68
View File
@@ -0,0 +1,68 @@
name: package
on:
push:
branches: [main, code-preview]
tags: ["v*"]
pull_request:
jobs:
wheel:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: interface/web/package-lock.json
- name: Build and test web UI
working-directory: interface/web
run: |
npm ci
npm run lint
npm test
npm run build
- uses: actions/setup-python@v5
with:
python-version: "3.13"
cache: pip
- name: Test Python runtime
run: |
python -m pip install -e ".[dev]"
python -m pytest -q tests management
bash -n scripts/install-termux.sh
- name: Build wheel and sdist
run: |
python -m build
python -m twine check dist/*
- name: Verify clean wheel install
run: |
python -m venv "$RUNNER_TEMP/nexus-wheel"
"$RUNNER_TEMP/nexus-wheel/bin/python" -m pip install dist/*.whl
cd "$RUNNER_TEMP"
export NEXUS_HOME="$RUNNER_TEMP/nexus-home"
export NEXUS_CONFIG_DIR="$RUNNER_TEMP/nexus-config"
"$RUNNER_TEMP/nexus-wheel/bin/nexus" init --json
"$RUNNER_TEMP/nexus-wheel/bin/nexus" doctor --json
"$RUNNER_TEMP/nexus-wheel/bin/python" -c "from synapse.main import sio_app; assert sio_app"
- uses: actions/upload-artifact@v4
with:
name: nexusos-python-dist
path: dist/*
- name: Publish tagged release to PyPI
if: startsWith(gitea.ref, 'refs/tags/v')
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
run: python -m twine upload --non-interactive dist/*
+3
View File
@@ -2,6 +2,9 @@ Promethean/
ollama/
interface/web/node_modules/
interface/web/dist/
/.build-check/
/dist/
/*.egg-info/
runtime/
__pycache__/
*.pyc
+31 -5
View File
@@ -5,8 +5,10 @@
# NexusOS
**A local-first AI assistant platform.** Runs entirely on your machine — a
Python/FastAPI backend, a bundled Ollama instance for inference, persistent
memory, and a React frontend. No external AI provider is called.
Python/FastAPI backend, an Ollama-compatible endpoint for inference, a
persistent memory service, and a React frontend. Ollama is local by default;
Termux and container installs can explicitly point at a separately managed
endpoint.
</div>
@@ -15,10 +17,12 @@ memory, and a React frontend. No external AI provider is called.
## What it is
NexusOS ("Nexus") is a self-hosted assistant you actually own. All inference
runs through a **locally bundled Ollama** on `localhost`; conversations, facts,
and settings live in local SQLite. It ships with desktop branding (XFCE theme,
runs through **Ollama** on `localhost` by default; conversations, facts, and
settings live in local SQLite. It ships with desktop branding (XFCE theme,
icons, boot splash) so it can be run as a full assistant environment on Linux,
not just a web app.
not just a web app. A remote Ollama-compatible URL is an explicit configuration
option for lightweight clients; NexusOS never starts or stops that remote
process.
Chat with vision and voice, persistent memory, tool-using playbooks, document
RAG scoped to Projects, gated action tools, and full model management — see
@@ -88,6 +92,28 @@ NexusOS runs **single-process**: the backend on `:8000` serves the built web UI
itself, so there's no separate frontend server at runtime. Ollama is started
manually from the app (**Start AI** in the sidebar), not at boot.
### Python package and portable CLI
The portable package installs `nexus`, `ncp`, and `nexusos` as equivalent
commands. From a checkout today:
```bash
python -m pip install -e ".[standard]"
nexus init
nexus doctor
nexus serve
```
After a package release, the install becomes `python -m pip install
"nexusos-ai[standard]"`. The wheel includes the compiled web UI and default
playbooks; it keeps writable state outside `site-packages`. See
[the CLI reference](docs/CLI.md) for commands, configuration, and dependency
profiles.
Termux uses the base package with a remote Ollama-compatible provider. Its
bootstrap and the current Android native-wheel gate are documented in
[the Termux guide](docs/TERMUX.md).
### Linux
Nexus was built using an Apple T2 computer running Linux Mint XFCE. The desktop
+99
View File
@@ -0,0 +1,99 @@
# NexusOS CLI
The Python package installs three equivalent command names: `nexus`, `ncp`,
and `nexusos`. New documentation uses `nexus`; `ncp` remains available for
existing desktop installs and scripts. Legacy spellings such as `ncp web`,
`ncp start -b`, `ncp refresh`, `ncp backup`, and `ncp restore` remain supported;
checkout-specific operations report a clear error when invoked from a wheel.
## Install
From a source checkout:
```bash
python -m pip install -e ".[standard]"
nexus init
nexus doctor
```
From the package index after a release is published:
```bash
python -m pip install "nexusos-ai[standard]"
nexus init
nexus serve
```
The base install contains the backend, memory service, compiled web UI, CLI,
and seed playbooks. Extras keep platform-sensitive dependencies optional:
- `standard`: documents, vector search, and process control
- `documents`: PDF and DOCX ingestion
- `vector`: sqlite-vec semantic indexes
- `voice`: local faster-whisper transcription
- `process`: psutil-backed process and port inspection
- `desktop`: desktop process support and Windows pywebview
## Common commands
```text
nexus init Create writable state and seed playbooks
nexus doctor [--fix] [--json] Diagnose the install and provider
nexus paths [--json] Show package, state, and asset locations
nexus status [--json] Show services and provider reachability
nexus serve Run backend + memory in the foreground
nexus start|stop|restart Manage background services
nexus open Open the compiled web interface
nexus logs [service] --follow Tail service logs
nexus models list|pull|remove Manage Ollama-compatible models
nexus config list|get|set|unset Manage persistent settings
```
API commands are also available directly:
```bash
nexus chat send "Hello"
nexus history list
nexus memory list
nexus playbook list
```
Run `nexus COMMAND --help` for command-specific arguments.
## Providers
Local desktop installs can allow NexusOS to start and stop a local Ollama:
```bash
nexus provider use local
```
For Termux, containers, or a separate inference machine, configure a remote
Ollama-compatible endpoint. NexusOS probes it but never manages its process:
```bash
nexus provider use remote --url http://192.168.1.20:11434
nexus provider show --json
```
## State and configuration
Installed wheels never write into `site-packages`. Writable files use the
platform data directory, while configuration uses the platform config
directory. Inspect the exact locations with `nexus paths`.
Environment variables override persisted settings. The most useful are:
```text
NEXUS_HOME Override the complete writable state root
NEXUS_CONFIG_DIR Override the config directory
NEXUS_PROVIDER ollama or ollama-remote
NEXUS_PROVIDER_URL Ollama-compatible API base URL
NEXUS_BIND_HOST Backend bind address (loopback by default)
NEXUS_BACKEND_PORT Backend/web port (default 8000)
NEXUS_MEMORY_PORT Memory service port (default 8001)
```
The REST APIs are unauthenticated. `nexus serve` refuses non-loopback binds
unless `--allow-lan` is given; that flag is an explicit acknowledgement, not
an authentication layer.
+49
View File
@@ -0,0 +1,49 @@
# Termux installation path
NexusOS is packaged so its Python runtime, memory database, playbooks, and
compiled web UI can run without a source checkout or Node.js. Inference is
configured separately through an Ollama-compatible HTTP endpoint; NexusOS does
not attempt to manage that remote process.
## Bootstrap
After `nexusos-ai` and a compatible Android `pydantic-core` wheel are published:
```bash
curl -fsSLO https://git.enderofwings.com/enderofwings/NexusOS/raw/branch/main/scripts/install-termux.sh
chmod +x install-termux.sh
NEXUS_ANDROID_WHEEL_INDEX=https://packages.example.invalid/android/simple \
./install-termux.sh
```
For a local release artifact, pass the wheel path or URL as the first argument:
```bash
NEXUS_ANDROID_WHEEL_INDEX=https://packages.example.invalid/android/simple \
./scripts/install-termux.sh ./dist/nexusos_ai-1.0.0-py3-none-any.whl
```
Then configure inference and serve the UI:
```bash
nexus provider use remote --url http://192.168.1.20:11434
nexus serve
termux-open-url http://127.0.0.1:8000
```
## Native wheel gate
Current Termux Python is 3.14, so Pydantic 1 is not a safe fallback. Pydantic 2
depends on the Rust-based `pydantic-core`. PyPI publishes Linux, macOS, Windows,
and WebAssembly wheels but no Android wheel, while the current Termux Rust
package cannot build common Rust extensions on-device.
The bootstrap script therefore requires a binary `pydantic-core` and accepts a
trusted PEP 503 wheel index through `NEXUS_ANDROID_WHEEL_INDEX`. It fails early
with the detected Python ABI and CPU when that artifact is missing. The release
pipeline can publish the pure NexusOS wheel today; an Android wheel job/index is
the remaining prerequisite for a one-line public Termux install.
Do not work around this by downloading an unverified binary or by exposing the
NexusOS server with `--allow-lan`. Keep the UI on loopback and let the Android
browser connect to `127.0.0.1`.
+1
View File
@@ -0,0 +1 @@
"""NexusOS command-line and desktop management helpers."""
+710
View File
@@ -0,0 +1,710 @@
"""Portable NexusOS command line used by the ``nexus`` and ``ncp`` scripts."""
from __future__ import annotations
import argparse
import importlib.util
import json
import os
import shutil
import subprocess
import sys
import time
import urllib.error
import urllib.request
import webbrowser
from pathlib import Path
from urllib.parse import urlparse
from synapse import nexus_config as config
from synapse.nexus_config import settings
from . import ncp as services
CONFIG_SCHEMA = {
"api_url": "url",
"memory_url": "url",
"bind_host": "text",
"backend_port": "port",
"memory_port": "port",
"provider": "provider",
"provider_url": "url",
"provider_timeout": "positive_int",
"data_dir": "path",
"models_dir": "path",
"runtime_dir": "path",
"memory_dir": "path",
"memory_db": "path",
}
LEGACY_TARGETS = {
"-m": "memory",
"--memory": "memory",
"-b": "backend",
"--backend": "backend",
"-f": "frontend",
"--frontend": "frontend",
"-a": "ai",
"--ai": "ai",
}
def _emit(payload, json_output: bool = False) -> None:
if json_output:
print(json.dumps(payload, indent=2, sort_keys=True))
elif isinstance(payload, str):
print(payload)
else:
for key, value in payload.items():
print(f"{key}: {value}")
def _http_ok(url: str, timeout: float = 1.0) -> bool:
try:
urllib.request.urlopen(url, timeout=timeout).read(1)
return True
except urllib.error.HTTPError:
return True
except Exception:
return False
def _is_termux() -> bool:
prefix = os.getenv("PREFIX", "")
return "com.termux" in prefix or bool(os.getenv("TERMUX_VERSION"))
def _validate_config(key: str, raw: str):
kind = CONFIG_SCHEMA[key]
value = raw.strip()
if kind == "url":
parsed = urlparse(value)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
raise ValueError(f"{key} must be an http(s) URL")
return value.rstrip("/")
if kind == "port":
number = int(value)
if not 1 <= number <= 65535:
raise ValueError(f"{key} must be between 1 and 65535")
return number
if kind == "positive_int":
number = int(value)
if number <= 0:
raise ValueError(f"{key} must be greater than zero")
return number
if kind == "provider":
if value not in ("ollama", "ollama-remote"):
raise ValueError("provider must be ollama or ollama-remote")
return value
if kind == "path":
return str(Path(value).expanduser().resolve())
if not value:
raise ValueError(f"{key} cannot be empty")
return value
def cmd_init(args) -> int:
config.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
copied = list(config.INITIALIZED_FILES) + config.init_state()
payload = {
"status": "initialized",
"state_dir": str(settings.state_dir),
"config_file": str(settings.config_file),
"data_dir": str(settings.data_dir),
"models_dir": str(settings.models_dir),
"runtime_dir": str(settings.runtime_dir),
"seeded_playbooks": len(copied),
}
_emit(payload, args.json)
return 0
def cmd_paths(args) -> int:
payload = {
"install_mode": "checkout" if settings.source_checkout else "wheel",
"project_root": str(settings.project_root),
"resource_root": str(settings.resource_root),
"state_dir": str(settings.state_dir),
"config_file": str(settings.config_file),
"data_dir": str(settings.data_dir),
"memory_db": str(settings.memory_db),
"models_dir": str(settings.models_dir),
"runtime_dir": str(settings.runtime_dir),
"web_dist_dir": str(settings.web_dist_dir),
}
_emit(payload, args.json)
return 0
def cmd_config(args) -> int:
values = config.read_user_config()
if args.action == "path":
print(config.CONFIG_FILE)
return 0
if args.action == "list":
_emit(values, args.json)
return 0
if args.action == "get":
if args.key not in CONFIG_SCHEMA:
print(f"Unknown configuration key: {args.key}", file=sys.stderr)
return 2
value = values.get(args.key, getattr(settings, args.key, None))
_emit({args.key: value}, args.json)
return 0
if args.action == "set":
if args.key not in CONFIG_SCHEMA:
print(f"Unknown configuration key: {args.key}", file=sys.stderr)
print("Known keys: " + ", ".join(CONFIG_SCHEMA), file=sys.stderr)
return 2
try:
values[args.key] = _validate_config(args.key, args.value)
except (TypeError, ValueError) as exc:
print(f"Invalid value: {exc}", file=sys.stderr)
return 2
config.write_user_config(values)
_emit({"updated": args.key, "value": values[args.key], "restart_required": True}, args.json)
return 0
if args.action == "unset":
if args.key not in CONFIG_SCHEMA:
print(f"Unknown configuration key: {args.key}", file=sys.stderr)
return 2
values.pop(args.key, None)
config.write_user_config(values)
_emit({"removed": args.key, "restart_required": True}, args.json)
return 0
return 2
def _provider_payload() -> dict:
url = settings.ollama_host.rstrip("/")
return {
"provider": settings.provider,
"url": url,
"managed_by_nexus": settings.manage_ollama,
"reachable": _http_ok(url + "/api/tags", timeout=2.0),
}
def cmd_provider(args) -> int:
if args.action == "show":
_emit(_provider_payload(), args.json)
return 0
values = config.read_user_config()
if args.mode == "local":
values["provider"] = "ollama"
try:
values["provider_url"] = _validate_config(
"provider_url", args.url or "http://127.0.0.1:11434"
)
except ValueError as exc:
print(f"Invalid value: {exc}", file=sys.stderr)
return 2
else:
if not args.url:
print("Remote provider setup requires --url", file=sys.stderr)
return 2
try:
values["provider_url"] = _validate_config("provider_url", args.url)
except ValueError as exc:
print(f"Invalid value: {exc}", file=sys.stderr)
return 2
values["provider"] = "ollama-remote"
config.write_user_config(values)
_emit({
"provider": values["provider"],
"url": values["provider_url"],
"restart_required": True,
}, args.json)
return 0
def _check_import(module: str) -> bool:
return importlib.util.find_spec(module) is not None
def _writable(path: Path) -> bool:
try:
path.mkdir(parents=True, exist_ok=True)
probe = path / ".nexus-write-test"
probe.write_text("ok", encoding="utf-8")
probe.unlink()
return True
except OSError:
return False
def diagnostics() -> dict:
checks: list[dict] = []
def add(name: str, ok: bool, detail: str, required: bool = True):
checks.append({
"name": name,
"status": "pass" if ok else ("fail" if required else "warn"),
"detail": detail,
"required": required,
})
add("python", sys.version_info >= (3, 11), sys.version.split()[0])
add("state", _writable(settings.state_dir), str(settings.state_dir))
add("database directory", _writable(settings.memory_db.parent), str(settings.memory_db.parent))
add("web assets", (settings.web_dist_dir / "index.html").is_file(), str(settings.web_dist_dir))
add(
"provider mode",
settings.provider in ("ollama", "ollama-remote"),
settings.provider,
)
add(
"service ports",
all(1 <= port <= 65535 for port in (settings.backend_port, settings.memory_port)),
f"backend={settings.backend_port}, memory={settings.memory_port}",
)
for module in ("fastapi", "uvicorn", "httpx", "pydantic", "yaml"):
add(f"import:{module}", _check_import(module), module)
add("backend", _http_ok(settings.api_url + "/status"), settings.api_url, required=False)
add("memory service", _http_ok(settings.memory_url + "/"), settings.memory_url, required=False)
provider = _provider_payload()
add("provider", provider["reachable"], provider["url"], required=False)
if settings.manage_ollama:
add("ollama executable", bool(services.ollama_bin()), services.ollama_bin() or "not on PATH", required=False)
for label, module in (
("process control", "psutil"),
("vector search", "sqlite_vec"),
("voice transcription", "faster_whisper"),
("PDF documents", "pypdf"),
("Word documents", "docx"),
):
add(label, _check_import(module), module, required=False)
return {
"ok": not any(c["status"] == "fail" for c in checks),
"version": settings.version,
"install_mode": "checkout" if settings.source_checkout else "wheel",
"platform": sys.platform,
"termux": _is_termux(),
"checks": checks,
}
def _doctor_fix() -> None:
config.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
config.init_state()
index = settings.web_dist_dir / "index.html"
npm = shutil.which("npm.cmd" if os.name == "nt" else "npm")
if settings.source_checkout and not index.exists() and npm:
subprocess.run([npm, "run", "build"], cwd=str(settings.frontend_source_dir), check=False)
def cmd_doctor(args) -> int:
if args.fix:
_doctor_fix()
result = diagnostics()
if args.json:
_emit(result, True)
else:
print(f"NexusOS {result['version']} diagnostics ({result['install_mode']})\n")
marks = {"pass": "OK", "warn": "WARN", "fail": "FAIL"}
for check in result["checks"]:
print(f" {marks[check['status']]:<4} {check['name']:<20} {check['detail']}")
print("\nCore runtime is ready." if result["ok"] else "\nCore runtime has required failures.")
return 0 if result["ok"] else 1
def service_status() -> dict:
payload = {}
for key in ("backend", "memory", "frontend"):
svc = services.SERVICES[key]
pid = services.read_pid(svc)
payload[key] = {
"running": services.alive(pid) or _http_ok(svc.url),
"pid": pid if services.alive(pid) else None,
"url": svc.url,
}
payload["provider"] = _provider_payload()
return payload
def cmd_status(args) -> int:
payload = service_status()
if args.json:
_emit(payload, True)
return 0
print("Nexus Service Status:\n")
for key in ("backend", "memory", "frontend"):
info = payload[key]
suffix = f" (PID {info['pid']})" if info["pid"] else ""
print(f" {key:<10} {'RUNNING' if info['running'] else 'STOPPED'}{suffix} {info['url']}")
p = payload["provider"]
print(f" provider {'RUNNING' if p['reachable'] else 'STOPPED'} {p['provider']} @ {p['url']}")
return 0
def _target_flag(target: str | None):
return {
"memory": "--memory",
"backend": "--backend",
"frontend": "--frontend",
"ai": "--ai",
}.get(target or "all")
def cmd_start(args) -> int:
services.cmd_start(_target_flag(args.target))
return 0
def cmd_stop(args) -> int:
services.cmd_stop(_target_flag(args.target))
return 0
def cmd_refresh(args) -> int:
services.cmd_stop(None)
services.cmd_start(None)
return 0
def cmd_serve(args) -> int:
host = args.host or settings.bind_host
if host not in ("127.0.0.1", "localhost", "::1") and not args.allow_lan:
print("Refusing an unauthenticated LAN bind. Add --allow-lan to acknowledge the exposure.", file=sys.stderr)
return 2
if _http_ok(f"http://127.0.0.1:{args.port}/"):
print(f"Port {args.port} is already serving HTTP.", file=sys.stderr)
return 2
settings.backend_port = args.port
settings.memory_port = args.memory_port
settings.bind_host = host
settings.api_url = f"http://127.0.0.1:{args.port}"
settings.memory_url = f"http://127.0.0.1:{args.memory_port}"
os.environ["NEXUS_BACKEND_PORT"] = str(args.port)
os.environ["NEXUS_MEMORY_PORT"] = str(args.memory_port)
os.environ["NEXUS_BIND_HOST"] = host
for origin_host in ("localhost", "127.0.0.1"):
for port in (args.port, args.memory_port):
origin = f"http://{origin_host}:{port}"
if origin not in config.ALLOWED_ORIGINS:
config.ALLOWED_ORIGINS.append(origin)
if args.allow_lan:
os.environ.setdefault("NEXUS_ALLOWED_HOSTS", "*")
os.environ.setdefault("NEXUS_ALLOWED_ORIGINS", "*")
config.ALLOWED_HOSTS[:] = ["*"]
config.ALLOWED_ORIGINS[:] = ["*"]
memory_proc = None
memory_log = None
try:
if not args.no_memory and not _http_ok(settings.memory_url + "/"):
log_path = settings.runtime_dir / "memory.log"
log_path.parent.mkdir(parents=True, exist_ok=True)
memory_log = open(log_path, "ab")
memory_proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "synapse.memory.service:app",
"--host", host, "--port", str(args.memory_port)],
stdout=memory_log, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
)
print(f"Memory service starting on {host}:{args.memory_port} (log: {log_path})")
print(f"NexusOS serving on http://{host}:{args.port}")
import uvicorn
uvicorn.run(
"synapse.main:sio_app", host=host, port=args.port,
reload=bool(args.reload and settings.source_checkout),
log_level=args.log_level,
)
finally:
if memory_proc is not None and memory_proc.poll() is None:
memory_proc.terminate()
try:
memory_proc.wait(timeout=5)
except subprocess.TimeoutExpired:
memory_proc.kill()
if memory_log is not None:
memory_log.close()
return 0
def cmd_open(args) -> int:
if not _http_ok(settings.api_url + "/status") and not args.no_start:
services.cmd_start(None)
url = args.url or settings.api_url
opener = shutil.which("termux-open-url") if _is_termux() else None
opened = False
if opener:
opened = subprocess.run([opener, url], check=False).returncode == 0
else:
opened = webbrowser.open(url)
print(f"{'Opened' if opened else 'NexusOS is available at'} {url}")
return 0
def cmd_web(args) -> int:
"""Preserve ``ncp web`` for desktop checkouts; wheels use the browser UI."""
if settings.source_checkout:
return services.cmd_web()
return cmd_open(argparse.Namespace(url=None, no_start=False))
def cmd_panel(_args) -> int:
if not settings.source_checkout:
print("The legacy Tk control panel is only available in a desktop source install.", file=sys.stderr)
return 2
return services.main(["panel"])
def cmd_backup(args) -> int:
if not settings.source_checkout:
print("Backup is a source-checkout command; wheel state should be backed up from nexus paths.", file=sys.stderr)
return 2
if args.check:
return services.sync_py("backup", "--check")
if args.full:
return services.sync_py("backup", "--full")
return services.sync_py("backup")
def cmd_restore(args) -> int:
if not settings.source_checkout:
print("Restore is a source-checkout command; reinstall the wheel and restore its state directory.", file=sys.stderr)
return 2
return services.cmd_restore("--check" if args.check else None)
def cmd_nvidia_reqs(_args) -> int:
if not settings.source_checkout:
print("nvidia-reqs is only available in a desktop source install.", file=sys.stderr)
return 2
return subprocess.run(
[sys.executable, str(settings.project_root / "bin" / "gen-nvidia-reqs.py")]
).returncode
def cmd_logs(args) -> int:
keys = ("backend", "memory", "frontend") if args.target == "all" else (args.target,)
paths = [services.SERVICES[key].log_file for key in keys]
for path in paths:
print(f"=== {path.name} ===")
services._tail(path, args.lines)
if not args.follow:
return 0
offsets = {path: path.stat().st_size if path.exists() else 0 for path in paths}
try:
while True:
for path in paths:
if not path.exists():
continue
size = path.stat().st_size
if size < offsets[path]:
offsets[path] = 0
if size > offsets[path]:
with open(path, encoding="utf-8", errors="replace") as stream:
stream.seek(offsets[path])
sys.stdout.write(stream.read())
sys.stdout.flush()
offsets[path] = stream.tell()
time.sleep(0.5)
except KeyboardInterrupt:
print()
return 130
def cmd_clean(args) -> int:
for path in (settings.runtime_dir / "pids", settings.runtime_dir / "logs"):
if path.is_dir():
for file in path.glob("*"):
if file.is_file():
file.unlink(missing_ok=True)
for pattern in ("*.log", "*.pid"):
for file in settings.runtime_dir.glob(pattern):
file.unlink(missing_ok=True)
print(f"Cleaned runtime files under {settings.runtime_dir}")
return 0
def cmd_update(args) -> int:
if settings.source_checkout:
services.cmd_update()
return 0
command = [sys.executable, "-m", "pip", "install", "--upgrade", "nexusos-ai"]
if args.pre:
command.append("--pre")
return subprocess.run(command).returncode
def cmd_models(args) -> int:
import httpx
base = settings.ollama_host.rstrip("/")
try:
if args.action == "list":
response = httpx.get(base + "/api/tags", timeout=5.0)
response.raise_for_status()
models = response.json().get("models", [])
if args.json:
_emit(models, True)
elif not models:
print("No models installed.")
else:
for model in models:
print(f"{model.get('name', ''):<36} {model.get('size', 0) / 1024**3:5.1f} GB")
elif args.action == "available":
services.cmd_models("available", None)
elif args.action in ("pull", "install"):
with httpx.stream("POST", base + "/api/pull", json={"name": args.name}, timeout=None) as response:
response.raise_for_status()
for line in response.iter_lines():
if line:
try:
item = json.loads(line)
status = item.get("status") or item.get("error")
if status:
print(status)
except ValueError:
print(line)
elif args.action in ("remove", "rm"):
response = httpx.request("DELETE", base + "/api/delete", json={"name": args.name}, timeout=30.0)
response.raise_for_status()
print(f"Removed {args.name}")
return 0
except httpx.HTTPError as exc:
print(f"Provider request failed at {base}: {exc}", file=sys.stderr)
return 1
def cmd_api(args) -> int:
from . import nexus_api
nexus_api.BASE = args.api_url or settings.api_url
rest = list(args.rest)
if args.command == "chat" and rest[:1] == ["send"]:
rest.pop(0)
if args.command == "history" and rest[:1] == ["list"]:
rest.pop(0)
return nexus_api.main([args.command, *rest], prog=f"nexus {args.command}")
def _add_json(parser) -> None:
parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")
def _port(value: str) -> int:
try:
return _validate_config("backend_port", value)
except (TypeError, ValueError) as exc:
raise argparse.ArgumentTypeError(str(exc)) from exc
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="nexus", description="NexusOS local AI runtime and API client")
parser.add_argument("--version", action="version", version=f"NexusOS {settings.version}")
parser.add_argument("--api-url", help="override the NexusOS backend URL for this command")
sub = parser.add_subparsers(dest="command", required=True)
p = sub.add_parser("init", help="create user state and seed default playbooks"); _add_json(p); p.set_defaults(fn=cmd_init)
p = sub.add_parser("paths", help="show resolved package and writable paths"); _add_json(p); p.set_defaults(fn=cmd_paths)
p = sub.add_parser("config", help="manage persistent CLI/runtime configuration")
p.add_argument("action", choices=["list", "get", "set", "unset", "path"])
p.add_argument("key", nargs="?"); p.add_argument("value", nargs="?"); _add_json(p); p.set_defaults(fn=cmd_config)
p = sub.add_parser("provider", help="configure the Ollama-compatible model provider")
provider_sub = p.add_subparsers(dest="action", required=True)
show = provider_sub.add_parser("show"); _add_json(show); show.set_defaults(fn=cmd_provider)
use = provider_sub.add_parser("use"); use.add_argument("mode", choices=["local", "remote"])
use.add_argument("--url"); _add_json(use); use.set_defaults(fn=cmd_provider)
p = sub.add_parser("doctor", help="check the core runtime and optional capabilities")
p.add_argument("--fix", action="store_true"); _add_json(p); p.set_defaults(fn=cmd_doctor)
p = sub.add_parser("status", help="show service and provider status"); _add_json(p); p.set_defaults(fn=cmd_status)
p = sub.add_parser("serve", help="run NexusOS in the foreground")
p.add_argument("--host"); p.add_argument("--port", type=_port, default=settings.backend_port)
p.add_argument("--memory-port", type=_port, default=settings.memory_port)
p.add_argument("--no-memory", action="store_true"); p.add_argument("--allow-lan", action="store_true")
p.add_argument("--reload", action="store_true"); p.add_argument("--log-level", default="info")
p.set_defaults(fn=cmd_serve)
for name, fn, help_text in (
("start", cmd_start, "start services in the background"),
("stop", cmd_stop, "stop background services"),
):
p = sub.add_parser(name, help=help_text)
p.add_argument("target", nargs="?", choices=["all", "backend", "memory", "frontend", "ai"], default="all")
p.set_defaults(fn=fn)
sub.add_parser("restart", aliases=["refresh"], help="restart all services").set_defaults(fn=cmd_refresh)
sub.add_parser("kill", help="force-stop NexusOS-owned processes").set_defaults(fn=lambda _a: services.cmd_kill() or 0)
p = sub.add_parser("open", help="open the web interface")
p.add_argument("--url"); p.add_argument("--no-start", action="store_true"); p.set_defaults(fn=cmd_open)
sub.add_parser("web", help="legacy desktop alias for open").set_defaults(fn=cmd_web)
sub.add_parser("panel", help="launch the legacy desktop control panel").set_defaults(fn=cmd_panel)
p = sub.add_parser("logs", help="read or follow service logs")
p.add_argument("target", nargs="?", choices=["all", "backend", "memory", "frontend"], default="all")
p.add_argument("--lines", type=int, choices=range(1, 10001), default=50, metavar="1..10000")
p.add_argument("--follow", "-f", action="store_true"); p.set_defaults(fn=cmd_logs)
sub.add_parser("clean", help="remove runtime logs and stale PID files").set_defaults(fn=cmd_clean)
p = sub.add_parser("update", help="update dependencies or the installed wheel"); p.add_argument("--pre", action="store_true"); p.set_defaults(fn=cmd_update)
p = sub.add_parser("backup", help="back up a desktop source checkout")
p.add_argument("--full", "-f", action="store_true"); p.add_argument("--check", "-c", action="store_true")
p.set_defaults(fn=cmd_backup)
p = sub.add_parser("restore", help="restore a desktop source checkout")
p.add_argument("--check", "-c", action="store_true"); p.set_defaults(fn=cmd_restore)
sub.add_parser("nvidia-reqs", help="regenerate source-checkout NVIDIA requirements").set_defaults(fn=cmd_nvidia_reqs)
p = sub.add_parser("models", help="list, pull, and remove provider models")
model_sub = p.add_subparsers(dest="action", required=True)
item = model_sub.add_parser("list"); _add_json(item); item.set_defaults(fn=cmd_models)
model_sub.add_parser("available", aliases=["search"]).set_defaults(fn=cmd_models, action="available")
item = model_sub.add_parser("pull", aliases=["install"]); item.add_argument("name"); item.set_defaults(fn=cmd_models, action="pull")
item = model_sub.add_parser("remove", aliases=["rm"]); item.add_argument("name"); item.set_defaults(fn=cmd_models, action="remove")
for command in ("chat", "memory", "playbook", "history"):
p = sub.add_parser(command, add_help=False, help=f"use the {command} API from the terminal")
p.add_argument("rest", nargs=argparse.REMAINDER)
p.set_defaults(fn=cmd_api)
return parser
def _normalize_legacy_argv(argv) -> list[str]:
"""Translate the old shell CLI spelling before argparse sees it."""
normalized = list(argv or [])
if normalized == ["help"]:
return ["--help"]
if normalized and normalized[0] in ("start", "stop", "logs"):
normalized[1:] = [LEGACY_TARGETS.get(value, value) for value in normalized[1:]]
if normalized[:2] == ["backup", "full"]:
normalized[1] = "--full"
elif len(normalized) > 1 and normalized[0] == "backup" and normalized[1] in ("check", "--claude"):
normalized[1] = "--check"
elif len(normalized) > 1 and normalized[0] == "restore":
if normalized[1] in ("check", "--claude"):
normalized[1] = "--check"
elif normalized[1] in ("full", "-f", "--full"):
normalized.pop(1)
return normalized
def main(argv=None) -> int:
parser = build_parser()
args = parser.parse_args(_normalize_legacy_argv(argv))
if args.command == "config":
if args.action in ("get", "unset") and not args.key:
parser.error(f"config {args.action} requires KEY")
if args.action == "set" and (not args.key or args.value is None):
parser.error("config set requires KEY VALUE")
try:
result = args.fn(args)
return result if isinstance(result, int) else 0
except KeyboardInterrupt:
print()
return 130
def entrypoint() -> int:
return main(sys.argv[1:])
if __name__ == "__main__":
raise SystemExit(entrypoint())
+7 -4
View File
@@ -12,17 +12,20 @@ REM exactly what this file exists to avoid.
REM
REM ASCII only, same rule as the .ps1 files - a test in tests/test_smoke.py enforces it.
setlocal
REM ncp.py pins its stdout to UTF-8 (it prints check marks and em-dashes, and a
REM The CLI pins its stdout to UTF-8 (it prints check marks and em-dashes, and a
REM redirected stdout would otherwise raise UnicodeEncodeError). A console still
REM on codepage 437 renders those bytes as mojibake, so switch it to UTF-8 here.
REM ponytail: chcp changes the calling console's codepage and does not restore
REM it on exit. Harmless in practice; if that ever matters, set the console CP
REM from inside ncp.py with ctypes SetConsoleOutputCP instead.
REM from inside the CLI with ctypes SetConsoleOutputCP instead.
chcp 65001 >nul 2>&1
set "ROOT=%~dp0.."
REM System Python fallback so backup/restore work before the venv exists; those
REM delegate to the stdlib-only bin/sync.py. Mirrors the same fallback in ncp.ps1.
set "PY=%ROOT%\Promethean\Scripts\python.exe"
if not exist "%PY%" set "PY=python"
"%PY%" "%ROOT%\management\ncp.py" %*
exit /b %ERRORLEVEL%
pushd "%ROOT%"
"%PY%" -m management.cli %*
set "NEXUS_EXIT=%ERRORLEVEL%"
popd
exit /b %NEXUS_EXIT%
+94 -30
View File
@@ -28,15 +28,20 @@ import urllib.request
from dataclasses import dataclass, field
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
PID_DIR = ROOT / "runtime" / "pids"
LOG_DIR = ROOT / "runtime"
FRONTEND_DIR = ROOT / "interface" / "web"
from synapse.nexus_config import SOURCE_CHECKOUT, settings
ROOT = settings.project_root
PID_DIR = settings.runtime_dir / "pids"
LOG_DIR = settings.runtime_dir
FRONTEND_DIR = settings.frontend_source_dir
OLLAMA_BIN = ROOT / "ollama" / "bin" / ("ollama.exe" if os.name == "nt" else "ollama")
OLLAMA_MODELS_DIR = ROOT / "models"
OLLAMA_MODELS_DIR = settings.models_dir
WINDOWS = os.name == "nt"
PYTHON = ROOT / "Promethean" / ("Scripts/python.exe" if WINDOWS else "bin/python3")
_VENV_PYTHON = ROOT / "Promethean" / ("Scripts/python.exe" if WINDOWS else "bin/python3")
PYTHON = Path(os.getenv("NEXUS_PYTHON", "")) if os.getenv("NEXUS_PYTHON") else (
_VENV_PYTHON if _VENV_PYTHON.exists() else Path(sys.executable)
)
PID_DIR.mkdir(parents=True, exist_ok=True)
LOG_DIR.mkdir(parents=True, exist_ok=True)
@@ -93,7 +98,7 @@ def _psutil():
import psutil
return psutil
except ImportError:
sys.exit("psutil is missing - run ./install.sh (or install-windows.ps1) to rebuild the venv.")
return None
# -- services ------------------------------------------------------------------
@@ -124,7 +129,7 @@ class Service:
# Bind loopback by default: the backend REST API is unauthenticated, so
# binding 0.0.0.0 handed the full admin+data plane to any host on the LAN. Set
# NEXUS_BIND_HOST=0.0.0.0 to opt into LAN exposure once real auth is in place.
BIND_HOST = os.environ.get("NEXUS_BIND_HOST", "127.0.0.1")
BIND_HOST = settings.bind_host
def _uvicorn(app: str, port: int):
@@ -140,9 +145,12 @@ def _uvicorn(app: str, port: int):
SERVICES = {
"backend": Service("backend", "NEXUS BACKEND SERVICE", 8000, ROOT,
"memory": Service("memory", "NEXUS MEMORY SERVICE", settings.memory_port, settings.state_dir,
["uvicorn synapse.memory"],
lambda: _uvicorn("synapse.memory.service:app", settings.memory_port)),
"backend": Service("backend", "NEXUS BACKEND SERVICE", settings.backend_port, settings.state_dir,
["uvicorn synapse.main"],
lambda: _uvicorn("synapse.main:sio_app", 8000)),
lambda: _uvicorn("synapse.main:sio_app", settings.backend_port)),
"frontend": Service("frontend", "NEXUS FRONTEND SERVICE", 5173, FRONTEND_DIR,
["vite --host", "npm run dev"],
lambda: [npm(), "run", "dev", "--", "--host", "0.0.0.0"]),
@@ -158,7 +166,15 @@ def read_pid(svc: Service):
def alive(pid) -> bool:
ps = _psutil()
return pid is not None and ps.pid_exists(pid)
if pid is None:
return False
if ps is not None:
return ps.pid_exists(pid)
try:
os.kill(pid, 0)
return True
except (OSError, ValueError):
return False
def pid_is_ours(pid, patterns) -> bool:
@@ -168,7 +184,13 @@ def pid_is_ours(pid, patterns) -> bool:
is not enough. TERMing a recycled PID can log the user out."""
ps = _psutil()
try:
cmd = " ".join(ps.Process(pid).cmdline())
if ps is not None:
cmd = " ".join(ps.Process(pid).cmdline())
elif os.name != "nt":
cmd = (Path(f"/proc/{pid}/cmdline").read_bytes()
.replace(b"\0", b" ").decode(errors="replace"))
else:
return False
except Exception:
return False
return any(p in cmd for p in patterns)
@@ -255,6 +277,20 @@ def kill_matching(patterns, force=False) -> int:
ps = _psutil()
me = os.getpid()
hit = 0
if ps is None:
if os.name == "nt":
return 0
for entry in Path("/proc").iterdir():
if not entry.name.isdigit() or int(entry.name) == me:
continue
try:
cmd = (entry / "cmdline").read_bytes().replace(b"\0", b" ").decode(errors="replace")
if any(pattern in cmd for pattern in patterns):
os.kill(int(entry.name), 9 if force else 15)
hit += 1
except (OSError, ValueError):
pass
return hit
for proc in ps.process_iter(["pid", "cmdline"]):
if proc.info["pid"] == me:
continue
@@ -272,6 +308,8 @@ def kill_port(port: int) -> bool:
"""Whatever holds the port IS the service - this is the backstop that makes
stop reliable regardless of process-tree shape or PID-file accuracy."""
ps = _psutil()
if ps is None:
return False
killed = False
try:
conns = ps.net_connections(kind="inet")
@@ -295,13 +333,16 @@ def stop_service(svc: Service) -> bool:
if alive(pid) and pid_is_ours(pid, svc.patterns):
ps = _psutil()
try:
proc = ps.Process(pid)
for child in proc.children(recursive=True):
try:
child.terminate()
except Exception:
pass
proc.terminate()
if ps is not None:
proc = ps.Process(pid)
for child in proc.children(recursive=True):
try:
child.terminate()
except Exception:
pass
proc.terminate()
elif os.name != "nt":
os.kill(pid, 15)
except Exception:
pass
svc.pid_file.unlink(missing_ok=True)
@@ -332,8 +373,15 @@ def start_ollama(background: bool = False) -> None:
background=False (`ncp start --ai`): blocks until the model is warmed - weights
read off disk into RAM/VRAM, routinely about a minute - so "started" means the
AI can actually answer."""
if not settings.manage_ollama:
running = http_ok(settings.ollama_host.rstrip("/") + "/api/tags", timeout=3)
print(
f"REMOTE OLLAMA {'REACHABLE' if running else 'UNREACHABLE'} "
f"({settings.ollama_host})"
)
return
print("Starting OLLAMA...")
url = "http://localhost:8000/ollama/start" + ("?background=true" if background else "")
url = settings.api_url + "/ollama/start" + ("?background=true" if background else "")
req = urllib.request.Request(url, method="POST")
try:
urllib.request.urlopen(req, timeout=180).read(1)
@@ -346,7 +394,10 @@ def stop_ollama() -> None:
"""Prefer the backend endpoint for a clean OllamaManager shutdown; if the
backend is already down, kill `ollama serve` directly so it never lingers
holding VRAM/RAM. Must run BEFORE the backend is torn down."""
req = urllib.request.Request("http://localhost:8000/ollama/stop", method="POST")
if not settings.manage_ollama:
print(f"REMOTE OLLAMA IS EXTERNALLY MANAGED ({settings.ollama_host})")
return
req = urllib.request.Request(settings.api_url + "/ollama/stop", method="POST")
try:
urllib.request.urlopen(req, timeout=5).read(1)
print("NEXUS OLLAMA STOPPED")
@@ -367,6 +418,9 @@ def cmd_start(target) -> None:
launch(SERVICES["backend"]); wait_for_port(SERVICES["backend"])
start_ollama()
elif target in ("--frontend", "-f"):
if not SOURCE_CHECKOUT:
print("Vite source is unavailable in wheel installs; the backend serves the bundled UI.")
return
launch(SERVICES["frontend"]); wait_for_port(SERVICES["frontend"])
elif target in ("--ai", "-a"):
start_ollama()
@@ -389,7 +443,7 @@ def cmd_start(target) -> None:
wait_for_port(SERVICES["backend"])
t_services = time.perf_counter()
start_ollama(background=True)
if not WINDOWS:
if SOURCE_CHECKOUT and not WINDOWS:
launch(SERVICES["frontend"])
t_bg = time.perf_counter()
print("\nBoot timing:")
@@ -405,6 +459,8 @@ def cmd_stop(target) -> None:
stop_ollama(); stop_service(SERVICES["backend"])
elif target in ("--frontend", "-f"):
stop_service(SERVICES["frontend"])
elif target in ("--ai", "-a"):
stop_ollama()
elif target in (None, "", "all"):
stop_ollama()
stop_service(SERVICES["backend"])
@@ -415,14 +471,21 @@ def cmd_stop(target) -> None:
def cmd_kill() -> None:
print("Force-killing all Nexus processes...")
for port, name in ((8000, "SYNAPSE"),
(5173, "INTERFACE"), (11434, "OLLAMA")):
targets = [
(settings.backend_port, "SYNAPSE"),
(settings.memory_port, "MEMORY"),
(5173, "INTERFACE"),
]
patterns = ["uvicorn synapse", "npm run dev", "vite --host"]
if settings.manage_ollama:
targets.append((11434, "OLLAMA"))
patterns.append("ollama serve")
for port, name in targets:
if kill_port(port):
print(f" KILLED: {name} (:{port})")
else:
print(f" NOT RUNNING: {name} (:{port})")
kill_matching(["uvicorn synapse", "npm run dev", "vite --host", "ollama serve"],
force=True)
kill_matching(patterns, force=True)
for pid_file in PID_DIR.glob("*.pid"):
pid_file.unlink(missing_ok=True)
print("Done.")
@@ -445,8 +508,9 @@ def cmd_status() -> None:
print("\nFrontend:")
one("Vite ", SERVICES["frontend"])
print("\nModel server:")
running = http_ok("http://localhost:11434/api/tags")
print(f" Ollama : {'RUNNING (:11434)' if running else 'STOPPED'}")
running = http_ok(settings.ollama_host.rstrip("/") + "/api/tags")
mode = "remote" if not settings.manage_ollama else "local"
print(f" Ollama ({mode}) : {'RUNNING' if running else 'STOPPED'} ({settings.ollama_host})")
def _tail(path: Path, n: int) -> None:
@@ -687,10 +751,10 @@ MODEL_CATALOG = [
def cmd_models(action, name) -> None:
if action == "list":
if not http_ok("http://localhost:11434/api/tags"):
if not http_ok(settings.ollama_host.rstrip("/") + "/api/tags"):
print("Ollama is not running. Start the backend first with: ncp start -b")
return
raw = urllib.request.urlopen("http://localhost:11434/api/tags", timeout=5).read()
raw = urllib.request.urlopen(settings.ollama_host.rstrip("/") + "/api/tags", timeout=5).read()
models = json.loads(raw).get("models", [])
print("Installed models:\n")
if not models:
+5 -4
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# ncp - Linux entry point. The CLI itself is management/ncp.py, which runs
# ncp - Linux entry point. The CLI itself is management/cli.py, which runs
# unchanged on Windows too (see management/ncp.cmd); this stays a shell script
# because ~/.bashrc, launch_nexus.sh, bin/restore-linux.sh, controlpanel.py,
# bin/panel/nexus-popup.py and management/nexus-app.sh all invoke this path.
@@ -8,9 +8,10 @@
# directory drives itself instead of reaching into the real install.
NEXUS_ROOT="$(cd "$(dirname "$(realpath "$0")")/.." && pwd)"
# ncp.py needs psutil (venv), but its backup/restore path delegates to the
# stdlib-only bin/sync.py and must work before the venv is built.
# Prefer the project venv for the full desktop dependency set. The portable CLI
# falls back to system Python when the package is installed without that venv.
PY="$NEXUS_ROOT/Promethean/bin/python3"
[ -x "$PY" ] || PY=python3
exec "$PY" "$NEXUS_ROOT/management/ncp.py" "$@"
cd "$NEXUS_ROOT"
exec "$PY" -m management.cli "$@"
+8 -5
View File
@@ -11,7 +11,9 @@ import sys
import httpx
BASE = __import__("os").environ.get("NEXUS_API", "http://localhost:8000")
from synapse.nexus_config import settings
BASE = settings.api_url
def _client():
@@ -118,8 +120,8 @@ def cmd_history(args):
_die_if_down(e)
def main():
p = argparse.ArgumentParser(prog="ncp")
def main(argv=None, prog="nexus"):
p = argparse.ArgumentParser(prog=prog)
sub = p.add_subparsers(dest="cmd", required=True)
c = sub.add_parser("chat"); c.add_argument("message", nargs="+")
@@ -135,8 +137,9 @@ def main():
h = sub.add_parser("history"); h.add_argument("query", nargs="?")
h.add_argument("--limit", type=int, default=20); h.set_defaults(fn=cmd_history)
args = p.parse_args()
args.fn(args)
args = p.parse_args(argv)
result = args.fn(args)
return result if isinstance(result, int) else 0
if __name__ == "__main__":
+4 -1
View File
@@ -11,7 +11,10 @@ import pytest
pytest.importorskip("tkinter", reason="controlpanel is a Tk GUI; headless boxes lack python3-tk")
from controlpanel import NexusControlPanel # noqa: E402
try:
from management.controlpanel import NexusControlPanel # noqa: E402
except ModuleNotFoundError: # direct: python management/test_controlpanel_close.py
from controlpanel import NexusControlPanel # type: ignore[no-redef] # noqa: E402
def _fake(closing, after_raises):
+4 -1
View File
@@ -1,7 +1,10 @@
"""Pin the SSE parser in nexus_api. Run: python management/test_nexus_api.py"""
import sys, os
sys.path.insert(0, os.path.dirname(__file__))
from nexus_api import iter_chunks
try:
from management.nexus_api import iter_chunks
except ModuleNotFoundError: # direct: python management/test_nexus_api.py
from nexus_api import iter_chunks
# A realistic /chat/stream frame: two token chunks, a meta block, then done.
lines = [
+107
View File
@@ -0,0 +1,107 @@
[build-system]
requires = ["hatchling>=1.26"]
build-backend = "hatchling.build"
[project]
name = "nexusos-ai"
dynamic = ["version"]
description = "Local-first AI assistant runtime, web UI, and portable CLI"
readme = "README.md"
requires-python = ">=3.11"
authors = [{name = "EnderOfWings"}]
keywords = ["ai", "assistant", "ollama", "local-ai", "termux"]
classifiers = [
"Development Status :: 4 - Beta",
"Environment :: Console",
"Framework :: FastAPI",
"Operating System :: Android",
"Operating System :: Microsoft :: Windows",
"Operating System :: POSIX :: Linux",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Communications :: Chat",
]
dependencies = [
"fastapi>=0.115,<1",
"httpx>=0.27,<1",
"pydantic>=2.7,<3",
"python-dotenv>=1,<2",
"PyYAML>=6,<7",
"uvicorn>=0.30,<1",
]
[project.optional-dependencies]
documents = ["pypdf>=5,<7", "python-docx>=1.1,<2"]
process = ["psutil>=5.9,<8"]
vector = ["sqlite-vec>=0.1,<1"]
voice = ["faster-whisper>=1.1,<2"]
mail = ["imap-tools>=1.7,<2"]
desktop = [
"psutil>=5.9,<8",
"pywebview>=5,<7; platform_system == 'Windows'",
]
standard = [
"pypdf>=5,<7",
"python-docx>=1.1,<2",
"psutil>=5.9,<8",
"sqlite-vec>=0.1,<1",
]
dev = [
"build>=1.2,<2",
"pytest>=8,<10",
"twine>=7,<8",
]
[project.scripts]
nexus = "management.cli:entrypoint"
ncp = "management.cli:entrypoint"
nexusos = "management.cli:entrypoint"
[project.urls]
Homepage = "https://git.enderofwings.com/enderofwings/NexusOS"
Issues = "https://git.enderofwings.com/enderofwings/NexusOS/issues"
Repository = "https://git.enderofwings.com/enderofwings/NexusOS"
[tool.hatch.version]
path = "VERSION"
pattern = "^(?P<version>[^\\s]+)$"
[tool.hatch.build]
skip-excluded-dirs = true
[tool.hatch.build.targets.wheel]
packages = ["synapse", "management"]
[tool.hatch.build.targets.wheel.force-include]
"VERSION" = "synapse/_resources/VERSION"
"interface/web/dist" = "synapse/_resources/web"
"data/playbooks" = "synapse/_resources/playbooks"
"assets/n-small.png" = "synapse/_resources/assets/n-small.png"
"assets/themes/NexusOS-icons-src/nexus-underlay.svg" = "synapse/_resources/assets/themes/NexusOS-icons-src/nexus-underlay.svg"
"assets/themes/NexusOS-icons-src/nexus-underlay-ring.svg" = "synapse/_resources/assets/themes/NexusOS-icons-src/nexus-underlay-ring.svg"
[tool.hatch.build.targets.sdist]
include = [
"/assets/n-small.png",
"/assets/themes/NexusOS-icons-src/nexus-underlay.svg",
"/assets/themes/NexusOS-icons-src/nexus-underlay-ring.svg",
"/data/playbooks",
"/docs",
"/interface/web/dist",
"/management",
"/scripts",
"/synapse",
"/tests",
"/README.md",
"/VERSION",
"/pyproject.toml",
]
[tool.hatch.build.targets.sdist.force-include]
"interface/web/dist" = "interface/web/dist"
[tool.pytest.ini_options]
testpaths = ["tests", "management"]
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Install the portable NexusOS wheel in Termux.
#
# NEXUS_PACKAGE may be a PyPI requirement, wheel URL, or local wheel path.
# NEXUS_ANDROID_WHEEL_INDEX may point at a trusted PEP 503 index containing
# Android pydantic-core wheels for the device's Python/CPU combination.
set -euo pipefail
if [[ "${PREFIX:-}" != *com.termux* ]]; then
echo "This installer must be run inside Termux." >&2
exit 2
fi
PACKAGE_SPEC="${1:-${NEXUS_PACKAGE:-nexusos-ai}}"
echo "==> Installing Termux prerequisites"
pkg update -y
pkg install -y python python-pip curl clang make
PYTHON_TAG="$(python -c 'import sys; print(f"cp{sys.version_info.major}{sys.version_info.minor}")')"
ARCH="$(uname -m)"
echo "==> Python ${PYTHON_TAG}; architecture ${ARCH}"
PIP_INDEX_ARGS=()
if [[ -n "${NEXUS_ANDROID_WHEEL_INDEX:-}" ]]; then
PIP_INDEX_ARGS+=(--extra-index-url "${NEXUS_ANDROID_WHEEL_INDEX}")
fi
# Pydantic 2 is required on Python 3.14+, and its Rust extension is not built
# reliably on-device. Require a binary before asking pip to resolve NexusOS so
# failures are immediate and actionable. Older Termux environments also benefit
# from using a wheel instead of compiling the extension on a phone.
echo "==> Checking for an Android pydantic-core wheel"
if ! python -m pip install \
--only-binary=pydantic-core \
"${PIP_INDEX_ARGS[@]}" \
"pydantic>=2.7,<3"; then
cat >&2 <<EOF
No compatible pydantic-core wheel was found for ${PYTHON_TAG}/${ARCH}.
PyPI does not currently publish Android wheels for this native dependency.
Set NEXUS_ANDROID_WHEEL_INDEX to a trusted NexusOS Android wheel index and
run this installer again. Do not force a source build on a memory-limited phone.
EOF
exit 1
fi
echo "==> Installing ${PACKAGE_SPEC}"
python -m pip install "${PACKAGE_SPEC}"
echo "==> Initializing NexusOS"
nexus init
if [[ -n "${NEXUS_PROVIDER_URL:-}" ]]; then
nexus provider use remote --url "${NEXUS_PROVIDER_URL}"
fi
nexus doctor
cat <<'EOF'
NexusOS is installed. Configure an Ollama-compatible provider, then run:
nexus provider use remote --url http://YOUR-OLLAMA-HOST:11434
nexus serve
Open http://127.0.0.1:8000 in the Android browser.
EOF
+42 -15
View File
@@ -12,12 +12,16 @@ import os
import shutil
import subprocess
import urllib.request
from pathlib import Path
import psutil
try:
import psutil
except ImportError: # optional in the portable/Termux core install
psutil = None
from .nexus_config import PROJECT_ROOT, RUNTIME_DIR
from .nexus_config import FRONTEND_SOURCE_DIR, RUNTIME_DIR
FRONTEND_DIR = PROJECT_ROOT / "interface" / "web"
FRONTEND_DIR = FRONTEND_SOURCE_DIR
PID_FILE = RUNTIME_DIR / "pids" / "frontend.pid"
LOG_FILE = RUNTIME_DIR / "frontend.log"
PORT = 5173
@@ -41,12 +45,30 @@ def _is_ours(pid: int) -> bool:
# the tracked PID's own cmdline. Loosen to "vite" (matches once the tree
# gets that far) or "npm"+"dev" both present (matches the wrapper hop too).
try:
cmd = " ".join(psutil.Process(pid).cmdline())
if psutil is not None:
cmd = " ".join(psutil.Process(pid).cmdline())
elif os.name != "nt":
cmd = (Path(f"/proc/{pid}/cmdline").read_bytes()
.replace(b"\0", b" ").decode(errors="replace"))
else:
return False
except Exception:
return False
return "vite" in cmd or ("npm" in cmd and "dev" in cmd)
def _alive(pid: int | None) -> bool:
if pid is None:
return False
if psutil is not None:
return psutil.pid_exists(pid)
try:
os.kill(pid, 0)
return True
except (OSError, ValueError):
return False
def _http_up() -> bool:
try:
with urllib.request.urlopen(f"http://127.0.0.1:{PORT}/", timeout=1.5) as r:
@@ -60,7 +82,7 @@ def is_running() -> bool:
port actually answers (covers Vite started by another process, or a lost
PID file) - not the stricter pattern match `stop()` uses before killing."""
pid = _read_pid()
if pid is not None and psutil.pid_exists(pid):
if _alive(pid):
return True
return _http_up()
@@ -68,6 +90,8 @@ def is_running() -> bool:
def start() -> dict:
if is_running():
return {"status": "already_running"}
if not FRONTEND_DIR.is_dir():
return {"status": "unavailable", "detail": "Vite source is not included in wheel installs"}
npm = _npm()
if not npm:
return {"status": "error", "detail": "npm not found - install Node.js"}
@@ -92,17 +116,20 @@ def start() -> dict:
def stop() -> dict:
pid = _read_pid()
if pid is not None and psutil.pid_exists(pid) and _is_ours(pid):
if _alive(pid) and _is_ours(pid):
try:
proc = psutil.Process(pid)
# npm.cmd -> node -> vite is a multi-hop tree; kill it depth-first
# so the parent doesn't outlive its children as an orphaned shell.
for child in proc.children(recursive=True):
try:
child.terminate()
except Exception:
pass
proc.terminate()
if psutil is not None:
proc = psutil.Process(pid)
# npm.cmd -> node -> vite is a multi-hop tree; kill it depth-first
# so the parent doesn't outlive its children as an orphaned shell.
for child in proc.children(recursive=True):
try:
child.terminate()
except Exception:
pass
proc.terminate()
elif os.name != "nt":
os.kill(pid, 15)
except Exception:
pass
PID_FILE.unlink(missing_ok=True)
+6 -4
View File
@@ -5,9 +5,11 @@ import tempfile
import os
import re
_ROOT = Path(__file__).resolve().parents[2]
UNDERLAY_FILE = _ROOT / "assets/themes/NexusOS-icons-src/nexus-underlay.svg"
RING_FILE = _ROOT / "assets/themes/NexusOS-icons-src/nexus-underlay-ring.svg"
from ..nexus_config import settings
_ROOT = settings.project_root
UNDERLAY_FILE = settings.assets_dir / "themes/NexusOS-icons-src/nexus-underlay.svg"
RING_FILE = settings.assets_dir / "themes/NexusOS-icons-src/nexus-underlay-ring.svg"
ICONS_OUT = Path.home() / ".icons" / "NexusOS"
SIZES = [16, 22, 24, 32, 48, 64, 128]
@@ -18,7 +20,7 @@ _ALLOWED_ROOTS = [
"/opt",
str(Path.home() / ".local/share/icons"),
str(Path.home() / ".icons"),
str(_ROOT / "assets"),
str(settings.assets_dir),
]
_UNDERLAY_FALLBACK = """\
+3 -2
View File
@@ -184,6 +184,7 @@ from .memory.store import store, MemoryItem
from .playbooks.store import playbook_store, PlaybookItem
from .search import needs_web_search, web_search
MEMORY_SERVICE = settings.memory_url
app = FastAPI(title="Synapse Backend", version=VERSION)
@@ -1591,7 +1592,7 @@ async def delete_conversation(conversation_id: str):
# ── Icon branding routes ──────────────────────────────────────────────────────
_REPO_ASSETS = str(Path(__file__).resolve().parents[1] / "assets")
_REPO_ASSETS = str(settings.assets_dir)
_ALLOWED_ICON_ROOTS = [
"/usr/share/icons",
"/usr/share/pixmaps",
@@ -1672,7 +1673,7 @@ async def apply_icon_cache_route():
# and uses the Vite dev server as before.
from fastapi.staticfiles import StaticFiles # noqa: E402
_DIST = Path(__file__).resolve().parent.parent / "interface" / "web" / "dist"
_DIST = settings.web_dist_dir
if _DIST.is_dir():
app.mount("/", StaticFiles(directory=str(_DIST), html=True), name="ui")
+189 -20
View File
@@ -1,7 +1,10 @@
# config.py
from __future__ import annotations
import json
import os
import shutil
from importlib import metadata
from pathlib import Path
from typing import Dict, Any
@@ -12,14 +15,84 @@ try:
except Exception:
pass
# --- PROJECT ROOT ---
PROJECT_ROOT = Path(__file__).resolve().parent.parent
# --- INSTALL / RESOURCE LAYOUT ---
PACKAGE_DIR = Path(__file__).resolve().parent
_CHECKOUT_ROOT = PACKAGE_DIR.parent
SOURCE_CHECKOUT = (
(_CHECKOUT_ROOT / "VERSION").is_file()
and (_CHECKOUT_ROOT / "interface" / "web" / "package.json").is_file()
)
# --- VERSION (single source of truth: the VERSION file at the repo root) ---
# PROJECT_ROOT remains the source checkout for developer installs. In a wheel it
# is the installed package directory; writable state is deliberately elsewhere.
PROJECT_ROOT = Path(os.getenv("NEXUS_PROJECT_ROOT", "")).expanduser() if os.getenv(
"NEXUS_PROJECT_ROOT"
) else (_CHECKOUT_ROOT if SOURCE_CHECKOUT else PACKAGE_DIR)
PROJECT_ROOT = PROJECT_ROOT.resolve()
RESOURCE_ROOT = PROJECT_ROOT if SOURCE_CHECKOUT else PACKAGE_DIR / "_resources"
def _user_dir(env_name: str, windows_leaf: str, xdg_name: str, xdg_fallback: str) -> Path:
override = os.getenv(env_name, "").strip()
if override:
return Path(override).expanduser().resolve()
if os.name == "nt":
base = Path(os.getenv("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
return base / windows_leaf
base = Path(os.getenv(xdg_name, Path.home() / xdg_fallback)).expanduser()
return base / "nexusos"
CONFIG_DIR = _user_dir("NEXUS_CONFIG_DIR", "NexusOS", "XDG_CONFIG_HOME", ".config")
CONFIG_FILE = CONFIG_DIR / "config.json"
def read_user_config() -> dict[str, Any]:
try:
data = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except (OSError, ValueError, TypeError):
return {}
def write_user_config(values: dict[str, Any]) -> None:
"""Atomically persist CLI-managed configuration."""
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
tmp = CONFIG_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(values, indent=2, sort_keys=True) + "\n", encoding="utf-8")
tmp.replace(CONFIG_FILE)
USER_CONFIG = read_user_config()
def _value(key: str, env_name: str, default: Any) -> Any:
raw = os.getenv(env_name)
return raw if raw not in (None, "") else USER_CONFIG.get(key, default)
def _int_value(key: str, env_name: str, default: int) -> int:
try:
return int(_value(key, env_name, default))
except (TypeError, ValueError):
return default
def _configured_path(key: str, env_name: str, default: Path) -> Path:
return Path(str(_value(key, env_name, default))).expanduser().resolve()
# --- VERSION (repo file in a checkout; distribution metadata in a wheel) ---
try:
VERSION = (PROJECT_ROOT / "VERSION").read_text(encoding="utf-8").strip() or "0.0.0"
if SOURCE_CHECKOUT:
VERSION = (PROJECT_ROOT / "VERSION").read_text(encoding="utf-8").strip()
else:
VERSION = metadata.version("nexusos-ai")
except Exception:
VERSION = "0.0.0"
try:
VERSION = (RESOURCE_ROOT / "VERSION").read_text(encoding="utf-8").strip()
except Exception:
VERSION = "0.0.0"
# --- MODEL DEFAULTS ---
# Single source of truth for the three models NexusOS ships with. The installers
@@ -43,11 +116,24 @@ DEFAULT_MEMORY_MODEL = DEFAULT_CHAT_MODEL
DEFAULT_EMBED_MODEL = "nomic-embed-text"
# --- CORE DIRECTORIES ---
DATA_DIR = PROJECT_ROOT / "data"
MODELS_DIR = PROJECT_ROOT / "models"
RUNTIME_DIR = PROJECT_ROOT / "runtime"
_DEFAULT_STATE = PROJECT_ROOT if SOURCE_CHECKOUT else _user_dir(
"NEXUS_HOME", "NexusOS", "XDG_DATA_HOME", ".local/share"
)
STATE_DIR = _configured_path("home", "NEXUS_HOME", _DEFAULT_STATE)
_USE_CHECKOUT_STATE = SOURCE_CHECKOUT and not os.getenv("NEXUS_HOME", "").strip()
DATA_DIR = _configured_path("data_dir", "NEXUS_DATA_DIR", (
PROJECT_ROOT / "data" if _USE_CHECKOUT_STATE else STATE_DIR / "data"
))
MODELS_DIR = _configured_path("models_dir", "NEXUS_MODELS_DIR", (
PROJECT_ROOT / "models" if _USE_CHECKOUT_STATE else STATE_DIR / "models"
))
RUNTIME_DIR = _configured_path("runtime_dir", "NEXUS_RUNTIME_DIR", (
PROJECT_ROOT / "runtime" if _USE_CHECKOUT_STATE else STATE_DIR / "runtime"
))
MEMORY_DIR = PROJECT_ROOT / "synapse" / "memory"
MEMORY_DIR = _configured_path("memory_dir", "NEXUS_MEMORY_DIR", (
PROJECT_ROOT / "synapse" / "memory" if _USE_CHECKOUT_STATE else DATA_DIR
))
LOGS_DIR = RUNTIME_DIR / "logs"
CACHE_DIR = RUNTIME_DIR / "cache"
@@ -57,9 +143,19 @@ TEMP_DIR = RUNTIME_DIR / "tmp"
PLAYBOOK_DIR = DATA_DIR / "playbooks" # YAML playbook files (PlaybookFileStore)
UPLOADS_DIR = DATA_DIR / "uploads"
EXPORTS_DIR = DATA_DIR / "exports"
WEB_DIST_DIR = (
PROJECT_ROOT / "interface" / "web" / "dist"
if SOURCE_CHECKOUT else RESOURCE_ROOT / "web"
)
FRONTEND_SOURCE_DIR = PROJECT_ROOT / "interface" / "web"
ASSETS_DIR = PROJECT_ROOT / "assets" if SOURCE_CHECKOUT else RESOURCE_ROOT / "assets"
SEED_PLAYBOOK_DIR = (
PROJECT_ROOT / "data" / "playbooks"
if SOURCE_CHECKOUT else RESOURCE_ROOT / "playbooks"
)
# --- DATABASE / STORAGE FILES (match your repo) ---
MEMORY_DB = MEMORY_DIR / "memory.db"
MEMORY_DB = _configured_path("memory_db", "NEXUS_MEMORY_DB", MEMORY_DIR / "memory.db")
# --- LOG FILES ---
BACKEND_LOG = RUNTIME_DIR / "backend.log"
@@ -67,7 +163,8 @@ OLLAMA_LOG = LOGS_DIR / "ollama.log"
CHAT_LOG = LOGS_DIR / "chat.log"
# --- ENSURE REQUIRED DIRECTORIES EXIST ---
for d in (
_REQUIRED_DIRS = (
STATE_DIR,
DATA_DIR,
MODELS_DIR,
RUNTIME_DIR,
@@ -78,8 +175,25 @@ for d in (
PLAYBOOK_DIR,
UPLOADS_DIR,
EXPORTS_DIR,
):
d.mkdir(parents=True, exist_ok=True)
MEMORY_DB.parent,
)
def init_state() -> list[Path]:
"""Create writable state and seed playbooks on a first wheel install."""
for directory in _REQUIRED_DIRS:
directory.mkdir(parents=True, exist_ok=True)
copied: list[Path] = []
if SEED_PLAYBOOK_DIR.resolve() != PLAYBOOK_DIR.resolve() and SEED_PLAYBOOK_DIR.is_dir():
for source in SEED_PLAYBOOK_DIR.glob("*.yaml"):
target = PLAYBOOK_DIR / source.name
if not target.exists():
shutil.copy2(source, target)
copied.append(target)
return copied
INITIALIZED_FILES = init_state()
# --- PATH ACCESSOR (fail-fast) ---
def path(name: str) -> Path:
@@ -88,6 +202,9 @@ def path(name: str) -> Path:
"""
mapping = {
"root": PROJECT_ROOT,
"resources": RESOURCE_ROOT,
"state": STATE_DIR,
"config": CONFIG_FILE,
"data": DATA_DIR,
"models": MODELS_DIR,
"runtime": RUNTIME_DIR,
@@ -98,6 +215,8 @@ def path(name: str) -> Path:
"playbooks": PLAYBOOK_DIR,
"uploads": UPLOADS_DIR,
"exports": EXPORTS_DIR,
"web": WEB_DIST_DIR,
"assets": ASSETS_DIR,
"memory_db": MEMORY_DB,
"backend_log": BACKEND_LOG,
"ollama_log": OLLAMA_LOG,
@@ -146,12 +265,19 @@ class Settings:
"""
def __init__(self) -> None:
self.version: str = VERSION
self.source_checkout: bool = SOURCE_CHECKOUT
self.project_root: Path = PROJECT_ROOT
self.resource_root: Path = RESOURCE_ROOT
self.state_dir: Path = STATE_DIR
self.config_file: Path = CONFIG_FILE
self.data_dir: Path = DATA_DIR
self.models_dir: Path = MODELS_DIR
self.runtime_dir: Path = RUNTIME_DIR
self.memory_dir: Path = MEMORY_DIR
self.logs_dir: Path = LOGS_DIR
self.web_dist_dir: Path = WEB_DIST_DIR
self.frontend_source_dir: Path = FRONTEND_SOURCE_DIR
self.assets_dir: Path = ASSETS_DIR
# DB files
self.memory_db: Path = MEMORY_DB
@@ -166,23 +292,57 @@ class Settings:
# should CONNECT. `ollama_bind` keeps the user's literal intent for a
# serve we spawn (0.0.0.0 to expose it on the LAN); `ollama_host` is the
# connectable form for our own requests.
self.ollama_bind: str = os.getenv("OLLAMA_HOST", "") or "127.0.0.1:11434"
self.ollama_host: str = _normalize_ollama_host(
os.getenv("OLLAMA_HOST", "http://127.0.0.1:11434")
self.provider: str = str(_value("provider", "NEXUS_PROVIDER", "ollama"))
# A Nexus provider setting is more specific than the legacy Ollama bind
# variable. This matters on a desktop that has OLLAMA_HOST globally set
# but configures NexusOS to use a different remote inference machine.
provider_url = str(_value("provider_url", "NEXUS_PROVIDER_URL", "")).strip()
configured_host = (
provider_url
or os.getenv("OLLAMA_HOST", "").strip()
or "http://127.0.0.1:11434"
)
self.ollama_timeout: int = int(os.getenv("OLLAMA_TIMEOUT", "120"))
self.ollama_bind: str = configured_host or "127.0.0.1:11434"
self.ollama_host: str = _normalize_ollama_host(
configured_host
)
self.provider_url: str = self.ollama_host
self.manage_ollama: bool = self.provider == "ollama"
self.ollama_timeout: int = _int_value("provider_timeout", "OLLAMA_TIMEOUT", 120)
self.bind_host: str = str(_value(
"bind_host", "NEXUS_BIND_HOST", "127.0.0.1"
))
self.backend_port: int = _int_value("backend_port", "NEXUS_BACKEND_PORT", 8000)
self.memory_port: int = _int_value("memory_port", "NEXUS_MEMORY_PORT", 8001)
self.api_url: str = str(_value(
"api_url", "NEXUS_API", f"http://127.0.0.1:{self.backend_port}"
)).rstrip("/")
self.memory_url: str = str(_value(
"memory_url", "NEXUS_MEMORY_URL", f"http://127.0.0.1:{self.memory_port}"
)).rstrip("/")
def as_dict(self) -> Dict[str, Any]:
return {
"version": self.version,
"source_checkout": self.source_checkout,
"project_root": str(self.project_root),
"resource_root": str(self.resource_root),
"state_dir": str(self.state_dir),
"config_file": str(self.config_file),
"data_dir": str(self.data_dir),
"models_dir": str(self.models_dir),
"runtime_dir": str(self.runtime_dir),
"memory_dir": str(self.memory_dir),
"memory_db": str(self.memory_db),
"web_dist_dir": str(self.web_dist_dir),
"provider": self.provider,
"ollama_host": self.ollama_host,
"ollama_timeout": self.ollama_timeout,
"api_url": self.api_url,
"bind_host": self.bind_host,
"backend_port": self.backend_port,
"memory_port": self.memory_port,
"memory_url": self.memory_url,
}
# --- local-access allowlists (shared by the backend + memory FastAPI apps) ---
@@ -203,8 +363,13 @@ _LOCAL_HOSTS = ["localhost", "127.0.0.1", "[::1]", "::1", "testserver"]
_LOCAL_ORIGINS = [
f"http://{h}:{p}"
for h in ("localhost", "127.0.0.1")
for p in (8000, 5173)
for p in (
_int_value("backend_port", "NEXUS_BACKEND_PORT", 8000),
_int_value("memory_port", "NEXUS_MEMORY_PORT", 8001),
5173,
)
]
_LOCAL_ORIGINS.extend(["capacitor://localhost", "https://localhost"])
ALLOWED_HOSTS = _csv_env("NEXUS_ALLOWED_HOSTS", _LOCAL_HOSTS)
ALLOWED_ORIGINS = _csv_env("NEXUS_ALLOWED_ORIGINS", _LOCAL_ORIGINS)
@@ -250,9 +415,13 @@ settings = Settings()
# explicit exports for static checkers and IDEs
__all__ = ["Settings", "settings", "path", "VERSION",
"DEFAULT_CHAT_MODEL", "DEFAULT_MEMORY_MODEL", "DEFAULT_EMBED_MODEL",
"PROJECT_ROOT", "DATA_DIR", "MODELS_DIR", "RUNTIME_DIR",
"PACKAGE_DIR", "PROJECT_ROOT", "RESOURCE_ROOT", "SOURCE_CHECKOUT",
"STATE_DIR", "CONFIG_DIR", "CONFIG_FILE", "USER_CONFIG",
"read_user_config", "write_user_config", "init_state", "INITIALIZED_FILES",
"DATA_DIR", "MODELS_DIR", "RUNTIME_DIR",
"MEMORY_DIR", "LOGS_DIR", "PLAYBOOK_DIR", "UPLOADS_DIR",
"EXPORTS_DIR", "MEMORY_DB",
"EXPORTS_DIR", "MEMORY_DB", "WEB_DIST_DIR", "FRONTEND_SOURCE_DIR",
"ASSETS_DIR", "SEED_PLAYBOOK_DIR",
"BACKEND_LOG", "OLLAMA_LOG", "CHAT_LOG",
"ALLOWED_HOSTS", "ALLOWED_ORIGINS",
"MAX_REQUEST_BYTES", "MAX_UPLOAD_BYTES", "MAX_PDF_PAGES",
+17 -2
View File
@@ -20,7 +20,9 @@ _log = logging.getLogger("nexus.ollama")
_ollama_manager = None
# Bundled binary ships alongside the project; fall back to system PATH
_BUNDLED_OLLAMA = Path(__file__).resolve().parent.parent / "ollama" / "bin" / "ollama"
_BUNDLED_OLLAMA = settings.project_root / "ollama" / "bin" / (
"ollama.exe" if os.name == "nt" else "ollama"
)
# POSIX: detach the child into its own session so we can signal the whole group.
# Windows has no setsid/killpg — run the child normally and terminate() it.
@@ -340,7 +342,7 @@ class OllamaManager:
self.running = False
self._available = None # see is_available()
self.runtime_dir = Path(runtime_dir) if runtime_dir else Path(__file__).resolve().parent.parent / "runtime"
self.runtime_dir = Path(runtime_dir) if runtime_dir else settings.runtime_dir
(self.runtime_dir / "logs").mkdir(parents=True, exist_ok=True)
self.log_file = self.runtime_dir / "logs" / "ollama.log"
@@ -406,6 +408,10 @@ class OllamaManager:
return env
def is_available(self):
if not settings.manage_ollama:
# A remote provider has no local executable to discover. Availability
# means it is configured; is_running() performs the live probe.
return True
# ponytail: cached for the life of the process. This spawns a subprocess,
# and /status calls it on every poll - the frontend polls continuously,
# so it was a process spawn per tick to answer a question whose answer
@@ -433,6 +439,9 @@ class OllamaManager:
return False
def start(self):
if not settings.manage_ollama:
_log.info("Remote Ollama is externally managed at %s", self._api_base)
return self.is_running()
if not self.is_available():
_log.warning("Ollama not found at %s; skipping startup", _ollama_bin())
return False
@@ -474,6 +483,9 @@ class OllamaManager:
async def start_async(self):
"""Async-safe version of start() for use inside async startup handlers."""
if not settings.manage_ollama:
_log.info("Remote Ollama is externally managed at %s", self._api_base)
return self.is_running()
if not self.is_available():
_log.warning("Ollama not found at %s; skipping startup", _ollama_bin())
return False
@@ -514,6 +526,9 @@ class OllamaManager:
return False
def stop(self):
if not settings.manage_ollama:
_log.info("Not stopping externally managed Ollama at %s", self._api_base)
return False
# Terminate a server we spawned ourselves.
if self.process:
try:
+110
View File
@@ -0,0 +1,110 @@
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def run_cli(tmp_path: Path, *args: str) -> subprocess.CompletedProcess[str]:
env = os.environ.copy()
for key in tuple(env):
if key.startswith("NEXUS_"):
env.pop(key)
env["NEXUS_HOME"] = str(tmp_path / "state")
env["NEXUS_CONFIG_DIR"] = str(tmp_path / "config")
return subprocess.run(
[sys.executable, "-m", "management.cli", *args],
cwd=ROOT,
env=env,
text=True,
capture_output=True,
timeout=30,
check=False,
)
def test_help_exposes_portable_command_tree(tmp_path):
result = run_cli(tmp_path, "--help")
assert result.returncode == 0, result.stderr
for command in ("init", "config", "provider", "doctor", "serve", "models", "chat"):
assert command in result.stdout
def test_legacy_cli_spellings_remain_compatible():
from management.cli import _normalize_legacy_argv
assert _normalize_legacy_argv(["start", "-b"]) == ["start", "backend"]
assert _normalize_legacy_argv(["stop", "--ai"]) == ["stop", "ai"]
assert _normalize_legacy_argv(["logs", "-m", "--follow"]) == ["logs", "memory", "--follow"]
assert _normalize_legacy_argv(["backup", "full"]) == ["backup", "--full"]
assert _normalize_legacy_argv(["restore", "-f"]) == ["restore"]
assert _normalize_legacy_argv(["help"]) == ["--help"]
def test_init_uses_external_state_and_seeds_playbooks(tmp_path):
result = run_cli(tmp_path, "init", "--json")
assert result.returncode == 0, result.stderr
payload = json.loads(result.stdout)
assert Path(payload["state_dir"]) == (tmp_path / "state").resolve()
assert payload["seeded_playbooks"] == len(list((ROOT / "data" / "playbooks").glob("*.yaml")))
assert len(list((tmp_path / "state" / "data" / "playbooks").glob("*.yaml"))) > 0
def test_config_persists_validated_values(tmp_path):
set_result = run_cli(tmp_path, "config", "set", "backend_port", "8123", "--json")
assert set_result.returncode == 0, set_result.stderr
get_result = run_cli(tmp_path, "config", "get", "backend_port", "--json")
assert get_result.returncode == 0, get_result.stderr
assert json.loads(get_result.stdout)["backend_port"] == 8123
invalid = run_cli(tmp_path, "config", "set", "backend_port", "70000")
assert invalid.returncode == 2
assert "between 1 and 65535" in invalid.stderr
def test_remote_provider_configuration_is_explicit(tmp_path):
result = run_cli(
tmp_path, "provider", "use", "remote", "--url", "http://phone-lan:11434", "--json"
)
assert result.returncode == 0, result.stderr
payload = json.loads(result.stdout)
assert payload["provider"] == "ollama-remote"
assert payload["url"] == "http://phone-lan:11434"
values = json.loads((tmp_path / "config" / "config.json").read_text(encoding="utf-8"))
assert values["provider"] == "ollama-remote"
show = run_cli(tmp_path, "provider", "show", "--json")
assert show.returncode == 0, show.stderr
assert json.loads(show.stdout)["url"] == "http://phone-lan:11434"
def test_serve_rejects_invalid_ports_before_startup(tmp_path):
result = run_cli(tmp_path, "serve", "--port", "0")
assert result.returncode == 2
assert "between 1 and 65535" in result.stderr
def test_remote_provider_is_never_stopped_or_force_killed(monkeypatch):
from management import ncp
killed_ports = []
killed_patterns = []
monkeypatch.setattr(ncp.settings, "manage_ollama", False)
monkeypatch.setattr(ncp.settings, "ollama_host", "http://remote.test:11434")
monkeypatch.setattr(ncp, "kill_port", lambda port: killed_ports.append(port) or False)
monkeypatch.setattr(
ncp, "kill_matching", lambda patterns, force=False: killed_patterns.extend(patterns) or 0
)
ncp.stop_ollama()
ncp.cmd_kill()
assert 11434 not in killed_ports
assert "ollama serve" not in killed_patterns