diff --git a/backend/openbot_cli/README.md b/backend/openbot_cli/README.md new file mode 100644 index 0000000..8109803 --- /dev/null +++ b/backend/openbot_cli/README.md @@ -0,0 +1,110 @@ +# OpenBot Terminal Client + +A command-line frontend for an running OpenBot server — a terminal alternative to +the web UI. It reuses the **existing** OpenBot REST + Server-Sent-Events API +(`/api/v1/...`); it does not invent a new protocol. The only out-of-tree call is +GitHub issues, which the server does not proxy, so the client talks to +`api.github.com` directly. + +## Install + +The client lives in the backend package. From the `backend/` directory: + +```bash +uv pip install -e . # or: pip install -e . +``` + +This installs the `openbot` console script (a click group named `openbot`). + +Runtime deps: `httpx` (already in the backend) and `click>=8.1`. + +## Connect + +Save the server URL once (optional — every command also accepts `--base-url` / +`--api-key`, or the `OPENBOT_URL` / `OPENBOT_API_KEY` env vars): + +```bash +openbot connect http://127.0.0.1:8000 --api-key YOUR_KEY +``` + +The config is stored at `~/.config/openbot-cli/config.json`. + +## Commands + +| Command | What it does | +| --- | --- | +| `openbot connect [--api-key KEY]` | Save server URL (and optional key) for later commands. | +| `openbot threads [list]` | List threads (title, last activity, default bot, active flag). | +| `openbot thread [watch]` | Print a thread's messages; add `watch` to follow live SSE events. | +| `openbot send "" [--bot @handle]` | Post a message; `--bot` (repeatable) `@`-mentions a bot. | +| `openbot bots` | List bots (handle, name, active/disabled state, description). | +| `openbot runs []` | List runs; pass a `` id to scope to one thread. | +| `openbot run ` | Show one run and its event log (useful for streaming deltas). | +| `openbot issues [--repo regnull/openbot] [--state open\|closed\|all]` | List GitHub issues (fetched from the GitHub REST API). | + +Every command accepts `--json` to emit the raw API payload. + +## Examples + +```bash +# List threads and pick one +openbot threads + +# Read a thread +openbot thread 9f3a1c2e + +# Follow it live (prints new messages + run progress as they arrive) +openbot thread 9f3a1c2e watch + +# Ask the default bot a question; @-mention the engineer bot explicitly +openbot send 9f3a1c2e "Refactor cli.py and run the tests" --bot @engineer + +# See what the bots are doing +openbot bots +openbot runs 9f3a1c2e + +# Triage the upstream project +openbot issues --repo regnull/openbot --state open +``` + +## Streaming / `watch` + +`thread watch` opens an SSE connection to `/api/v1/events?thread_id=` +and prints, as they arrive: + +- new `message.created` events (formatted like a normal thread message), +- `run.updated` status lines, +- `run.event` streaming text deltas (printed inline, like a typing bot). + +Press `Ctrl+C` to disconnect. + +## Authentication + +If the server requires an API key (`OPENBOT_API_KEY` setting), pass it via +`--api-key`, the `OPENBOT_API_KEY` env var, or `openbot connect --api-key`. +The `/health` probe used by `connect` is unauthenticated, so a connection test +works even before a key is configured. + +## GitHub issues + +`openbot issues` calls `https://api.github.com/repos/{repo}/issues`. An optional +`GITHUB_TOKEN` (or the OpenBot API key) raises the GitHub rate limit; it is not +required for small lists. Pull requests returned by the GitHub endpoint are +filtered out so you see only issues. + +## Testing + +Unit tests mock the HTTP client (no live server required): + +```bash +cd backend && uv run pytest tests/test_openbot_cli.py +``` + +## Credits / prior art + +The `thread`/`runs`/`watch` model is inspired by run/transcript UIs such as +[harnlang.com sessions](https://harnlang.com/sessions.html) and the +[kodr](https://github.com/paulkohler/kodr) CLI run/scratchpad conventions, +and the session/scratchpad/resume model described at +[terrarium.kohaku-lab.org](https://terrarium.kohaku-lab.org/en/docs/guides/sessions). +Code is original; no third-party code is vendored. diff --git a/backend/openbot_cli/__init__.py b/backend/openbot_cli/__init__.py new file mode 100644 index 0000000..f013307 --- /dev/null +++ b/backend/openbot_cli/__init__.py @@ -0,0 +1,11 @@ +"""OpenBot Terminal Client — a command-line frontend for an OpenBot server. + +This package is a terminal (CLI) alternative to the OpenBot web UI: it talks to +the same REST + Server-Sent-Events API the browser app uses, so it never +invents a new protocol. It is intentionally separate from the server's +``openbot`` package and from any other CLI experiments in the tree. +""" + +from __future__ import annotations + +__version__ = "0.1.0" diff --git a/backend/openbot_cli/cli.py b/backend/openbot_cli/cli.py new file mode 100644 index 0000000..3c755ba --- /dev/null +++ b/backend/openbot_cli/cli.py @@ -0,0 +1,235 @@ +"""Click-based command line interface for the OpenBot terminal client. + +Commands mirror the core web-UI flows against a running OpenBot server: + + openbot connect [--api-key KEY] Save connection settings + openbot threads [list] List threads + openbot thread [watch] Show a thread (or live-follow it) + openbot send "" [--bot @h] Post a message, optionally @-ing a bot + openbot bots List bots + openbot runs [] List runs (optionally for one thread) + openbot issues [--repo regnull/openbot] List GitHub issues + +This is a frontend only — every command calls the existing ``/api/v1`` REST and +SSE endpoints. See README.md in this package for a full usage guide. +""" + +from __future__ import annotations + +import json +import os +import sys + +import click + +from openbot_cli.client import GitHubIssuesClient, OpenBotClient +from openbot_cli.config import ( + CONFIG_PATH, + ConfigError, + load_config, + resolve, + save_config, +) +from openbot_cli.formatting import ( + format_bots, + format_issues, + format_run_detail, + format_runs, + format_thread_detail, + format_threads, +) + + +def _make_client(base_url: str, api_key: str | None) -> OpenBotClient: + return OpenBotClient(base_url=base_url, api_key=api_key) + + +@click.group() +@click.option("--base-url", envvar="OPENBOT_URL", default=None, help="OpenBot base URL (env: OPENBOT_URL).") +@click.option("--api-key", envvar="OPENBOT_API_KEY", default=None, help="API key (env: OPENBOT_API_KEY).") +@click.pass_context +def cli(ctx: click.Context, base_url: str | None, api_key: str | None) -> None: + """OpenBot terminal client — talk to your bots and threads from the shell.""" + ctx.ensure_object(dict) + # If a client was injected (tests, embedding, DI), use it as-is and skip the + # config-resolve + client-build path so the injected one is not clobbered. + if "client" in ctx.obj: + return + try: + url, key = resolve(base_url, api_key) + except ConfigError as e: + raise click.ClickException(str(e)) from e + ctx.obj["base_url"] = url + ctx.obj["api_key"] = key + ctx.obj["client"] = _make_client(url, key) + + +@cli.command("connect") +@click.argument("url", required=False) +@click.option("--api-key", default=None, help="API key to store alongside the URL.") +@click.option("--config", "config_path", default=None, type=click.Path(), help="Config file (advanced).") +def connect(url: str | None, api_key: str | None, config_path: str | None) -> None: + """Save the OpenBot server URL (and optional API key) for future commands.""" + path = CONFIG_PATH if config_path is None else __import__("pathlib").Path(config_path) + data = load_config(path) + if url: + data["base_url"] = url.rstrip("/") + if api_key: + data["api_key"] = api_key + saved = save_config(data, path) + click.echo(f"Saved connection to {data['base_url']} ({saved})") + + +@cli.command("threads") +@click.argument("subcommand", required=False, type=click.Choice(["list"])) +@click.option("--json", "as_json", is_flag=True, help="Emit raw JSON.") +@click.pass_context +def threads(ctx: click.Context, subcommand: str | None, as_json: bool) -> None: + """List threads (alias: `threads list`).""" + client: OpenBotClient = ctx.obj["client"] + data = client.list_threads() + if as_json: + click.echo(json.dumps(data, indent=2, default=str)) + return + click.echo(format_threads(data)) + + +@cli.command("thread") +@click.argument("thread_id") +@click.argument("subcommand", required=False, type=click.Choice(["watch"])) +@click.option("--limit", default=50, show_default=True, help="Messages to load.") +@click.option("--json", "as_json", is_flag=True, help="Emit raw JSON.") +@click.pass_context +def thread(ctx: click.Context, thread_id: str, subcommand: str | None, limit: int, as_json: bool) -> None: + """Show a thread's messages. Add `watch` to follow live events.""" + client: OpenBotClient = ctx.obj["client"] + detail = client.get_thread(thread_id, limit=limit) + if as_json: + click.echo(json.dumps(detail, indent=2, default=str)) + return + click.echo(format_thread_detail(detail)) + if subcommand == "watch": + click.echo("\n--- watching (Ctrl+C to stop) ---") + try: + _watch(client, thread_id) + except KeyboardInterrupt: + click.echo("\nDisconnected.") + + +def _watch(client: OpenBotClient, thread_id: str) -> None: + for item in client.stream_events(thread_id=thread_id): + event = item.get("event", "?") + data = item.get("data", item) + if event == "message.created": + from openbot_cli.formatting import format_message + + msg = data if isinstance(data, dict) else {} + click.echo(format_message(msg)) + click.echo("─" * 60) + elif event == "run.updated": + status = (data or {}).get("status", "?") + click.echo(f"[run] {status}") + elif event == "run.event": + payload = (data or {}).get("payload") or {} + delta = payload.get("delta") + if delta: + click.echo(delta, nl=False) + else: + click.echo(f"[{event}]") + + +@cli.command("send") +@click.argument("thread_id") +@click.argument("message") +@click.option("--bot", "bots", multiple=True, help="Bot handle to @-mention (repeatable).") +@click.option("--from", "sender", default=None, help="Sender handle (default: you/human).") +@click.option("--json", "as_json", is_flag=True, help="Emit raw JSON.") +@click.pass_context +def send(ctx: click.Context, thread_id: str, message: str, bots: tuple[str, ...], + sender: str | None, as_json: bool) -> None: + """Send MESSAGE to THREAD_ID. Use --bot @handle to address a bot.""" + client: OpenBotClient = ctx.obj["client"] + to = [b.lstrip("@") for b in bots] + result = client.send_message(thread_id, message, to=to, sender=sender) + if as_json: + click.echo(json.dumps(result, indent=2, default=str)) + return + msg = result["message"] + click.echo(f"Posted as {msg['sender_name']} at {msg.get('created_at')}") + if result.get("addressed"): + click.echo(f"Addressed: {', '.join('@' + a for a in result['addressed'])}") + if result.get("unaddressed"): + click.echo("(no bots were addressed — message may go unread)") + + +@cli.command("bots") +@click.option("--json", "as_json", is_flag=True, help="Emit raw JSON.") +@click.pass_context +def bots(ctx: click.Context, as_json: bool) -> None: + """List bots on the connected server.""" + client: OpenBotClient = ctx.obj["client"] + data = client.list_bots() + if as_json: + click.echo(json.dumps(data, indent=2, default=str)) + return + click.echo(format_bots(data)) + + +@cli.command("runs") +@click.argument("thread_id", required=False) +@click.option("--json", "as_json", is_flag=True, help="Emit raw JSON.") +@click.pass_context +def runs(ctx: click.Context, thread_id: str | None, as_json: bool) -> None: + """List runs, optionally for one THREAD_ID.""" + client: OpenBotClient = ctx.obj["client"] + data = client.list_runs(thread_id) + if as_json: + click.echo(json.dumps(data, indent=2, default=str)) + return + click.echo(format_runs(data)) + + +@cli.command("run") +@click.argument("run_id") +@click.option("--json", "as_json", is_flag=True, help="Emit raw JSON.") +@click.pass_context +def run(ctx: click.Context, run_id: str, as_json: bool) -> None: + """Show one RUN_ID with its event log.""" + client: OpenBotClient = ctx.obj["client"] + detail = client.get_run(run_id) + if as_json: + click.echo(json.dumps(detail, indent=2, default=str)) + return + click.echo(format_run_detail(detail)) + + +@cli.command("issues") +@click.option("--repo", default="regnull/openbot", show_default=True, help="owner/name repository.") +@click.option("--state", default="open", show_default=True, type=click.Choice(["open", "closed", "all"])) +@click.option("--json", "as_json", is_flag=True, help="Emit raw JSON.") +@click.pass_context +def issues(ctx: click.Context, repo: str, state: str, as_json: bool) -> None: + """List GitHub issues for REPO (fetched from the GitHub REST API).""" + token = ctx.obj.get("api_key") or os.environ.get("GITHUB_TOKEN") + client = GitHubIssuesClient(token=token) + data = client.list_issues(repo=repo, state=state) + # GitHub's issues endpoint also returns PRs; filter them out for a clean list. + data = [it for it in data if "pull_request" not in it] + if as_json: + click.echo(json.dumps(data, indent=2, default=str)) + return + click.echo(format_issues(data)) + + +def main() -> int: + """Console-script entry point.""" + try: + cli() # pragma: no cover - click drives sys.exit + except click.ClickException as e: + e.show() + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/openbot_cli/client.py b/backend/openbot_cli/client.py new file mode 100644 index 0000000..e58da5a --- /dev/null +++ b/backend/openbot_cli/client.py @@ -0,0 +1,208 @@ +"""Thin HTTP/SSE client for the OpenBot REST API and the GitHub issues API. + +Everything here is a thin wrapper around endpoints the server already exposes +under ``/api/v1``; there is no bespoke client->server protocol. The only +out-of-tree call is ``GET /repos/{owner}/{name}/issues`` against +``api.github.com``, because the server does not proxy GitHub issues. +""" + +from __future__ import annotations + +import json +from collections.abc import Iterator +from typing import Any + +import httpx + +DEFAULT_BASE_URL = "http://127.0.0.1:8000" +GITHUB_API_BASE = "https://api.github.com" + +# Event names the server publishes over the /events SSE stream. Discovering these +# in the bus publish sites (runtime/delivery.py, runtime/runner.py, runtime/actors.py) +# keeps this client from inventing its own vocabulary. +EVENT_MESSAGE_CREATED = "message.created" +EVENT_RUN_UPDATED = "run.updated" +EVENT_RUN_EVENT = "run.event" +EVENT_WAITERS_UPDATED = "waiters.updated" +EVENT_BOTS_UPDATED = "bots.updated" + + +class OpenBotClientError(RuntimeError): + """Raised when the server returns a non-2xx status.""" + + +class OpenBotClient: + """Minimal client for the OpenBot ``/api/v1`` REST surface.""" + + def __init__( + self, + base_url: str = DEFAULT_BASE_URL, + api_key: str | None = None, + *, + client: httpx.Client | None = None, + timeout: float = 30.0, + ) -> None: + self.base_url = base_url.rstrip("/") + self.api_key = api_key + if client is not None: + self._client = client + return + headers = {"Accept": "application/json"} + if api_key: + headers["X-API-Key"] = api_key + self._client = httpx.Client( + base_url=self.base_url, headers=headers, timeout=timeout, follow_redirects=True + ) + + # -- low level --------------------------------------------------------- + + def _request(self, method: str, path: str, **kwargs: Any) -> Any: + resp = self._client.request(method, path, **kwargs) + if resp.status_code >= 400: + raise OpenBotClientError( + f"{method} {path} -> {resp.status_code}: {resp.text[:300]}" + ) + if resp.status_code == 204 or not resp.content: + return None + return resp.json() + + # -- health / connect -------------------------------------------------- + + def ping(self) -> dict[str, Any]: + """Probe the server. The /health route is on the public (unauthenticated) router.""" + return self._request("GET", "/api/v1/health") + + # -- threads ----------------------------------------------------------- + + def list_threads(self) -> list[dict[str, Any]]: + return self._request("GET", "/api/v1/threads") + + def get_thread(self, thread_id: str, limit: int = 50) -> dict[str, Any]: + return self._request("GET", f"/api/v1/threads/{thread_id}", params={"limit": limit}) + + def create_thread( + self, + title: str = "", + handles: list[str] | None = None, + default_bot: str | None = None, + ) -> dict[str, Any]: + body: dict[str, Any] = {"title": title, "handles": handles or []} + if default_bot: + body["default_bot_handle"] = default_bot + return self._request("POST", "/api/v1/threads", json=body) + + def send_message( + self, + thread_id: str, + content: str, + to: list[str] | None = None, + sender: str | None = None, + ) -> dict[str, Any]: + body: dict[str, Any] = {"content": content, "to": to or []} + if sender: + body["from"] = sender + return self._request("POST", f"/api/v1/threads/{thread_id}/messages", json=body) + + # -- bots / runs ------------------------------------------------------- + + def list_bots(self) -> list[dict[str, Any]]: + return self._request("GET", "/api/v1/bots") + + def list_runs(self, thread_id: str | None = None) -> list[dict[str, Any]]: + params = {"thread_id": thread_id} if thread_id else {} + return self._request("GET", "/api/v1/runs", params=params) + + def get_run(self, run_id: str) -> dict[str, Any]: + return self._request("GET", f"/api/v1/runs/{run_id}") + + # -- SSE --------------------------------------------------------------- + + def stream_events(self, thread_id: str | None = None) -> Iterator[dict[str, Any]]: + """Yield parsed SSE payloads from ``/api/v1/events`` until disconnected. + + Each yielded item is ``{"event": , **data}``. Use a ``break`` from + the caller to stop streaming. + """ + headers = {"Accept": "text/event-stream"} + if self.api_key: + headers["X-API-Key"] = self.api_key + with self._client.stream( + "GET", + "/api/v1/events", + params={"thread_id": thread_id} if thread_id else {}, + headers=headers, + timeout=None, + ) as resp: + resp.raise_for_status() + event = "message" + for line in resp.iter_lines(): + if not line or line.startswith(":"): + continue # comment / ping + if line.startswith("event:"): + event = line[len("event:") :].strip() + continue + if not line.startswith("data:"): + continue + data_str = line[len("data:") :].strip() + try: + data = json.loads(data_str) + except json.JSONDecodeError: + data = {"raw": data_str} + if isinstance(data, dict): + data.setdefault("event", event) + yield data + event = "message" + + # -- GitHub issues (no server endpoint exists) ------------------------- + + def list_github_issues( + self, repo: str = "regnull/openbot", *, state: str = "open", per_page: int = 30 + ) -> list[dict[str, Any]]: + """Fetch issues directly from the GitHub REST API. + + The OpenBot server does not proxy GitHub, so this hits + ``https://api.github.com/repos/{repo}/issues``. A token in + ``GITHUB_TOKEN`` is optional and only raises the rate limit. + """ + url = f"{GITHUB_API_BASE}/repos/{repo}/issues" + headers = {"Accept": "application/vnd.github+json"} + token = self._github_token() + if token: + headers["Authorization"] = f"Bearer {token}" + resp = httpx.get( + url, params={"state": state, "per_page": per_page}, headers=headers, timeout=30.0 + ) + if resp.status_code >= 400: + raise OpenBotClientError(f"github issues {resp.status_code}: {resp.text[:300]}") + return resp.json() + + @staticmethod + def _github_token() -> str | None: + import os + + return os.environ.get("GITHUB_TOKEN") + + +class GitHubIssuesClient: + """Standalone client for GitHub issues, used when only ``issues`` is needed. + + Kept separate so the import surface of :class:`OpenBotClient` stays server- + focused; the CLI wires the two together. + """ + + def __init__(self, token: str | None = None) -> None: + self._token = token + + def list_issues( + self, repo: str = "regnull/openbot", *, state: str = "open", per_page: int = 30 + ) -> list[dict[str, Any]]: + url = f"{GITHUB_API_BASE}/repos/{repo}/issues" + headers = {"Accept": "application/vnd.github+json"} + if self._token: + headers["Authorization"] = f"Bearer {self._token}" + resp = httpx.get( + url, params={"state": state, "per_page": per_page}, headers=headers, timeout=30.0 + ) + if resp.status_code >= 400: + raise OpenBotClientError(f"github issues {resp.status_code}: {resp.text[:300]}") + return resp.json() diff --git a/backend/openbot_cli/config.py b/backend/openbot_cli/config.py new file mode 100644 index 0000000..7fb8d31 --- /dev/null +++ b/backend/openbot_cli/config.py @@ -0,0 +1,46 @@ +"""Persistent connection config for the OpenBot terminal client. + +Stored as a tiny JSON file in the user's config dir so ``openbot connect`` only +needs to run once per machine. The schema is intentionally flat and forward +compatible — unknown keys are preserved on write. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + +DEFAULT_BASE_URL = "http://127.0.0.1:8000" +CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "openbot-cli" +CONFIG_PATH = CONFIG_DIR / "config.json" + + +class ConfigError(RuntimeError): + pass + + +def load_config(path: Path | None = None) -> dict[str, Any]: + path = path or CONFIG_PATH + if not path.exists(): + return {"base_url": DEFAULT_BASE_URL, "api_key": None} + try: + data = json.loads(path.read_text()) + except (json.JSONDecodeError, OSError) as e: # pragma: no cover - defensive + raise ConfigError(f"could not read config {path}: {e}") from e + data.setdefault("base_url", DEFAULT_BASE_URL) + return data + + +def save_config(data: dict[str, Any], path: Path | None = None) -> Path: + path = path or CONFIG_PATH + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, indent=2)) + return path + + +def resolve(base_url: str | None, api_key: str | None, path: Path | None = None) -> tuple[str, str | None]: + """Merge explicit flags over stored config; returns (base_url, api_key).""" + cfg = load_config(path) + return (base_url or cfg["base_url"]), (api_key or cfg.get("api_key")) diff --git a/backend/openbot_cli/formatting.py b/backend/openbot_cli/formatting.py new file mode 100644 index 0000000..2a3942f --- /dev/null +++ b/backend/openbot_cli/formatting.py @@ -0,0 +1,141 @@ +"""Human-readable rendering helpers for OpenBot API payloads. + +Pure functions: no I/O, no terminal side effects beyond what the caller prints. +Keeping them separate makes them trivially unit-testable without a server. +""" + +from __future__ import annotations + +from datetime import UTC, datetime + + +def format_ts(value: str | None) -> str: + """Render an ISO timestamp in the local timezone, or a dash when missing.""" + if not value: + return "—" + try: + dt = datetime.fromisoformat(value) + except (ValueError, OSError): + return value + if dt.tzinfo is None: + dt = dt.replace(tzinfo=UTC) + return dt.astimezone().strftime("%Y-%m-%d %H:%M") + + +def format_threads(threads: list[dict]) -> str: + if not threads: + return "No threads found." + lines = [] + for t in threads: + title = (t.get("title") or "(untitled)")[:60] + updated = format_ts(t.get("last_message_at") or t.get("updated_at")) + bot = t.get("default_bot_handle") or "—" + active = "⚡" if t.get("active") else " " + tid = t["id"] + lines.append(f"{active} {tid[:8]} {title:<60s} {updated} @{bot}") + return "\n".join(lines) + + +def format_thread_detail(detail: dict) -> str: + title = detail.get("title") or "(untitled)" + bot = detail.get("default_bot_handle") or "—" + out = [f"Thread: {title}", f"Default bot: @{bot}"] + if detail.get("participants"): + parts = ", ".join(f"@{p['handle']} ({p['kind']})" for p in detail["participants"]) + out.append(f"Participants: {parts}") + if detail.get("has_more"): + out.append("(more messages available — use --limit to load more)") + out.append("─" * 60) + for m in detail.get("messages", []): + out.append(format_message(m)) + out.append("─" * 60) + return "\n".join(out) + + +def format_message(m: dict) -> str: + sender = m.get("sender_name") or m.get("sender_actor_id", "?") + ts = format_ts(m.get("created_at")) + content = m.get("content", "") + header = f"[{ts}] {sender}" + if m.get("mentions"): + header += " → " + " ".join(f"@{h}" for h in m["mentions"]) + if len(content) > 500: + content = content[:500] + f"\n... [{len(content) - 500} more chars]" + return f"{header}:\n{content}" + + +def format_bots(bots: list[dict]) -> str: + if not bots: + return "No bots found." + lines = [] + for b in bots: + state = "active" if b.get("active") else ("disabled" if not b.get("enabled") else "idle") + lines.append(f"{b.get('icon', '🤖')} {b['handle']:<20s} {b.get('name', ''):<24s} [{state}]") + if b.get("description"): + lines.append(f" {b['description']}") + return "\n".join(lines) + + +def format_runs(runs: list[dict]) -> str: + if not runs: + return "No runs found." + lines = [] + for r in runs: + created = format_ts(r.get("created_at")) + actor = r.get("actor_id", "?")[:8] + status = r.get("status", "?") + tokens = r.get("total_tokens") + tok = f"{tokens} tok" if tokens is not None else "— tok" + cost = r.get("cost_usd") + cost_s = f"${cost:.4f}" if cost is not None else "—" + lines.append( + f"{r['id'][:8]} {created} actor={actor} {status:<14s} {tok:<12s} {cost_s}" + ) + return "\n".join(lines) + + +def format_run_detail(run: dict) -> str: + out = [ + f"Run: {run['id']}", + f"Status: {run.get('status')}", + f"Actor: {run.get('actor_id')}", + f"Created: {format_ts(run.get('created_at'))}", + f"Started: {format_ts(run.get('started_at'))}", + f"Finished: {format_ts(run.get('finished_at'))}", + f"Tokens: {run.get('total_tokens')} Cost: {run.get('cost_usd')}", + ] + if run.get("error"): + out.append(f"Error: {run['error']}") + events = run.get("events") or [] + if events: + out.append("─" * 60) + out.append(f"Events ({len(events)}):") + for ev in events: + out.append(f" [{ev.get('seq')}] {ev.get('type')}: {short_payload(ev.get('payload'))}") + return "\n".join(out) + + +def short_payload(payload: dict | None) -> str: + if not payload: + return "" + # Surface the streaming text delta when present; otherwise a compact view. + if "delta" in payload: + return str(payload["delta"])[:120] + if "content" in payload: + return str(payload["content"])[:120] + return str(payload)[:120] + + +def format_issues(issues: list[dict]) -> str: + if not issues: + return "No issues found." + lines = [] + for it in issues: + number = it.get("number") + title = (it.get("title") or "").replace("\n", " ") + labels = ", ".join(lbl.get("name", "") for lbl in it.get("labels", [])) + state = it.get("state", "?") + lines.append(f"#{number} [{state}] {title}") + if labels: + lines.append(f" labels: {labels}") + return "\n".join(lines) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 11b418a..94e0bde 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -28,11 +28,16 @@ dependencies = [ "cryptography>=50.0.1", "httpx2>=2.12", "pyyaml>=6.0", + "click>=8.1", # OpenBot terminal client (backend/openbot_cli) ] [dependency-groups] dev = ["pytest>=8", "pytest-asyncio>=0.24", "ruff>=0.6", "asgi-lifespan>=2.1"] +[project.scripts] +# Terminal client (backend/openbot_cli) — a CLI/TUI alternative to the web UI. +openbot = "openbot_cli.cli:cli" + [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests"] diff --git a/backend/tests/test_openbot_cli.py b/backend/tests/test_openbot_cli.py new file mode 100644 index 0000000..0fc11f4 --- /dev/null +++ b/backend/tests/test_openbot_cli.py @@ -0,0 +1,326 @@ +"""Tests for the OpenBot terminal client. + +All HTTP is mocked via ``httpx.MockTransport`` — no live server is required. The +``OpenBotClient`` is constructed with a prebuilt ``httpx.Client`` so the tests +exercise the request/parse logic without sockets, and ``stream_events`` is +monkeypatched for the ``watch`` path. +""" + +from __future__ import annotations + +import json +from collections.abc import Iterator + +import httpx +import pytest +from click.testing import CliRunner +from httpx import SyncByteStream + +from openbot_cli import cli as cli_mod +from openbot_cli.client import GitHubIssuesClient, OpenBotClient, OpenBotClientError + + +class _SS(SyncByteStream): + """Wrap a byte iterator as an httpx SyncByteStream for mocked streams.""" + + def __init__(self, it): + self._it = it + + def __iter__(self): + return iter(self._it) + + +def _client(handler) -> OpenBotClient: + # MockTransport responses aren't bound to their request, which makes httpx's + # cookie extraction blow up ("unknown url type"). Bind it so the responses + # behave like real ones — production code never hits this path. The injected + # httpx.Client must carry base_url so relative paths resolve, mirroring how + # OpenBotClient configures the client it builds itself. + def _wrap(req: httpx.Request): + resp = handler(req) + resp.request = req + return resp + + transport = httpx.MockTransport(_wrap) + http_client = httpx.Client(base_url="http://test", transport=transport) + return OpenBotClient(base_url="http://test", api_key="k", client=http_client) + + +def _json_response(payload, status=200): + return httpx.Response(status, json=payload) + + +# --------------------------------------------------------------------------- +# Client unit tests +# --------------------------------------------------------------------------- + +def test_list_threads_parses_payload(): + sample = [ + { + "id": "thread_abc", + "title": "Plan Rome trip", + "last_message_at": "2026-10-20T10:00:00Z", + "default_bot_handle": "eng", + "active": True, + } + ] + c = _client(lambda req: _json_response(sample) if req.url.path == "/api/v1/threads" else _json_response({})) + assert c.list_threads() == sample + + +def test_get_thread_uses_limit_param(): + captured = {} + + def handler(req): + if req.url.path.startswith("/api/v1/threads/") and req.method == "GET": + captured["params"] = dict(req.url.params) + return _json_response({"id": "t1", "messages": [], "participants": []}) + return _json_response({}, status=404) + + c = _client(handler) + detail = c.get_thread("t1", limit=12) + assert detail["id"] == "t1" + assert captured["params"]["limit"] == "12" + + +def test_send_message_posts_body_and_returns_addressed(): + captured = {} + + def handler(req): + if req.url.path.endswith("/messages") and req.method == "POST": + captured["body"] = json.loads(req.content) + return _json_response( + { + "message": {"id": "m1", "sender_name": "you", "content": "hi", "created_at": "2026-10-20T10:00:00Z"}, + "addressed": ["eng"], + "unaddressed": False, + }, + status=201, + ) + return _json_response({}, status=404) + + c = _client(handler) + res = c.send_message("t1", "hi", to=["eng"], sender="you") + assert captured["body"] == {"content": "hi", "to": ["eng"], "from": "you"} + assert res["addressed"] == ["eng"] + + +def test_send_message_defaults_sender_to_human(): + captured = {} + + def handler(req): + if req.url.path.endswith("/messages"): + captured["body"] = json.loads(req.content) + return _json_response({"message": {"id": "m1"}, "addressed": [], "unaddressed": True}, status=201) + return _json_response({}, status=404) + + c = _client(handler) + c.send_message("t1", "hi") + assert "from" not in captured["body"] + + +def test_list_bots_and_runs(): + bots = [{"id": "b1", "handle": "eng", "name": "Engineer", "enabled": True, "active": True}] + runs = [{"id": "r1", "actor_id": "a1", "thread_id": "t1", "status": "completed", "total_tokens": 10}] + + def handler(req): + if req.url.path == "/api/v1/bots": + return _json_response(bots) + if req.url.path == "/api/v1/runs" and req.method == "GET": + assert dict(req.url.params).get("thread_id") == "t1" + return _json_response(runs) + return _json_response({}, status=404) + + c = _client(handler) + assert c.list_bots() == bots + assert c.list_runs("t1") == runs + + +def test_health_probe_is_public_path(): + captured = {} + + def handler(req): + captured["path"] = req.url.path + return _json_response({"status": "ok"}) + c = _client(handler) + c.ping() + assert captured["path"] == "/api/v1/health" + + +def test_stream_events_yields_parsed_events(): + def sse_body() -> Iterator[bytes]: + yield b": connected\n\n" + yield b"event: message.created\ndata: " + json.dumps({"id": "m1"}).encode() + b"\n\n" + yield b"data: " + json.dumps({"plain": True}).encode() + b"\n\n" + + captured = {} + + def handler(req): + captured["path"] = req.url.path + captured["params"] = dict(req.url.params) + return httpx.Response(200, stream=_SS(sse_body()), headers={"content-type": "text/event-stream"}) + + c = _client(handler) + events = list(c.stream_events(thread_id="t1")) + assert captured["path"] == "/api/v1/events" + assert captured["params"]["thread_id"] == "t1" + assert events[0] == {"event": "message.created", "id": "m1"} + assert events[1] == {"event": "message", "plain": True} + + +def test_github_issues_calls_rest_api(monkeypatch): + captured = {} + + class FakeResp: + status_code = 200 + text = "" + + def json(self): + return [{"number": 1, "title": "Bug", "state": "open", "labels": []}] + + def fake_get(url, params=None, headers=None, timeout=None): + captured["url"] = url + captured["params"] = params + return FakeResp() + + monkeypatch.setattr(httpx, "get", fake_get) + issues = GitHubIssuesClient().list_issues(repo="regnull/openbot") + assert captured["url"].endswith("/repos/regnull/openbot/issues") + assert issues[0]["number"] == 1 + + +def test_client_error_on_4xx(): + c = _client(lambda req: _json_response({"detail": "nope"}, status=404)) + with pytest.raises(OpenBotClientError): + c.list_threads() + + +# --------------------------------------------------------------------------- +# CLI integration tests (CliRunner with injected client) +# --------------------------------------------------------------------------- + +def _runner_with_client(c: OpenBotClient) -> CliRunner: + runner = CliRunner() + + def invoke(args=None, **kwargs): + # Callers pass a single command list (e.g. ["threads"]); forward it + # directly to click so we don't accidentally nest it inside a tuple, + # which would make click iterate over a list and raise + # "unhashable type: 'list'" while parsing. + kwargs.setdefault("obj", {}) + kwargs["obj"]["client"] = c + kwargs["obj"]["base_url"] = c.base_url + kwargs["obj"]["api_key"] = c.api_key + return runner.invoke(cli_mod.cli, args, **kwargs) + + return invoke + + +def test_cli_threads_lists(): + c = _client(lambda req: _json_response([{"id": "t1", "title": "Hi", "default_bot_handle": "eng", "active": False}])) + run = _runner_with_client(c) + result = run(["threads"]) + assert result.exit_code == 0, result.output + assert "t1" in result.output + assert "@eng" in result.output + + +def test_cli_thread_shows_messages(): + detail = { + "title": "Trip", + "default_bot_handle": "eng", + "participants": [], + "has_more": False, + "messages": [ + {"sender_name": "you", "created_at": "2026-10-20T10:00:00Z", "content": "hello", "mentions": ["eng"]} + ], + } + c = _client(lambda req: _json_response(detail) if req.method == "GET" and "/threads/" in req.url.path else _json_response({})) + result = _runner_with_client(c)(["thread", "t1"]) + assert result.exit_code == 0, result.output + assert "hello" in result.output + assert "@eng" in result.output + + +def test_cli_send_posts_and_reports_addressed(): + captured = {} + + def handler(req): + if req.url.path.endswith("/messages"): + captured["body"] = json.loads(req.content) + return _json_response( + {"message": {"id": "m1", "sender_name": "you", "created_at": "2026-10-20T10:00:00Z"}, + "addressed": ["eng"], "unaddressed": False}, status=201) + return _json_response({}, status=404) + + c = _client(handler) + result = _runner_with_client(c)(["send", "t1", "do the thing", "--bot", "@eng"]) + assert result.exit_code == 0, result.output + assert captured["body"]["to"] == ["eng"] + assert "Addressed: @eng" in result.output + + +def test_cli_bots_and_runs(): + def handler(req): + if req.url.path == "/api/v1/bots": + return _json_response([{"id": "b1", "handle": "eng", "name": "Eng", "enabled": True, "active": True, "description": "d"}]) + if req.url.path == "/api/v1/runs": + return _json_response([{"id": "r1", "actor_id": "a1", "thread_id": "t1", "status": "completed", "total_tokens": 5}]) + return _json_response({}, status=404) + + c = _client(handler) + r1 = _runner_with_client(c)(["bots"]) + r2 = _runner_with_client(c)(["runs"]) + assert r1.exit_code == 0 and "eng" in r1.output + assert r2.exit_code == 0 and "r1" in r2.output + + +def test_cli_issues_fetches_github(monkeypatch): + class FakeResp: + status_code = 200 + text = "" + + def json(self): + return [{"number": 7, "title": "Broken thing", "state": "open", "labels": [{"name": "bug"}]}] + + monkeypatch.setattr(httpx, "get", lambda *a, **k: FakeResp()) + c = _client(lambda req: _json_response({})) + result = _runner_with_client(c)(["issues", "--repo", "regnull/openbot"]) + assert result.exit_code == 0, result.output + assert "#7" in result.output + assert "Broken thing" in result.output + + +def test_cli_thread_watch_streams(monkeypatch): + detail = { + "title": "Trip", + "default_bot_handle": "eng", + "participants": [], + "has_more": False, + "messages": [], + } + + def sse_body(): + yield b"event: message.created\ndata: " + json.dumps({"sender_name": "eng", "created_at": "2026-10-20T10:00:00Z", "content": "on it"}).encode() + b"\n\n" + + def handler(req): + # The `thread` command first fetches the detail (JSON), then opens the + # SSE stream for `watch`; serve each with the right content type. + if req.url.path.startswith("/api/v1/events"): + return httpx.Response(200, stream=_SS(sse_body()), headers={"content-type": "text/event-stream"}) + return _json_response(detail) + + c = _client(handler) + + # Yield one event then stop, so `watch` exits; emulate Ctrl+C via StopIteration. + original = c.stream_events + + def fake_stream(thread_id=None): + for item in original(thread_id=thread_id): + yield item + break # stop after the first event + + monkeypatch.setattr(c, "stream_events", fake_stream) + result = _runner_with_client(c)(["thread", "t1", "watch"]) + assert result.exit_code == 0, result.output + assert "on it" in result.output diff --git a/backend/uv.lock b/backend/uv.lock index dd52e35..8a21bfa 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1175,6 +1175,7 @@ dependencies = [ { name = "aiosqlite" }, { name = "alembic" }, { name = "beautifulsoup4" }, + { name = "click" }, { name = "cryptography" }, { name = "fastapi" }, { name = "httpx" }, @@ -1211,6 +1212,7 @@ requires-dist = [ { name = "aiosqlite", specifier = ">=0.22" }, { name = "alembic", specifier = ">=1.20" }, { name = "beautifulsoup4", specifier = ">=4.12" }, + { name = "click", specifier = ">=8.1" }, { name = "cryptography", specifier = ">=50.0.1" }, { name = "fastapi", specifier = ">=0.141" }, { name = "httpx", specifier = ">=0.28" },