Skip to content

Commit 2be3fa3

Browse files
mikemolinetclaude
andcommitted
feat: cueapi message-to <name> + agents list --online-only + agents describe alias
Three server-independent CLI additions ahead of the agents-roster endpoint (CTO greenlight 2026-05-05, token CTO-AGENT-DIR-CLI-V2-GREENLIGHT). - `cueapi message-to <recipient>` top-level wrapper. Resolves <recipient> against the calling key's agent roster: - agent_id (`agt_*`) and slug-form (`slug@user`) pass through unchanged — no lookup, no extra round-trip. - bare names match case-insensitive against `display_name` and `slug` via `GET /agents`. Zero matches errors with the roster. Multiple matches errors with the candidate IDs and points users at `messages send` for unambiguous addressing. Reuses the existing `POST /messages` request shape and response handling (idempotency 200/201, 409 conflict messaging, X-CueAPI-Priority-Downgraded surfacing). - `agents list --online-only` shortcut for `--status online`. Mutually exclusive with `--status`. Server already supports the filter; the flag is just a friendlier surface. - `agents describe <ref>` alias for `agents get <ref>`. Same argument and `--include-deleted` flag; defers to `agents_get`. Universal `message-to` (Q8 wrapper) is canonical over per-agent scripts (anti-pattern repeat / migration cost / onboarding / Surface 5 composition all rule against per-agent scripts). last_seen_at surfacing and `agents roster` debug command land after the server-side Surface 1 endpoint ships. Tests: 19 new (3 --online-only, 2 describe, 14 message-to — pass-through, display-name match, slug match, no-match, ambiguous, optionals, defaults, validation). 169/169 pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent dd75c63 commit 2be3fa3

3 files changed

Lines changed: 579 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22

33
All notable changes to cueapi-cli will be documented here.
44

