#!/usr/bin/env python3 """Backend for `ncp` subcommands (chat/memory/playbook/history). Not a second CLI — `ncp` is the only entrypoint. This just hits the same REST API the web UI uses, so the terminal matches the general features. httpx and argparse only (both already present); no curses, no framework. """ import argparse import json import sys import httpx BASE = __import__("os").environ.get("NEXUS_API", "http://localhost:8000") def _client(): return httpx.Client(base_url=BASE, timeout=None) def _die_if_down(exc: Exception): if isinstance(exc, (httpx.ConnectError, httpx.ConnectTimeout)): sys.exit(f"Backend not reachable at {BASE}. Start it: ncp start -b") raise exc def iter_chunks(lines): """Yield ('kind', payload) from raw SSE lines. kind is 'chunk' for reply text, else the event name ('meta'/'done'/'error'/'title'/...). Pure so the stream parsing is unit-testable without a live server (see test_nexus_api.py).""" event = "message" for line in lines: if line == "": # blank line ends an event block event = "message" continue if line.startswith("event:"): event = line[6:].strip() elif line.startswith("data:"): data = line[5:].strip() if event in ("message", ""): yield "chunk", json.loads(data) # server json-encodes each token else: yield event, data def cmd_chat(args): body = {"message": " ".join(args.message)} if args.model: body["model"] = args.model try: with _client() as c, c.stream("POST", "/chat/stream", json=body) as r: r.raise_for_status() for kind, payload in iter_chunks(r.iter_lines()): if kind == "chunk": sys.stdout.write(payload) sys.stdout.flush() elif kind == "escalating": sys.stderr.write("\n[escalating to Claude…]\n") elif kind == "error": sys.exit("\n" + json.loads(payload).get("detail", "chat failed")) elif kind == "done": break print() except Exception as e: _die_if_down(e) def cmd_memory(args): try: with _client() as c: if args.action == "list": items = c.get("/memory").json()["items"] if not items: print("No memory facts.") return section = None for m in items: if m["section"] != section: section = m["section"] print(f"\n## {section}") print(f" {m['id'][:8]} {m['text']}") elif args.action == "add": m = c.post("/memory", json={"text": " ".join(args.rest), "section": args.section}).json() print(f"added {m['id'][:8]} to {m['section']}") elif args.action == "rm": c.delete(f"/memory/{args.rest[0]}").raise_for_status() print("deleted") except Exception as e: _die_if_down(e) def cmd_playbook(args): try: with _client() as c: if args.action == "list": pbs = c.get("/playbooks").json()["playbooks"] for i, p in enumerate(pbs): mark = "* " if i == 0 else " " # first = active system prompt tags = f" [{', '.join(p['tags'])}]" if p["tags"] else "" print(f"{mark}{p['id'][:8]} {p['title']}{tags}") elif args.action == "show": p = c.get(f"/playbooks/{args.rest[0]}").json() print(f"# {p['title']}\n\nGoal: {p['goal']}\n\n{p['instructions']}") except Exception as e: _die_if_down(e) def cmd_history(args): try: with _client() as c: params = {"q": args.query} if args.query else {} convs = c.get("/conversations", params=params).json()["conversations"] for cv in convs[:args.limit]: title = cv.get("title") or cv.get("preview") or "(untitled)" print(f" {cv['id'][:8]} {title}") except Exception as e: _die_if_down(e) def main(): p = argparse.ArgumentParser(prog="ncp") sub = p.add_subparsers(dest="cmd", required=True) c = sub.add_parser("chat"); c.add_argument("message", nargs="+") c.add_argument("--model"); c.set_defaults(fn=cmd_chat) m = sub.add_parser("memory"); m.add_argument("action", choices=["list", "add", "rm"]) m.add_argument("rest", nargs="*"); m.add_argument("--section", default="General") m.set_defaults(fn=cmd_memory) pb = sub.add_parser("playbook"); pb.add_argument("action", choices=["list", "show"]) pb.add_argument("rest", nargs="*"); pb.set_defaults(fn=cmd_playbook) 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) if __name__ == "__main__": main()