feat(packaging): move the CLI into nexusos_cli and make the wheel self-sufficient

The CLI shipped from `management/`, which also holds desktop-only pieces (the
Tk control panel, the XFCE panel wiring, the shell wrappers). Packaging that
directory meant the wheel either dragged in tkinter or shipped a broken import.
Split it: `nexusos_cli/` is what the wheel ships and what `nexus`/`ncp`/
`nexusos` dispatch to, `management/` keeps the desktop half.

Alongside the move:

* hatch_build.py decides the interface/web/dist include at build time. dist/
  is gitignored, so a static force-include aborts `pip install -e .` on a
  fresh clone - before the reader reaches the `npm run build` step. Editable
  installs now skip a missing dist; wheels and sdists hard-error naming the
  command to run.
* synapse/proc_util.py gives frontend_manager and ncp process inspection and
  termination without psutil, which became an optional extra when the wheel
  landed. It routes around Windows having no signals, where os.kill(pid, 15)
  is an unblockable TerminateProcess rather than a polite request.
* nexusos_cli/monitor.py adds `ncp monitor`, an ASCII dashboard with no curses
  or rich dependency so it works in Termux, plain SSH and Windows Terminal.
  Collector and renderer are separate so tests feed fixtures, no stack needed.
* tests/test_packaging_deps.py fails the gate when synapse or nexusos_cli
  import a distribution pyproject does not declare, and when an optional
  dependency is imported at module scope instead of lazily.
* bin/check.sh now builds the wheel, twine-checks it, and asserts the compiled
  UI and seed playbooks are actually inside it. A wheel that builds but ships
  no dist/ serves a blank page, which only shows up after release.