5+
## [Unreleased]
6+
7+
### Added
8+
- `cueapi message-to <recipient>` top-level wrapper for sending a message by name. Resolves `<recipient>` 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`).
9+
- `agents list --online-only` shortcut for `--status online`. Mutually exclusive with `--status`.
10+
- `agents describe <ref>` alias for `agents get <ref>`.
11+
512
## [0.2.0] - 2026-05-01
613

714
### Added

cueapi/cli.py

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1374,18 +1374,30 @@ def agents_create(
13741374

13751375
@agents.command(name="list")
13761376
@click.option("--status", default=None, type=click.Choice(["online", "offline", "away"]), help="Filter by status")
1377+
@click.option(
1378+
"--online-only",
1379+
"online_only",
1380+
is_flag=True,
1381+
default=False,
1382+
help="Shortcut for --status online. Mutually exclusive with --status.",
1383+
)
13771384
@click.option("--include-deleted", is_flag=True, default=False, help="Include soft-deleted agents")
13781385
@click.option("--limit", default=50, type=int, help="Max results (default 50, max 100)")
13791386
@click.option("--offset", default=0, type=int, help="Offset for pagination")
13801387
@click.pass_context
13811388
def agents_list(
13821389
ctx: click.Context,
13831390
status: Optional[str],
1391+
online_only: bool,
13841392
include_deleted: bool,
13851393
limit: int,
13861394
offset: int,
13871395
) -> None:
13881396
"""List your agents."""
1397+
if online_only and status:
1398+
raise click.UsageError("--online-only and --status are mutually exclusive")
1399+
if online_only:
1400+
status = "online"
13891401
try:
13901402
with CueAPIClient(api_key=ctx.obj.get("api_key"), profile=ctx.obj.get("profile")) as client:
13911403
params: dict = {"limit": limit, "offset": offset}
@@ -1459,6 +1471,15 @@ def agents_get(ctx: click.Context, ref: str, include_deleted: bool) -> None:
14591471
click.echo(str(e))
14601472

14611473

1474+
@agents.command(name="describe")
1475+
@click.argument("ref")
1476+
@click.option("--include-deleted", is_flag=True, default=False, help="Include soft-deleted agents")
1477+
@click.pass_context
1478+
def agents_describe(ctx: click.Context, ref: str, include_deleted: bool) -> None:
1479+
"""Alias for `agents get`."""
1480+
ctx.invoke(agents_get, ref=ref, include_deleted=include_deleted)
1481+
1482+
14621483
@agents.command(name="update")
14631484
@click.argument("ref")
14641485
@click.option("--display-name", "display_name", default=None, help="New display name")
@@ -2047,5 +2068,193 @@ def messages_ack(ctx: click.Context, msg_id: str) -> None:
20472068
main.add_command(messages)
20482069

20492070

2071+
def _resolve_recipient(client, recipient: str) -> str:
2072+
"""Resolve a recipient string to an agent_id or slug-form.
2073+
2074+
Pass-through when `recipient` already looks like an agent_id (`agt_*`)
2075+
or slug-form (`slug@user`). Otherwise list `/agents` and match
2076+
`display_name` or `slug` case-insensitive exact.
2077+
"""
2078+
if recipient.startswith("agt_") or "@" in recipient:
2079+
return recipient
2080+
2081+
candidates: list = []
2082+
offset = 0
2083+
while True:
2084+
resp = client.get("/agents", params={"limit": 100, "offset": offset})
2085+
if resp.status_code != 200:
2086+
raise click.ClickException(
2087+
f"Failed to list agents (HTTP {resp.status_code})"
2088+
)
2089+
page = resp.json().get("agents", [])
2090+
candidates.extend(page)
2091+
if len(page) < 100 or offset >= 200:
2092+
break
2093+
offset += 100
2094+
2095+
needle = recipient.lower()
2096+
matches = [
2097+
a
2098+
for a in candidates
2099+
if (a.get("display_name") or "").lower() == needle
2100+
or (a.get("slug") or "").lower() == needle
2101+
]
2102+
if not matches:
2103+
known = sorted({
2104+
a.get("display_name") or a.get("slug") or a.get("id", "?")
2105+
for a in candidates
2106+
})
2107+
hint = ", ".join(known) if known else "(no agents in roster)"
2108+
raise click.ClickException(
2109+
f"No agent matches '{recipient}'. Roster: {hint}"
2110+
)
2111+
if len(matches) > 1:
2112+
ids = ", ".join(m.get("id", "?") for m in matches)
2113+
raise click.ClickException(
2114+
f"'{recipient}' matches {len(matches)} agents: {ids}. "
2115+
"Disambiguate with --to <agent_id> via `messages send`."
2116+
)
2117+
return matches[0].get("id", recipient)
2118+
2119+
2120+
@main.command(name="message-to")
2121+
@click.argument("recipient")
2122+
@click.option(
2123+
"--from",
2124+
"from_agent",
2125+
required=True,
2126+
help=(
2127+
"Sender agent — opaque agent_id or slug-form (agent@user). Sent as "
2128+
"the X-Cueapi-From-Agent header."
2129+
),
2130+
)
2131+
@click.option("--body", "body_text", required=True, help="Message body (1-32768 chars)")
2132+
@click.option("--subject", default=None, help="Optional subject line (max 255 chars)")
2133+
@click.option(
2134+
"--reply-to",
2135+
"reply_to",
2136+
default=None,
2137+
help="Previous message ID this is replying to (msg_<12 alphanumeric>). thread_id inherits.",
2138+
)
2139+
@click.option(
2140+
"--priority",
2141+
default=None,
2142+
type=click.IntRange(1, 5),
2143+
help="Priority 1-5 (server default 3). Receiver-pair limits may downgrade priority>3 to 3.",
2144+
)
2145+
@click.option(
2146+
"--expects-reply",
2147+
"expects_reply",
2148+
is_flag=True,
2149+
default=False,
2150+
help="Mark this message as expecting a reply.",
2151+
)
2152+
@click.option(
2153+
"--reply-to-agent",
2154+
"reply_to_agent",
2155+
default=None,
2156+
help="Decoupled reply target (defaults to the sender).",
2157+
)
2158+
@click.option("--metadata", default=None, help="JSON metadata blob")
2159+
@click.option(
2160+
"--idempotency-key",
2161+
"idempotency_key",
2162+
default=None,
2163+
help=(
2164+
"Optional Idempotency-Key header (≤255 chars). Same key + same body within "
2165+
"24h returns the existing message with HTTP 200 instead of 201."
2166+
),
2167+
)
2168+
@click.pass_context
2169+
def message_to(
2170+
ctx: click.Context,
2171+
recipient: str,
2172+
from_agent: str,
2173+
body_text: str,
2174+
subject: Optional[str],
2175+
reply_to: Optional[str],
2176+
priority: Optional[int],
2177+
expects_reply: bool,
2178+
reply_to_agent: Optional[str],
2179+
metadata: Optional[str],
2180+
idempotency_key: Optional[str],
2181+
) -> None:
2182+
"""Send a message to a recipient by name, slug, or agent ID.
2183+
2184+
Resolves <recipient> against your roster:
2185+
agent_id (agt_*) or slug-form (slug@user) — used as-is.
2186+
bare name — matched case-insensitive against display_name and slug.
2187+
"""
2188+
body: dict = {"body": body_text}
2189+
if subject:
2190+
body["subject"] = subject
2191+
if reply_to:
2192+
body["reply_to"] = reply_to
2193+
if priority is not None:
2194+
body["priority"] = priority
2195+
if expects_reply:
2196+
body["expects_reply"] = True
2197+
if reply_to_agent:
2198+
body["reply_to_agent"] = reply_to_agent
2199+
if metadata:
2200+
try:
2201+
body["metadata"] = json.loads(metadata)
2202+
except json.JSONDecodeError:
2203+
raise click.UsageError("--metadata must be valid JSON")
2204+
2205+
headers: dict = {"X-Cueapi-From-Agent": from_agent}
2206+
if idempotency_key:
2207+
if len(idempotency_key) > 255:
2208+
raise click.UsageError("--idempotency-key must be ≤255 characters")
2209+
headers["Idempotency-Key"] = idempotency_key
2210+
2211+
try:
2212+
with CueAPIClient(api_key=ctx.obj.get("api_key"), profile=ctx.obj.get("profile")) as client:
2213+
try:
2214+
resolved = _resolve_recipient(client, recipient)
2215+
except click.ClickException as e:
2216+
echo_error(str(e))
2217+
return
2218+
body["to"] = resolved
2219+
2220+
resp = client.post("/messages", json=body, headers=headers)
2221+
if resp.status_code in (200, 201):
2222+
m = resp.json()
2223+
click.echo()
2224+
if resp.status_code == 200:
2225+
echo_info("Idempotency-Key dedup hit:", "existing message returned")
2226+
echo_success(f"{'Sent' if resp.status_code == 201 else 'Existing'}: {m.get('id', '?')}")
2227+
echo_info("To:", resolved)
2228+
if m.get("thread_id"):
2229+
echo_info("Thread:", m["thread_id"])
2230+
echo_info("Delivery state:", m.get("delivery_state", "?"))
2231+
downgraded_header = None
2232+
try:
2233+
downgraded_header = resp.headers.get("X-CueAPI-Priority-Downgraded")
2234+
except Exception:
2235+
pass
2236+
if downgraded_header == "true":
2237+
echo_info(
2238+
"Priority downgraded:",
2239+
"true (receiver-pair limit applied; message delivered at priority 3)",
2240+
)
2241+
click.echo()
2242+
elif resp.status_code == 409:
2243+
error = resp.json().get("detail", {}).get("error", {})
2244+
code = error.get("code", "conflict")
2245+
if code == "idempotency_key_conflict":
2246+
echo_error(
2247+
"Idempotency-Key conflict — same key was already used with a different body. "
2248+
"Either reuse the original body or change the key."
2249+
)
2250+
else:
2251+
echo_error(error.get("message", f"Conflict (HTTP 409, {code})"))
2252+
else:
2253+
error = resp.json().get("detail", {}).get("error", {})
2254+
echo_error(error.get("message", f"Failed (HTTP {resp.status_code})"))
2255+
except click.ClickException as e:
2256+
click.echo(str(e))
2257+
2258+
20502259
if __name__ == "__main__":
20512260
main()

0 commit comments

Comments
 (0)