refactor(management): replace curses TUIs with API-backed ncp subcommands; panel/autostart/install integration
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user