53eaed4c7f
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
144 lines
5.1 KiB
Python
144 lines
5.1 KiB
Python
#!/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()
|