From 4ca50748ad5b8a9da05238fd210dd154f8419dc5 Mon Sep 17 00:00:00 2001 From: mikemolinet Date: Tue, 5 May 2026 10:27:43 -0700 Subject: [PATCH] feat: cueapi message-to + agents --online-only + describe alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the agent-directory productization PRD (https://trydock.ai/mike/agent-directory-productization-prd), this adds the CLI surface for the universal inter-agent comm primitive the PRD calls for, plus two `agents` group conveniences. ## `cueapi message-to ` — universal inter-agent comm The PRD's stated goal: agents address peers by stable name; the 6-field cue-fire incantation goes away from agent code. This command is the CLI realization of that. Hides the messaging-primitive routing details (X-Cueapi-From-Agent header, Idempotency-Key header, /v1/messages POST shape) so callers don't need to remember them. Replaces the per-agent bash-script anti-pattern (cma-reply.sh and siblings) with a single canonical CLI command. Behavior: - AGENT_NAME pre-flight: by default, `GET /v1/agents/` is called before the POST to validate the recipient exists. Typo'd names fail fast with a clear error ("Unknown agent 'foo'. Run `cueapi agents list` to see available agents.") instead of being silently routed. --skip-resolve opts out of the pre-flight for callers who want to save the API call. - BODY positional or stdin: `cueapi message-to alice "hi"` OR `echo "msg" | cueapi message-to alice` OR `cueapi message-to alice -` (explicit stdin marker). Useful for multi-line messages. - --from defaults from $CUEAPI_MY_AGENT_SLUG env var. Required either via flag or env. The PRD's vision is each agent sets the env once at session boot and the CLI auto-fills it from there. - --token auto-fills subject as `[TOKEN] ` and stashes in metadata.token for thread traceability. Matches the existing cue-fire convention used by PM/CTO threads. Explicit --subject takes precedence. - --idempotency-key max 255 enforced client-side. Same client-side cap as `cueapi messages send` — fails fast. - --priority click.IntRange(1, 5). Same validation as `messages send`. - Surfaces server's X-CueAPI-Priority-Downgraded response header when receiver-pair limits apply. - 200 vs 201 distinction surfaced as "Idempotency-Key dedup hit" UI copy on 200; matches messages send. ## `cueapi agents list --online-only` One-line alias for `--status online`. Mutually exclusive with --status (raises UsageError before any HTTP call). ## `cueapi agents describe ` Verb alias for `cueapi agents get ` — same Click command registered under both names (via `agents.add_command(agents_get, name="describe")`). Per the agent-directory PRD's discovery vocabulary ("describe this agent to me"). Both verbs invoke the same callback, same options, same behavior; new options added to `agents_get` auto-apply to `agents describe`. ## Tests 15 new (133 → 148 total): - `--online-only` flag in help, mutex with --status, translates to status=online query param - `agents describe` alias visible in group help, accepts ref - `message-to` help shows all flags - env-var `CUEAPI_MY_AGENT_SLUG` auto-fills --from - pre-flight resolve happens before POST - unknown recipient → clear error + no POST attempted - --skip-resolve skips the pre-flight GET - --token auto-fills subject + stashes in metadata - --idempotency-key length cap enforced client-side - --priority Click IntRange validation - explicit --subject wins over token-derived ## Skipped from PRD scope `cueapi agents roster` (wraps GET /v1/agents/roster) is gated on cueapi shipping the roster endpoint. Will follow in a small PR once that endpoint lands. Rest of Surface 3 work tracked under PRD's Phase A status board. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- cueapi/cli.py | 239 +++++++++++++++++++++++++++++++++++++++++++++- tests/test_cli.py | 231 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 469 insertions(+), 1 deletion(-) diff --git a/cueapi/cli.py b/cueapi/cli.py index 482c2d4..2197934 100644 --- a/cueapi/cli.py +++ b/cueapi/cli.py @@ -1374,6 +1374,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="Shorthand 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") @@ -1381,11 +1388,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} @@ -1427,7 +1439,11 @@ def agents_list( @click.option("--include-deleted", is_flag=True, default=False, help="Include soft-deleted agents") @click.pass_context def agents_get(ctx: click.Context, ref: str, include_deleted: bool) -> None: - """Get an agent by opaque ID or slug-form (agent@user).""" + """Get an agent by opaque ID or slug-form (agent@user). + + Aliased as `cueapi agents describe ` per the agent-directory PRD's + discovery vocabulary. Both verbs invoke the same Click command. + """ try: with CueAPIClient(api_key=ctx.obj.get("api_key"), profile=ctx.obj.get("profile")) as client: params: dict = {} @@ -1703,6 +1719,15 @@ def agents_sent( click.echo(str(e)) +# Register `cueapi agents describe ` as an alias of `agents get`. Per +# the agent-directory PRD's verb vocabulary (`describe` reads more naturally +# in discovery contexts: "describe this agent to me"). Both verbs invoke +# the same underlying Click command — same callback, same options, same +# behavior. The shared registration means new options added to `agents_get` +# automatically apply to `agents describe`. +agents.add_command(agents_get, name="describe") + + main.add_command(agents) @@ -2047,5 +2072,217 @@ def messages_ack(ctx: click.Context, msg_id: str) -> None: main.add_command(messages) +# --- Universal `message-to ` shortcut --- +# +# Per the agent-directory productization PRD (https://trydock.ai/mike/agent-directory-productization-prd): +# the canonical inter-agent comm primitive. Hides the messaging-primitive +# routing details (X-Cueapi-From-Agent header, Idempotency-Key header, +# /v1/messages POST shape) so callers don't need to remember them. +# +# Anti-pattern this replaces: per-agent bash scripts (cma-reply.sh and +# its siblings) that re-implement the routing protocol. Each per-agent +# script is a fresh opportunity to mis-encode the same fields the PRD +# exists to fix. Universal CLI command means one place to look + one +# migration path when messaging-primitive internals change. + + +@main.command(name="message-to") +@click.argument("agent_name") +@click.argument("body", required=False) +@click.option( + "--from", + "from_agent", + default=None, + envvar="CUEAPI_MY_AGENT_SLUG", + help=( + "Sender agent — opaque agent_id or slug-form (agent@user). Sent as " + "the X-Cueapi-From-Agent header. Defaults to $CUEAPI_MY_AGENT_SLUG " + "env var. Required either via flag or env." + ), +) +@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).", +) +@click.option( + "--expects-reply", + "expects_reply", + is_flag=True, + default=False, + help="Mark this message as expecting a reply.", +) +@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.option( + "--token", + default=None, + help=( + "Optional thread token (e.g. MY-TOPIC-V1). Stored in metadata.token " + "and surfaced in the subject line if no --subject is given. Useful " + "for traceable threads." + ), +) +@click.option( + "--skip-resolve", + "skip_resolve", + is_flag=True, + default=False, + help=( + "Skip the pre-flight `agents get ` resolution check. By default " + "the CLI verifies the recipient exists before sending so you get a " + "clearer error than the server's. Use --skip-resolve to save one " + "API call when you're certain the recipient is valid." + ), +) +@click.pass_context +def message_to( + ctx: click.Context, + agent_name: str, + body: Optional[str], + from_agent: Optional[str], + subject: Optional[str], + reply_to: Optional[str], + priority: Optional[int], + expects_reply: bool, + idempotency_key: Optional[str], + token: Optional[str], + skip_resolve: bool, +) -> None: + """Send a message to an agent by name. Universal inter-agent comm shortcut. + + Hides the messaging-primitive routing details (X-Cueapi-From-Agent + header, Idempotency-Key, /v1/messages body shape). Resolves AGENT_NAME + via `agents get` first (server-side slug-form lookup) so a typo'd name + fails fast with a clear error instead of being silently routed to a + nonexistent agent. + + Body comes either from the BODY positional argument OR stdin (use `-` + or pipe in). The stdin path is useful for multi-line messages or for + composing via `$EDITOR | cueapi message-to alice -`. + + Examples: + + \b + cueapi message-to alice "ack on the design review" + echo "longer message" | cueapi message-to alice - + cueapi message-to alice "$(cat draft.md)" --subject "[REVIEW] design" + cueapi message-to alice "ack" --token PR-REVIEW-V1 --expects-reply + """ + # Body resolution: positional arg, stdin if "-", or stdin if missing. + if body is None: + # No positional — read from stdin (allows `cmd | cueapi message-to ...`) + body = click.get_text_stream("stdin").read().rstrip("\n") + elif body == "-": + # Explicit stdin marker. + body = click.get_text_stream("stdin").read().rstrip("\n") + if not body: + raise click.UsageError( + "Message body is required. Pass as second positional arg, via stdin, or as `-`." + ) + + if not from_agent: + raise click.UsageError( + "--from is required (or set $CUEAPI_MY_AGENT_SLUG env var). " + "This is the sender agent slug; the server reads it from the " + "X-Cueapi-From-Agent header." + ) + + if idempotency_key is not None and len(idempotency_key) > 255: + raise click.UsageError("--idempotency-key must be ≤255 characters") + + # Subject auto-fill from token if not provided. Standard convention: + # `[TOKEN] ` (truncated). Token-tagged subjects + # are how threads stay traceable across PR/CTO/etc. comms. + if not subject and token: + first_line = body.splitlines()[0] if body else "" + subject = f"[{token}] {first_line[:80]}".strip() + + try: + with CueAPIClient(api_key=ctx.obj.get("api_key"), profile=ctx.obj.get("profile")) as client: + # Pre-flight resolve unless --skip-resolve. Catches typo'd + # recipients before we send and returns a clean error message + # naming the unknown agent — matches PRD Success Criterion #3 + # ("Wrong task name = no longer possible"). + if not skip_resolve: + resolve_resp = client.get(f"/agents/{agent_name}") + if resolve_resp.status_code == 404: + echo_error( + f"Unknown agent '{agent_name}'. " + f"Run `cueapi agents list` to see available agents." + ) + return + if resolve_resp.status_code != 200: + echo_error(f"Failed to resolve agent '{agent_name}' (HTTP {resolve_resp.status_code})") + return + + payload: dict = {"to": agent_name, "body": body} + if subject: + payload["subject"] = subject + if reply_to: + payload["reply_to"] = reply_to + if priority is not None: + payload["priority"] = priority + if expects_reply: + payload["expects_reply"] = True + if token: + # Stash token in metadata for downstream traceability. + payload["metadata"] = {"token": token} + + headers: dict = {"X-Cueapi-From-Agent": from_agent} + if idempotency_key: + headers["Idempotency-Key"] = idempotency_key + + resp = client.post("/messages", json=payload, 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'} to {agent_name}: {m.get('id', '?')}") + if m.get("thread_id"): + echo_info("Thread:", m["thread_id"]) + echo_info("Delivery state:", m.get("delivery_state", "?")) + # Surface server-side priority-downgrade signal. + downgraded = None + try: + downgraded = resp.headers.get("X-CueAPI-Priority-Downgraded") + except Exception: + pass + if downgraded == "true": + echo_info( + "Priority downgraded:", + "true (receiver-pair limit applied; 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 644c5cf..636c17f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2160,3 +2160,234 @@ def test_top_level_help_lists_messages(): result = runner.invoke(main, ["--help"]) assert result.exit_code == 0 assert "messages" in result.output + + +# --- agents --online-only flag + describe alias --- + + +def test_agents_list_help_shows_online_only_flag(): + result = runner.invoke(main, ["agents", "list", "--help"]) + assert result.exit_code == 0 + assert "--online-only" in result.output + + +def test_agents_describe_alias_exists_in_group_help(): + result = runner.invoke(main, ["agents", "--help"]) + assert result.exit_code == 0 + assert "describe" in result.output + assert "get" in result.output + + +def test_agents_describe_takes_ref_argument(): + result = runner.invoke(main, ["agents", "describe", "--help"]) + assert result.exit_code == 0 + assert "REF" in result.output or "ref" in result.output.lower() + + +def test_agents_list_online_only_and_status_mutually_exclusive(): + result = runner.invoke( + main, + ["agents", "list", "--online-only", "--status", "offline"], + ) + assert result.exit_code != 0 + assert "mutually exclusive" in result.output.lower() + + +class _AgentsOnlineOnlyClient: + def __init__(self): + self.last_get_path = None + self.last_params = None + + def __enter__(self): + return self + + def __exit__(self, *_): + pass + + def get(self, path, params=None, **_): + self.last_get_path = path + self.last_params = params + class _R: + status_code = 200 + def json(self): + return {"agents": [], "total": 0} + return _R() + + +def test_agents_list_online_only_translates_to_status_online(monkeypatch): + holder: dict = {} + import cueapi.cli as cli_mod + def fake_factory(*_, **__): + holder["client"] = _AgentsOnlineOnlyClient() + return holder["client"] + monkeypatch.setattr(cli_mod, "CueAPIClient", fake_factory) + + result = runner.invoke(main, ["agents", "list", "--online-only"]) + assert result.exit_code == 0, result.output + assert holder["client"].last_params.get("status") == "online" + + +# --- cueapi message-to --- + + +class _MessageToClient: + def __init__(self, recipient_exists=True): + self.calls = [] + self.recipient_exists = recipient_exists + + def __enter__(self): + return self + + def __exit__(self, *_): + pass + + def get(self, path, params=None, **_): + self.calls.append(("GET", path, params, None)) + recipient_exists_local = self.recipient_exists + class _R: + status_code = 200 if recipient_exists_local else 404 + def json(self): + return {"id": "agt_x", "slug": "alice"} if recipient_exists_local else {} + headers = {} + return _R() + + def post(self, path, json=None, headers=None, **_): + self.calls.append(("POST", path, json, headers)) + class _R: + status_code = 201 + def json(self): + return {"id": "msg_x", "delivery_state": "queued", "thread_id": "thr_x"} + headers = {} + return _R() + + +def _patch_msg_client(monkeypatch, holder, recipient_exists=True): + import cueapi.cli as cli_mod + def fake_factory(*_, **__): + holder["client"] = _MessageToClient(recipient_exists=recipient_exists) + return holder["client"] + monkeypatch.setattr(cli_mod, "CueAPIClient", fake_factory) + + +def test_message_to_help_lists_flags(): + result = runner.invoke(main, ["message-to", "--help"]) + assert result.exit_code == 0 + assert "--from" in result.output + assert "--subject" in result.output + assert "--idempotency-key" in result.output + assert "--token" in result.output + assert "--skip-resolve" in result.output + + +def test_message_to_top_level_help_includes_command(): + result = runner.invoke(main, ["--help"]) + assert result.exit_code == 0 + assert "message-to" in result.output + + +def test_message_to_uses_env_var_for_from(monkeypatch): + holder: dict = {} + _patch_msg_client(monkeypatch, holder) + result = runner.invoke( + main, + ["message-to", "alice", "hello there"], + env={"CUEAPI_MY_AGENT_SLUG": "sender-bot"}, + ) + assert result.exit_code == 0, result.output + post_calls = [c for c in holder["client"].calls if c[0] == "POST"] + assert len(post_calls) == 1 + _, path, body, headers = post_calls[0] + assert path == "/messages" + assert body == {"to": "alice", "body": "hello there"} + assert headers["X-Cueapi-From-Agent"] == "sender-bot" + + +def test_message_to_resolves_recipient_first(monkeypatch): + holder: dict = {} + _patch_msg_client(monkeypatch, holder) + result = runner.invoke( + main, + ["message-to", "alice", "hi", "--from", "bob"], + ) + assert result.exit_code == 0 + assert holder["client"].calls[0][0] == "GET" + assert holder["client"].calls[0][1] == "/agents/alice" + assert holder["client"].calls[1][0] == "POST" + + +def test_message_to_unknown_recipient_clear_error(monkeypatch): + holder: dict = {} + _patch_msg_client(monkeypatch, holder, recipient_exists=False) + result = runner.invoke( + main, + ["message-to", "ghost", "hi", "--from", "bob"], + ) + assert "Unknown agent" in result.output or "ghost" in result.output + assert "agents list" in result.output + post_calls = [c for c in holder["client"].calls if c[0] == "POST"] + assert len(post_calls) == 0 + + +def test_message_to_skip_resolve_skips_pre_flight(monkeypatch): + holder: dict = {} + _patch_msg_client(monkeypatch, holder, recipient_exists=False) + result = runner.invoke( + main, + ["message-to", "ghost", "hi", "--from", "bob", "--skip-resolve"], + ) + assert result.exit_code == 0 + get_calls = [c for c in holder["client"].calls if c[0] == "GET"] + post_calls = [c for c in holder["client"].calls if c[0] == "POST"] + assert len(get_calls) == 0 + assert len(post_calls) == 1 + + +def test_message_to_token_auto_fills_subject(monkeypatch): + holder: dict = {} + _patch_msg_client(monkeypatch, holder) + result = runner.invoke( + main, + ["message-to", "alice", "first line of body\nsecond line", "--from", "bob", "--token", "PR-V1"], + ) + assert result.exit_code == 0 + body = [c for c in holder["client"].calls if c[0] == "POST"][0][2] + assert "subject" in body + assert "[PR-V1]" in body["subject"] + assert "first line" in body["subject"] + assert body["metadata"] == {"token": "PR-V1"} + + +def test_message_to_idempotency_key_too_long_rejected_client_side(): + long_key = "x" * 256 + result = runner.invoke( + main, + ["message-to", "alice", "hi", "--from", "bob", "--idempotency-key", long_key], + ) + assert result.exit_code != 0 + assert "255" in result.output or "characters" in result.output.lower() + + +def test_message_to_priority_validated_by_click(): + result = runner.invoke( + main, + ["message-to", "alice", "hi", "--from", "bob", "--priority", "9"], + ) + assert result.exit_code != 0 + + +def test_message_to_with_explicit_subject_takes_precedence_over_token(monkeypatch): + holder: dict = {} + _patch_msg_client(monkeypatch, holder) + result = runner.invoke( + main, + [ + "message-to", "alice", "body", + "--from", "bob", + "--token", "PR-V1", + "--subject", "explicit subject wins", + ], + ) + assert result.exit_code == 0 + body = [c for c in holder["client"].calls if c[0] == "POST"][0][2] + assert body["subject"] == "explicit subject wins" + assert body["metadata"] == {"token": "PR-V1"}