Skip to content

Commit 2bd30a2

Browse files
authored
feat(events): events + subscriptions top-level command groups (PR-1b) (#50)
Adds two top-level command groups for the event-emit primitive (cueapi-core PR #71, OSS counterpart of cueapi/cueapi#731): - `cueapi events list <ref>` — pull events for an agent - `cueapi subscriptions create <ref>` — create a subscription - `cueapi subscriptions list <ref>` — list active subscriptions - `cueapi subscriptions delete <ref> <subscription-id>` — soft-detach Top-level groups per primary's kickoff doc suggestion (vs nesting under `agents`) — events/subscriptions are conceptually distinct primitives, not agent metadata. Same pattern as the top-level `messages` and `executions` groups (which are also user-scoped operations on a sub-resource). ## CLI architecture note This port uses cueapi-cli's own httpx-based client (CueAPIClient), NOT the cueapi-python SDK. The kickoff doc said the cli port "uses cueapi-python SDK so blocked on #2 landing" — that's incorrect re: the actual cli architecture (cli has its own client.py + no runtime dependency on cueapi-python). cli port is INDEPENDENT of python SDK timing. Flagged to PM. ## Wire format pinned - `events list`: GET `/v1/agents/{ref}/events` with `limit` (default 100) + optional `since` cursor + optional `event_type` filter. - `subscriptions create`: POST body `{event_type, delivery_target, webhook_url?}` to `/v1/agents/{ref}/subscriptions`. Client-side guard: `--webhook-url` required when `--delivery-target=webhook` (surface at parse time vs server 400). - `subscriptions list`: GET `/v1/agents/{ref}/subscriptions`. - `subscriptions delete`: DELETE on `/subscriptions/{id}` (idempotent). ## Tests 12 new tests in `tests/test_cli.py`: - events list: basic, with since+event_type, defaults only limit, 404 agent not found - subscriptions create: pull minimal, webhook with url, webhook without url errors client-side - subscriptions list: basic, empty - subscriptions delete: basic - help: events lists `list` subcommand, subscriptions lists create/list/delete 12/12 pass. Full suite: 191 tests collected, all events/subscriptions pass; no regressions. ## One-shot webhook secret discipline When `subscriptions create --delivery-target=webhook` returns a `webhook_secret`, the cli surfaces it with the "save now — only shown once" label. Same pattern as `agents webhook-secret regenerate`. Depends on cueapi-core PR #71 (merged 03:53Z, commit 50d2b2c2). Closes Backlog row: cmp0h2nzd000204l8acl5j6w0
1 parent a9151b1 commit 2bd30a2

2 files changed

Lines changed: 442 additions & 0 deletions

File tree

cueapi/cli.py

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2451,5 +2451,201 @@ def message_to(
24512451
click.echo(str(e))
24522452

24532453

2454+
# ───────────────────────────────────────────────────────────────────
2455+
# Event-emit primitive (PR-1b) — events + subscriptions top-level groups
2456+
# ───────────────────────────────────────────────────────────────────
2457+
2458+
2459+
@main.group()
2460+
def events() -> None:
2461+
"""Pull events from an agent's event stream (PR-1b)."""
2462+
pass
2463+
2464+
2465+
@events.command(name="list")
2466+
@click.argument("ref")
2467+
@click.option("--since", default=None, type=int,
2468+
help="Cursor — only return events with id > since (BIGSERIAL).")
2469+
@click.option("--limit", default=100, type=int,
2470+
help="Page size (default 100, server caps at 1000).")
2471+
@click.option("--event-type", "event_type", default=None,
2472+
help="Filter to a specific event type.")
2473+
@click.pass_context
2474+
def events_list(
2475+
ctx: click.Context,
2476+
ref: str,
2477+
since: Optional[int],
2478+
limit: int,
2479+
event_type: Optional[str],
2480+
) -> None:
2481+
"""List events for an agent.
2482+
2483+
Events are append-only with a monotonic id (BIGSERIAL). Use --since
2484+
as a cursor: pass the highest id from the previous page to continue
2485+
pagination. Default fetches from the beginning.
2486+
"""
2487+
try:
2488+
with CueAPIClient(api_key=ctx.obj.get("api_key"), profile=ctx.obj.get("profile")) as client:
2489+
params: dict = {"limit": limit}
2490+
if since is not None:
2491+
params["since"] = since
2492+
if event_type:
2493+
params["event_type"] = event_type
2494+
resp = client.get(f"/agents/{ref}/events", params=params)
2495+
if resp.status_code == 404:
2496+
echo_error(f"Agent not found: {ref}")
2497+
return
2498+
if resp.status_code != 200:
2499+
error = resp.json().get("detail", {}).get("error", {})
2500+
echo_error(error.get("message", f"Failed (HTTP {resp.status_code})"))
2501+
return
2502+
data = resp.json()
2503+
evs = data.get("events", [])
2504+
if not evs:
2505+
click.echo("\nNo events.\n")
2506+
return
2507+
click.echo()
2508+
rows = []
2509+
for e in evs:
2510+
ts = (e.get("emitted_at") or "")[:19].replace("T", " ")
2511+
rows.append([
2512+
str(e.get("id", "?")),
2513+
e.get("event_type", "?"),
2514+
ts,
2515+
])
2516+
echo_table(["ID", "EVENT TYPE", "EMITTED AT"], rows, widths=[12, 32, 22])
2517+
cursor = data.get("next_cursor")
2518+
if cursor is not None:
2519+
echo_info("Next cursor:", str(cursor))
2520+
click.echo()
2521+
except click.ClickException as e:
2522+
click.echo(str(e))
2523+
2524+
2525+
@main.group()
2526+
def subscriptions() -> None:
2527+
"""Manage event subscriptions for an agent (PR-1b)."""
2528+
pass
2529+
2530+
2531+
@subscriptions.command(name="create")
2532+
@click.argument("ref")
2533+
@click.option("--event-type", "event_type", required=True,
2534+
help="Event type to subscribe to (e.g. message.received).")
2535+
@click.option("--delivery-target", "delivery_target", required=True,
2536+
type=click.Choice(["pull", "webhook"]),
2537+
help="Delivery mechanism: pull (poll via `cueapi events list`) or webhook (server POSTs).")
2538+
@click.option("--webhook-url", "webhook_url", default=None,
2539+
help="Required for delivery-target=webhook; HTTPS only.")
2540+
@click.pass_context
2541+
def subscriptions_create(
2542+
ctx: click.Context,
2543+
ref: str,
2544+
event_type: str,
2545+
delivery_target: str,
2546+
webhook_url: Optional[str],
2547+
) -> None:
2548+
"""Create a subscription for an agent.
2549+
2550+
Subscriptions are agent-scoped — an agent can only subscribe to
2551+
events FOR ITSELF. The calling user must own the agent.
2552+
2553+
For webhook subscriptions, the response includes ``webhook_secret``
2554+
ONE-TIME. Save it now — the server never re-exposes it.
2555+
"""
2556+
if delivery_target == "webhook" and not webhook_url:
2557+
raise click.UsageError("--webhook-url is required when --delivery-target=webhook")
2558+
body: dict = {"event_type": event_type, "delivery_target": delivery_target}
2559+
if webhook_url:
2560+
body["webhook_url"] = webhook_url
2561+
try:
2562+
with CueAPIClient(api_key=ctx.obj.get("api_key"), profile=ctx.obj.get("profile")) as client:
2563+
resp = client.post(f"/agents/{ref}/subscriptions", json=body)
2564+
if resp.status_code == 404:
2565+
echo_error(f"Agent not found: {ref}")
2566+
return
2567+
if resp.status_code != 201:
2568+
error = resp.json().get("detail", {}).get("error", {})
2569+
echo_error(error.get("message", f"Failed (HTTP {resp.status_code})"))
2570+
return
2571+
sub = resp.json()
2572+
click.echo()
2573+
echo_success(f"Subscription created: {sub.get('id', '?')}")
2574+
echo_info("Event type:", sub.get("event_type", "?"))
2575+
echo_info("Delivery target:", sub.get("delivery_target", "?"))
2576+
secret = sub.get("webhook_secret")
2577+
if secret:
2578+
echo_info("Webhook secret (save now — only shown once):", secret)
2579+
click.echo()
2580+
except click.ClickException as e:
2581+
click.echo(str(e))
2582+
2583+
2584+
@subscriptions.command(name="list")
2585+
@click.argument("ref")
2586+
@click.pass_context
2587+
def subscriptions_list(ctx: click.Context, ref: str) -> None:
2588+
"""List active subscriptions for an agent.
2589+
2590+
``webhook_url`` is redacted to host-only in the response;
2591+
``webhook_secret`` is never exposed here (only on create).
2592+
"""
2593+
try:
2594+
with CueAPIClient(api_key=ctx.obj.get("api_key"), profile=ctx.obj.get("profile")) as client:
2595+
resp = client.get(f"/agents/{ref}/subscriptions")
2596+
if resp.status_code == 404:
2597+
echo_error(f"Agent not found: {ref}")
2598+
return
2599+
if resp.status_code != 200:
2600+
echo_error(f"Failed (HTTP {resp.status_code})")
2601+
return
2602+
data = resp.json()
2603+
subs = data.get("subscriptions", [])
2604+
if not subs:
2605+
click.echo("\nNo active subscriptions.\n")
2606+
return
2607+
click.echo()
2608+
rows = []
2609+
for s in subs:
2610+
rows.append([
2611+
s.get("id", "?")[:36],
2612+
s.get("event_type", "?"),
2613+
s.get("delivery_target", "?"),
2614+
s.get("webhook_url", "—") if s.get("delivery_target") == "webhook" else "—",
2615+
])
2616+
echo_table(["ID", "EVENT TYPE", "TARGET", "WEBHOOK HOST"], rows,
2617+
widths=[38, 28, 10, 24])
2618+
click.echo()
2619+
except click.ClickException as e:
2620+
click.echo(str(e))
2621+
2622+
2623+
@subscriptions.command(name="delete")
2624+
@click.argument("ref")
2625+
@click.argument("subscription_id")
2626+
@click.pass_context
2627+
def subscriptions_delete(ctx: click.Context, ref: str, subscription_id: str) -> None:
2628+
"""Soft-detach a subscription. Idempotent.
2629+
2630+
Re-DELETE on an already-detached subscription returns 200. The
2631+
server does NOT delete the row — it marks it detached so dispatch
2632+
stops + audit history is preserved.
2633+
"""
2634+
try:
2635+
with CueAPIClient(api_key=ctx.obj.get("api_key"), profile=ctx.obj.get("profile")) as client:
2636+
resp = client.delete(f"/agents/{ref}/subscriptions/{subscription_id}")
2637+
if resp.status_code == 404:
2638+
echo_error(f"Agent or subscription not found: {ref}/{subscription_id}")
2639+
return
2640+
if resp.status_code != 200:
2641+
echo_error(f"Failed (HTTP {resp.status_code})")
2642+
return
2643+
click.echo()
2644+
echo_success(f"Subscription detached: {subscription_id}")
2645+
click.echo()
2646+
except click.ClickException as e:
2647+
click.echo(str(e))
2648+
2649+
24542650
if __name__ == "__main__":
24552651
main()

0 commit comments

Comments
 (0)