Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions backend/openbot_cli/README.md
Original file line number Diff line number Diff line change
@@ -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 <url> [--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 <id> [watch]` | Print a thread's messages; add `watch` to follow live SSE events. |
| `openbot send <thread> "<msg>" [--bot @handle]` | Post a message; `--bot` (repeatable) `@`-mentions a bot. |
| `openbot bots` | List bots (handle, name, active/disabled state, description). |
| `openbot runs [<thread>]` | List runs; pass a `<thread>` id to scope to one thread. |
| `openbot run <id>` | 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 <id> watch` opens an SSE connection to `/api/v1/events?thread_id=<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.
11 changes: 11 additions & 0 deletions backend/openbot_cli/__init__.py
Original file line number Diff line number Diff line change
@@ -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"
235 changes: 235 additions & 0 deletions backend/openbot_cli/cli.py
Original file line number Diff line number Diff line change
@@ -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 <url> [--api-key KEY] Save connection settings
openbot threads [list] List threads
openbot thread <id> [watch] Show a thread (or live-follow it)
openbot send <thread> "<msg>" [--bot @h] Post a message, optionally @-ing a bot
openbot bots List bots
openbot runs [<thread>] 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())
Loading
Loading