diff --git a/CHANGELOG.md b/CHANGELOG.md index 47f1273..e141f43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to cueapi-cli will be documented here. +## [Unreleased] + +### Added +- `cueapi message-to ` top-level wrapper for sending a message by name. Resolves `` against your agent roster: `agent_id` (`agt_*`) and slug-form (`slug@user`) pass through unchanged; bare names match case-insensitively against `display_name` and `slug` via `GET /agents`. Same flag set as `messages send` (sans `--to`). +- `agents list --online-only` shortcut for `--status online`. Mutually exclusive with `--status`. +- `agents describe ` alias for `agents get `. + ## [0.2.0] - 2026-05-01 ### Added diff --git a/cueapi/cli.py b/cueapi/cli.py index d8e4a5f..e81ebaf 100644 --- a/cueapi/cli.py +++ b/cueapi/cli.py @@ -1392,6 +1392,13 @@ def agents_create( @agents.command(name="list") @click.option("--status", default=None, type=click.Choice(["online", "offline", "away"]), help="Filter by status") +@click.option( + "--online-only", + "online_only", + is_flag=True, + default=False, + help="Shortcut for --status online. Mutually exclusive with --status.", +) @click.option("--include-deleted", is_flag=True, default=False, help="Include soft-deleted agents") @click.option("--limit", default=50, type=int, help="Max results (default 50, max 100)") @click.option("--offset", default=0, type=int, help="Offset for pagination") @@ -1399,11 +1406,16 @@ def agents_create( def agents_list( ctx: click.Context, status: Optional[str], + online_only: bool, include_deleted: bool, limit: int, offset: int, ) -> None: """List your agents.""" + if online_only and status: + raise click.UsageError("--online-only and --status are mutually exclusive") + if online_only: + status = "online" try: with CueAPIClient(api_key=ctx.obj.get("api_key"), profile=ctx.obj.get("profile")) as client: params: dict = {"limit": limit, "offset": offset} @@ -1477,6 +1489,15 @@ def agents_get(ctx: click.Context, ref: str, include_deleted: bool) -> None: click.echo(str(e)) +@agents.command(name="describe") +@click.argument("ref") +@click.option("--include-deleted", is_flag=True, default=False, help="Include soft-deleted agents") +@click.pass_context +def agents_describe(ctx: click.Context, ref: str, include_deleted: bool) -> None: + """Alias for `agents get`.""" + ctx.invoke(agents_get, ref=ref, include_deleted=include_deleted) + + @agents.command(name="update") @click.argument("ref") @click.option("--display-name", "display_name", default=None, help="New display name") @@ -2065,5 +2086,193 @@ def messages_ack(ctx: click.Context, msg_id: str) -> None: main.add_command(messages) +def _resolve_recipient(client, recipient: str) -> str: + """Resolve a recipient string to an agent_id or slug-form. + + Pass-through when `recipient` already looks like an agent_id (`agt_*`) + or slug-form (`slug@user`). Otherwise list `/agents` and match + `display_name` or `slug` case-insensitive exact. + """ + if recipient.startswith("agt_") or "@" in recipient: + return recipient + + candidates: list = [] + offset = 0 + while True: + resp = client.get("/agents", params={"limit": 100, "offset": offset}) + if resp.status_code != 200: + raise click.ClickException( + f"Failed to list agents (HTTP {resp.status_code})" + ) + page = resp.json().get("agents", []) + candidates.extend(page) + if len(page) < 100 or offset >= 200: + break + offset += 100 + + needle = recipient.lower() + matches = [ + a + for a in candidates + if (a.get("display_name") or "").lower() == needle + or (a.get("slug") or "").lower() == needle + ] + if not matches: + known = sorted({ + a.get("display_name") or a.get("slug") or a.get("id", "?") + for a in candidates + }) + hint = ", ".join(known) if known else "(no agents in roster)" + raise click.ClickException( + f"No agent matches '{recipient}'. Roster: {hint}" + ) + if len(matches) > 1: + ids = ", ".join(m.get("id", "?") for m in matches) + raise click.ClickException( + f"'{recipient}' matches {len(matches)} agents: {ids}. " + "Disambiguate with --to via `messages send`." + ) + return matches[0].get("id", recipient) + + +@main.command(name="message-to") +@click.argument("recipient") +@click.option( + "--from", + "from_agent", + required=True, + help=( + "Sender agent — opaque agent_id or slug-form (agent@user). Sent as " + "the X-Cueapi-From-Agent header." + ), +) +@click.option("--body", "body_text", required=True, help="Message body (1-32768 chars)") +@click.option("--subject", default=None, help="Optional subject line (max 255 chars)") +@click.option( + "--reply-to", + "reply_to", + default=None, + help="Previous message ID this is replying to (msg_<12 alphanumeric>). thread_id inherits.", +) +@click.option( + "--priority", + default=None, + type=click.IntRange(1, 5), + help="Priority 1-5 (server default 3). Receiver-pair limits may downgrade priority>3 to 3.", +) +@click.option( + "--expects-reply", + "expects_reply", + is_flag=True, + default=False, + help="Mark this message as expecting a reply.", +) +@click.option( + "--reply-to-agent", + "reply_to_agent", + default=None, + help="Decoupled reply target (defaults to the sender).", +) +@click.option("--metadata", default=None, help="JSON metadata blob") +@click.option( + "--idempotency-key", + "idempotency_key", + default=None, + help=( + "Optional Idempotency-Key header (≤255 chars). Same key + same body within " + "24h returns the existing message with HTTP 200 instead of 201." + ), +) +@click.pass_context +def message_to( + ctx: click.Context, + recipient: str, + from_agent: str, + body_text: str, + subject: Optional[str], + reply_to: Optional[str], + priority: Optional[int], + expects_reply: bool, + reply_to_agent: Optional[str], + metadata: Optional[str], + idempotency_key: Optional[str], +) -> None: + """Send a message to a recipient by name, slug, or agent ID. + + Resolves against your roster: + agent_id (agt_*) or slug-form (slug@user) — used as-is. + bare name — matched case-insensitive against display_name and slug. + """ + body: dict = {"body": body_text} + if subject: + body["subject"] = subject + if reply_to: + body["reply_to"] = reply_to + if priority is not None: + body["priority"] = priority + if expects_reply: + body["expects_reply"] = True + if reply_to_agent: + body["reply_to_agent"] = reply_to_agent + if metadata: + try: + body["metadata"] = json.loads(metadata) + except json.JSONDecodeError: + raise click.UsageError("--metadata must be valid JSON") + + headers: dict = {"X-Cueapi-From-Agent": from_agent} + if idempotency_key: + if len(idempotency_key) > 255: + raise click.UsageError("--idempotency-key must be ≤255 characters") + headers["Idempotency-Key"] = idempotency_key + + try: + with CueAPIClient(api_key=ctx.obj.get("api_key"), profile=ctx.obj.get("profile")) as client: + try: + resolved = _resolve_recipient(client, recipient) + except click.ClickException as e: + echo_error(str(e)) + return + body["to"] = resolved + + resp = client.post("/messages", json=body, headers=headers) + if resp.status_code in (200, 201): + m = resp.json() + click.echo() + if resp.status_code == 200: + echo_info("Idempotency-Key dedup hit:", "existing message returned") + echo_success(f"{'Sent' if resp.status_code == 201 else 'Existing'}: {m.get('id', '?')}") + echo_info("To:", resolved) + if m.get("thread_id"): + echo_info("Thread:", m["thread_id"]) + echo_info("Delivery state:", m.get("delivery_state", "?")) + downgraded_header = None + try: + downgraded_header = resp.headers.get("X-CueAPI-Priority-Downgraded") + except Exception: + pass + if downgraded_header == "true": + echo_info( + "Priority downgraded:", + "true (receiver-pair limit applied; message delivered at priority 3)", + ) + click.echo() + elif resp.status_code == 409: + error = resp.json().get("detail", {}).get("error", {}) + code = error.get("code", "conflict") + if code == "idempotency_key_conflict": + echo_error( + "Idempotency-Key conflict — same key was already used with a different body. " + "Either reuse the original body or change the key." + ) + else: + echo_error(error.get("message", f"Conflict (HTTP 409, {code})")) + else: + error = resp.json().get("detail", {}).get("error", {}) + echo_error(error.get("message", f"Failed (HTTP {resp.status_code})")) + except click.ClickException as e: + click.echo(str(e)) + + if __name__ == "__main__": main() diff --git a/tests/test_cli.py b/tests/test_cli.py index 619617b..90ca5ff 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2238,3 +2238,364 @@ def test_fire_combines_send_at_with_payload_override(monkeypatch): "merge_strategy": "replace", "send_at": "2026-05-04T22:00:00Z", } +# --- agents list --online-only --- + + +def test_agents_list_online_only_sets_status_filter(monkeypatch): + holder: dict = {} + _patch_client( + monkeypatch, + holder, + responses={("GET", "/agents"): lambda: _FakeResp(200, {"agents": [], "total": 0})}, + ) + result = runner.invoke(main, ["agents", "list", "--online-only"]) + assert result.exit_code == 0, result.output + params = holder["client"].calls[-1][2] + assert params["status"] == "online" + + +def test_agents_list_online_only_conflicts_with_status(): + result = runner.invoke( + main, ["agents", "list", "--online-only", "--status", "offline"] + ) + assert result.exit_code != 0 + assert "mutually exclusive" in result.output.lower() + + +def test_agents_list_help_mentions_online_only(): + result = runner.invoke(main, ["agents", "list", "--help"]) + assert result.exit_code == 0 + assert "--online-only" in result.output + + +# --- agents describe (alias) --- + + +def test_agents_describe_renders_same_as_get(monkeypatch): + holder: dict = {} + _patch_client( + monkeypatch, + holder, + responses={ + ("GET", "/agents/agt_x"): lambda: _FakeResp( + 200, + { + "id": "agt_x", + "slug": "x", + "display_name": "X Agent", + "status": "online", + "webhook_url": None, + "metadata": None, + }, + ) + }, + ) + result = runner.invoke(main, ["agents", "describe", "agt_x"]) + assert result.exit_code == 0 + assert "agt_x" in result.output + assert "X Agent" in result.output + # Single GET to /agents/, same as `agents get`. + assert holder["client"].calls[-1][0] == "GET" + assert holder["client"].calls[-1][1] == "/agents/agt_x" + + +def test_agents_describe_appears_in_help(): + result = runner.invoke(main, ["agents", "--help"]) + assert result.exit_code == 0 + assert "describe" in result.output + + +# --- message-to top-level wrapper --- + + +def test_message_to_passes_agent_id_through_without_lookup(monkeypatch): + holder: dict = {} + _patch_messages_client( + monkeypatch, + holder, + responses={ + ("POST", "/messages"): lambda: _FakeResp( + 201, {"id": "msg_z", "delivery_state": "queued", "thread_id": "thr_z"} + ) + }, + ) + result = runner.invoke( + main, + [ + "message-to", + "agt_recipient", + "--from", + "sender@x", + "--body", + "hi", + ], + ) + assert result.exit_code == 0, result.output + # No GET /agents — agt_ prefix is pass-through. + methods = [c[0] for c in holder["client"].calls] + assert "GET" not in methods + method, path, body, headers = holder["client"].calls[-1] + assert method == "POST" + assert path == "/messages" + assert body["to"] == "agt_recipient" + assert body["body"] == "hi" + assert headers["X-Cueapi-From-Agent"] == "sender@x" + + +def test_message_to_passes_slug_form_through_without_lookup(monkeypatch): + holder: dict = {} + _patch_messages_client( + monkeypatch, + holder, + responses={ + ("POST", "/messages"): lambda: _FakeResp( + 201, {"id": "msg_z", "delivery_state": "queued"} + ) + }, + ) + result = runner.invoke( + main, + ["message-to", "alice@user1", "--from", "bob@user1", "--body", "hi"], + ) + assert result.exit_code == 0, result.output + methods = [c[0] for c in holder["client"].calls] + assert "GET" not in methods + body = holder["client"].calls[-1][2] + assert body["to"] == "alice@user1" + + +def test_message_to_resolves_display_name_case_insensitive(monkeypatch): + holder: dict = {} + _patch_messages_client( + monkeypatch, + holder, + responses={ + ("GET", "/agents"): lambda: _FakeResp( + 200, + { + "agents": [ + {"id": "agt_one", "slug": "one", "display_name": "One Agent"}, + {"id": "agt_two", "slug": "two", "display_name": "Two Agent"}, + ], + "total": 2, + }, + ), + ("POST", "/messages"): lambda: _FakeResp( + 201, {"id": "msg_z", "delivery_state": "queued"} + ), + }, + ) + result = runner.invoke( + main, + ["message-to", "two agent", "--from", "sender@x", "--body", "hi"], + ) + assert result.exit_code == 0, result.output + # 1st call: GET /agents (resolution); 2nd: POST /messages + assert holder["client"].calls[0][0] == "GET" + assert holder["client"].calls[0][1] == "/agents" + post_call = holder["client"].calls[-1] + assert post_call[0] == "POST" + assert post_call[2]["to"] == "agt_two" + + +def test_message_to_resolves_slug_match(monkeypatch): + holder: dict = {} + _patch_messages_client( + monkeypatch, + holder, + responses={ + ("GET", "/agents"): lambda: _FakeResp( + 200, + { + "agents": [ + {"id": "agt_pm", "slug": "pm", "display_name": "Cue PM"}, + ], + "total": 1, + }, + ), + ("POST", "/messages"): lambda: _FakeResp( + 201, {"id": "msg_z", "delivery_state": "queued"} + ), + }, + ) + result = runner.invoke( + main, ["message-to", "pm", "--from", "sender@x", "--body", "hi"] + ) + assert result.exit_code == 0, result.output + assert holder["client"].calls[-1][2]["to"] == "agt_pm" + + +def test_message_to_no_match_errors_with_roster_hint(monkeypatch): + holder: dict = {} + _patch_messages_client( + monkeypatch, + holder, + responses={ + ("GET", "/agents"): lambda: _FakeResp( + 200, + { + "agents": [ + {"id": "agt_a", "slug": "alpha", "display_name": "Alpha"}, + ], + "total": 1, + }, + ) + }, + ) + result = runner.invoke( + main, + ["message-to", "nobody", "--from", "sender@x", "--body", "hi"], + ) + # No POST should fire. + methods = [c[0] for c in holder["client"].calls] + assert "POST" not in methods + assert "no agent matches" in result.output.lower() + assert "alpha" in result.output.lower() + + +def test_message_to_ambiguous_match_errors(monkeypatch): + holder: dict = {} + _patch_messages_client( + monkeypatch, + holder, + responses={ + ("GET", "/agents"): lambda: _FakeResp( + 200, + { + "agents": [ + {"id": "agt_one", "slug": "shared", "display_name": "Worker"}, + {"id": "agt_two", "slug": "other", "display_name": "Worker"}, + ], + "total": 2, + }, + ) + }, + ) + result = runner.invoke( + main, ["message-to", "Worker", "--from", "sender@x", "--body", "hi"] + ) + methods = [c[0] for c in holder["client"].calls] + assert "POST" not in methods + assert "agt_one" in result.output and "agt_two" in result.output + + +def test_message_to_passes_optionals_through(monkeypatch): + holder: dict = {} + _patch_messages_client( + monkeypatch, + holder, + responses={ + ("POST", "/messages"): lambda: _FakeResp( + 201, {"id": "msg_z", "delivery_state": "queued"} + ) + }, + ) + result = runner.invoke( + main, + [ + "message-to", + "agt_recipient", + "--from", + "sender@x", + "--body", + "the body", + "--subject", + "the subject", + "--reply-to", + "msg_abcdef123456", + "--priority", + "5", + "--expects-reply", + "--reply-to-agent", + "alt@x", + "--metadata", + '{"k": "v"}', + "--idempotency-key", + "idemp-key-1", + ], + ) + assert result.exit_code == 0, result.output + body = holder["client"].calls[-1][2] + assert body == { + "body": "the body", + "subject": "the subject", + "reply_to": "msg_abcdef123456", + "priority": 5, + "expects_reply": True, + "reply_to_agent": "alt@x", + "metadata": {"k": "v"}, + "to": "agt_recipient", + } + headers = holder["client"].calls[-1][3] + assert headers["X-Cueapi-From-Agent"] == "sender@x" + assert headers["Idempotency-Key"] == "idemp-key-1" + + +def test_message_to_omits_expects_reply_when_unset(monkeypatch): + # Same default-false discipline as `messages send`. + holder: dict = {} + _patch_messages_client( + monkeypatch, + holder, + responses={ + ("POST", "/messages"): lambda: _FakeResp( + 201, {"id": "msg_z", "delivery_state": "queued"} + ) + }, + ) + result = runner.invoke( + main, + ["message-to", "agt_x", "--from", "y@z", "--body", "hi"], + ) + assert result.exit_code == 0 + body = holder["client"].calls[-1][2] + assert "expects_reply" not in body + + +def test_message_to_priority_validated_by_click_intrange(): + result = runner.invoke( + main, + ["message-to", "agt_x", "--from", "y@z", "--body", "hi", "--priority", "9"], + ) + assert result.exit_code != 0 + + +def test_message_to_idempotency_key_too_long_rejected(): + long_key = "x" * 256 + result = runner.invoke( + main, + [ + "message-to", + "agt_x", + "--from", + "y@z", + "--body", + "hi", + "--idempotency-key", + long_key, + ], + ) + assert result.exit_code != 0 + + +def test_message_to_invalid_metadata_json(): + result = runner.invoke( + main, + [ + "message-to", + "agt_x", + "--from", + "y@z", + "--body", + "hi", + "--metadata", + "{not json", + ], + ) + assert result.exit_code != 0 + + +def test_top_level_help_lists_message_to(): + result = runner.invoke(main, ["--help"]) + assert result.exit_code == 0 + assert "message-to" in result.output