tests/test_nexus_api.py moves to tests/ with the module it covers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Athena Kaminsky
2026-08-26 08:11:39 -05:00
committed by Athena
co-authored by Claude Opus 5
parent d579502a5b
commit 9104c724c4
25 changed files with 1412 additions and 76 deletions
+458
View File
@@ -0,0 +1,458 @@
"""ASCII dashboard for live NexusOS service / tool / resource stats.
No curses, no rich — pure box-drawing + optional ANSI color so it works in
Termux, plain SSH, and Windows Terminal alike. The collector is separate from
the renderer so tests can feed fixtures without a running stack.
"""
from __future__ import annotations
import json
import shutil
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from synapse.nexus_config import settings
from . import ncp as services
# Box drawing — ASCII fallbacks when the terminal can't do Unicode.
_BOX = {
"tl": "", "tr": "", "bl": "", "br": "",
"h": "", "v": "", "l": "", "r": "",
}
_BOX_ASCII = {
"tl": "+", "tr": "+", "bl": "+", "br": "+",
"h": "-", "v": "|", "l": "+", "r": "+",
}
_FILL = ""
_EMPTY = ""
_FILL_ASCII = "#"
_EMPTY_ASCII = "-"
def _use_unicode() -> bool:
enc = (getattr(__import__("sys").stdout, "encoding", None) or "").lower()
return "utf" in enc or enc in ("cp65001",)
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 _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 _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 _get_json(url: str, timeout: float = 1.5) -> Any | None:
try:
with urllib.request.urlopen(url, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8", errors="replace"))
except Exception:
return None
def _bar(ratio: float, width: int = 20, unicode: bool = True) -> str:
ratio = max(0.0, min(1.0, float(ratio)))
filled = int(round(ratio * width))
fill = _FILL if unicode else _FILL_ASCII
empty = _EMPTY if unicode else _EMPTY_ASCII
return fill * filled + empty * (width - filled)
def _fmt_bytes(n: float | int | None) -> str:
if n is None:
return ""
n = float(n)
for unit in ("B", "K", "M", "G", "T"):
if abs(n) < 1024 or unit == "T":
return f"{n:.0f}{unit}" if unit == "B" else f"{n:.1f}{unit}"
n /= 1024
return f"{n:.1f}T"
def _pid_stats(pids: list[int | None]) -> dict:
"""Aggregate CPU%/RSS for known service PIDs. Soft-depends on psutil."""
live = [int(p) for p in pids if p]
if not live:
return {"cpu_pct": None, "rss": None, "pids": []}
try:
import psutil # type: ignore
except ImportError:
return {"cpu_pct": None, "rss": None, "pids": live}
cpu = 0.0
rss = 0
seen: list[int] = []
for pid in live:
try:
proc = psutil.Process(pid)
cpu += proc.cpu_percent(interval=0.0)
rss += proc.memory_info().rss
seen.append(pid)
except (psutil.Error, ProcessLookupError, ValueError):
continue
return {"cpu_pct": cpu, "rss": rss, "pids": seen}
def _host_stats() -> dict:
try:
import psutil # type: ignore
except ImportError:
return {"cpu_pct": None, "mem_used": None, "mem_total": None, "mem_pct": None}
vm = psutil.virtual_memory()
return {
"cpu_pct": psutil.cpu_percent(interval=0.05),
"mem_used": vm.used,
"mem_total": vm.total,
"mem_pct": vm.percent,
}
def _api_counts(api_url: str) -> dict:
"""Pull cheap inventory counts from the backend when it is up."""
base = api_url.rstrip("/")
out = {
"online": False,
"version": None,
"ollama": None,
"memories": None,
"conversations": None,
"playbooks": None,
"models": None,
"action_tool_policy": None,
}
status = _get_json(base + "/status")
if not isinstance(status, dict):
return out
out["online"] = True
out["version"] = status.get("version")
out["ollama"] = status.get("ollama")
mem = _get_json(base + "/memory")
if isinstance(mem, list):
out["memories"] = len(mem)
elif isinstance(mem, dict) and isinstance(mem.get("memories"), list):
out["memories"] = len(mem["memories"])
conv = _get_json(base + "/conversations")
if isinstance(conv, list):
out["conversations"] = len(conv)
elif isinstance(conv, dict):
items = conv.get("conversations") or conv.get("items") or []
if isinstance(items, list):
out["conversations"] = len(items)
pbs = _get_json(base + "/playbooks")
if isinstance(pbs, list):
out["playbooks"] = len(pbs)
elif isinstance(pbs, dict) and isinstance(pbs.get("playbooks"), list):
out["playbooks"] = len(pbs["playbooks"])
models = _get_json(base + "/models")
if isinstance(models, list):
out["models"] = len(models)
elif isinstance(models, dict):
items = models.get("models") or models.get("items") or []
if isinstance(items, list):
out["models"] = len(items)
settings_payload = _get_json(base + "/settings")
if isinstance(settings_payload, dict):
out["action_tool_policy"] = settings_payload.get("action_tool_policy")
return out
def _toolchain_stats() -> list[dict]:
"""Which run_snippet languages have a host toolchain right now."""
try:
from synapse import code_run
except Exception:
return []
rows = []
for name, spec in code_run.RUN_LANGS.items():
tool = None
try:
tool = spec["tool"]()
except Exception:
tool = None
rows.append({
"lang": name,
"ready": bool(tool),
"tool": tool or None,
"summary": spec.get("summary") or name,
})
return rows
def _recent_tools(log_path: Path, limit: int = 8) -> list[str]:
"""Best-effort scrape of recent tool names from chat.log."""
if not log_path.is_file():
return []
try:
# Read the tail without pulling a multi-MB log into memory.
data = log_path.read_bytes()
if len(data) > 64_000:
data = data[-64_000:]
text = data.decode("utf-8", errors="replace")
except OSError:
return []
found: list[str] = []
for line in reversed(text.splitlines()):
# Chat tool loop yields "__status__<tool>"; logs may also name tools
# in JSON payloads. Keep the match narrow.
if "__status__" in line:
name = line.split("__status__", 1)[-1].strip().split()[0].strip(",\"'")
if name and name not in ("tools",) and name not in found:
found.append(name)
elif '"name":' in line and any(
t in line for t in ("render_preview", "run_snippet", "web_search",
"fetch_url", "remember", "get_time")
):
for t in ("run_snippet", "render_preview", "web_search", "fetch_url",
"remember", "get_time", "search_documents"):
if t in line and t not in found:
found.append(t)
if len(found) >= limit:
break
return found
def collect_snapshot() -> dict:
"""Gather one monitoring frame. Safe when services are down."""
services_payload = _service_status()
pids = [
services_payload.get("backend", {}).get("pid"),
services_payload.get("memory", {}).get("pid"),
services_payload.get("frontend", {}).get("pid"),
]
api = _api_counts(settings.api_url)
return {
"ts": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"),
"version": settings.version,
"services": services_payload,
"api": api,
"host": _host_stats(),
"procs": _pid_stats(pids),
"toolchains": _toolchain_stats(),
"recent_tools": _recent_tools(settings.logs_dir / "chat.log"),
"paths": {
"api_url": settings.api_url,
"memory_url": settings.memory_url,
"runtime_dir": str(settings.runtime_dir),
},
}
def _pad(text: str, width: int) -> str:
# Visual width ≈ len for our ASCII/box content (no wide East-Asian chars).
if len(text) > width:
return text[: width - 1] + "" if width > 1 else text[:width]
return text + " " * (width - len(text))
def _row(box: dict, inner: str, width: int) -> str:
return f"{box['v']} {_pad(inner, width - 4)} {box['v']}"
def _rule(box: dict, width: int, kind: str = "mid") -> str:
h = box["h"] * (width - 2)
if kind == "top":
return f"{box['tl']}{h}{box['tr']}"
if kind == "bot":
return f"{box['bl']}{h}{box['br']}"
return f"{box['l']}{h}{box['r']}"
def _svc_line(name: str, running: bool, detail: str, unicode: bool) -> str:
mark = (_FILL if unicode else _FILL_ASCII) * 3 if running else (_EMPTY if unicode else _EMPTY_ASCII) * 3
state = "UP " if running else "DOWN"
return f"{name:<10} {mark} {state} {detail}"
def render_frame(snapshot: dict, *, width: int | None = None, unicode: bool | None = None) -> str:
"""Turn a snapshot into a single multi-line ASCII panel."""
if unicode is None:
unicode = _use_unicode()
box = _BOX if unicode else _BOX_ASCII
cols = shutil.get_terminal_size((80, 24)).columns if width is None else width
width = max(56, min(100, cols))
lines: list[str] = []
lines.append(_rule(box, width, "top"))
title = f"NexusOS {snapshot.get('version', '')} monitor"
raw_ts = snapshot.get("ts") or ""
# Prefer local clock HH:MM:SS from an ISO stamp; fall back to wall clock.
stamp = ""
if "T" in raw_ts:
try:
stamp = raw_ts.split("T", 1)[1][:8]
except Exception:
stamp = ""
if not stamp:
stamp = datetime.now().strftime("%H:%M:%S")
gap = max(1, width - 4 - len(title) - len(stamp))
header = f"{title}{' ' * gap}{stamp}"
lines.append(_row(box, header, width))
lines.append(_rule(box, width, "mid"))
lines.append(_row(box, "SERVICES", width))
svcs = snapshot.get("services") or {}
for key, label in (("backend", "backend"), ("memory", "memory"), ("frontend", "frontend")):
info = svcs.get(key) or {}
running = bool(info.get("running"))
pid = info.get("pid")
url = info.get("url") or ""
detail = url
if pid:
detail = f"pid {pid} {url}"
lines.append(_row(box, _svc_line(label, running, detail, unicode), width))
provider = svcs.get("provider") or _provider_payload()
pref = f"{provider.get('provider', '?')} @ {provider.get('url', '')}"
lines.append(_row(box, _svc_line("provider", bool(provider.get("reachable")), pref, unicode), width))
lines.append(_rule(box, width, "mid"))
lines.append(_row(box, "RESOURCES", width))
host = snapshot.get("host") or {}
procs = snapshot.get("procs") or {}
cpu = host.get("cpu_pct")
if cpu is not None:
lines.append(_row(
box,
f"host CPU [{_bar(cpu / 100.0, 22, unicode)}] {cpu:5.1f}%",
width,
))
else:
lines.append(_row(box, "host CPU (install psutil for live bars)", width))
mem_pct = host.get("mem_pct")
if mem_pct is not None:
lines.append(_row(
box,
f"host MEM [{_bar(mem_pct / 100.0, 22, unicode)}] "
f"{_fmt_bytes(host.get('mem_used'))} / {_fmt_bytes(host.get('mem_total'))}",
width,
))
proc_cpu = procs.get("cpu_pct")
proc_rss = procs.get("rss")
if proc_cpu is not None or proc_rss is not None:
lines.append(_row(
box,
f"nexus cpu={proc_cpu if proc_cpu is not None else '':>5} "
f"rss={_fmt_bytes(proc_rss)} pids={','.join(str(p) for p in (procs.get('pids') or [])) or ''}",
width,
))
lines.append(_rule(box, width, "mid"))
lines.append(_row(box, "DATA / TOOLS", width))
api = snapshot.get("api") or {}
if api.get("online"):
policy = api.get("action_tool_policy") or ""
lines.append(_row(
box,
f"api UP v{api.get('version') or '?'} ollama={api.get('ollama') or ''} "
f"tools={policy}",
width,
))
lines.append(_row(
box,
f"memories={_n(api.get('memories'))} "
f"chats={_n(api.get('conversations'))} "
f"playbooks={_n(api.get('playbooks'))} "
f"models={_n(api.get('models'))}",
width,
))
else:
lines.append(_row(box, "api DOWN — start with: nexus start", width))
recent = snapshot.get("recent_tools") or []
lines.append(_row(
box,
"recent " + (", ".join(recent) if recent else "(none in chat.log)"),
width,
))
lines.append(_rule(box, width, "mid"))
lines.append(_row(box, "RUN TOOLCHAINS (run_snippet)", width))
chains = snapshot.get("toolchains") or []
if not chains:
lines.append(_row(box, "(code_run unavailable)", width))
else:
# Pack ready/missing into one or two compact lines.
ready = [c["lang"] for c in chains if c.get("ready")]
missing = [c["lang"] for c in chains if not c.get("ready")]
lines.append(_row(
box,
f"ready {', '.join(ready) if ready else ''}",
width,
))
lines.append(_row(
box,
f"missing {', '.join(missing) if missing else ''}",
width,
))
lines.append(_rule(box, width, "bot"))
return "\n".join(lines)
def _n(value) -> str:
return "" if value is None else str(value)
def run_monitor(*, interval: float = 1.5, once: bool = False, json_output: bool = False) -> int:
"""Print one frame, or refresh in place until interrupted."""
clear = "\033[H\033[J"
first = True
while True:
snap = collect_snapshot()
if json_output:
print(json.dumps(snap, indent=2, sort_keys=True))
else:
frame = render_frame(snap)
if once or not first:
# Replacing the screen keeps the panel stable; first frame of a
# live session also clears so leftover shell output doesn't mix.
if not once:
print(clear + frame, end="", flush=True)
else:
print(frame)
else:
print(clear + frame, end="", flush=True)
first = False
if once:
return 0
try:
time.sleep(max(0.3, float(interval)))
except KeyboardInterrupt:
print()
return 130