agentconnect-cli 0.1.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. agentconnect/__init__.py +3 -0
  2. agentconnect/a2a_adapter/__init__.py +6 -0
  3. agentconnect/a2a_adapter/adapter.py +565 -0
  4. agentconnect/a2a_adapter/cards.py +149 -0
  5. agentconnect/a2a_adapter/convert.py +157 -0
  6. agentconnect/a2a_adapter/facade.py +560 -0
  7. agentconnect/a2a_adapter/seen.py +49 -0
  8. agentconnect/a2a_adapter/serve.py +67 -0
  9. agentconnect/adapter.py +319 -0
  10. agentconnect/adapters.py +52 -0
  11. agentconnect/apiclient.py +51 -0
  12. agentconnect/backoff.py +42 -0
  13. agentconnect/callback.py +314 -0
  14. agentconnect/cli.py +2324 -0
  15. agentconnect/cli_json.py +470 -0
  16. agentconnect/cli_support.py +444 -0
  17. agentconnect/clock.py +161 -0
  18. agentconnect/config.py +1213 -0
  19. agentconnect/daemon/__init__.py +41 -0
  20. agentconnect/deliveries.py +295 -0
  21. agentconnect/directory_client.py +255 -0
  22. agentconnect/failpoints.py +99 -0
  23. agentconnect/identity.py +111 -0
  24. agentconnect/isolation.py +345 -0
  25. agentconnect/keystore.py +279 -0
  26. agentconnect/limits.py +457 -0
  27. agentconnect/log.py +143 -0
  28. agentconnect/mcp/__init__.py +30 -0
  29. agentconnect/mcp/backend.py +405 -0
  30. agentconnect/mcp/server.py +382 -0
  31. agentconnect/mcp/session.py +373 -0
  32. agentconnect/mcp/tools.py +583 -0
  33. agentconnect/ownerfile.py +238 -0
  34. agentconnect/payloads.py +109 -0
  35. agentconnect/policy.py +1066 -0
  36. agentconnect/provision.py +239 -0
  37. agentconnect/py.typed +0 -0
  38. agentconnect/relay.py +405 -0
  39. agentconnect/runtime/__init__.py +46 -0
  40. agentconnect/runtime/agent.py +225 -0
  41. agentconnect/runtime/api.py +2043 -0
  42. agentconnect/runtime/engine.py +5359 -0
  43. agentconnect/runtime/errors.py +47 -0
  44. agentconnect/runtime/floor.py +649 -0
  45. agentconnect/runtime/groups.py +1351 -0
  46. agentconnect/runtime/knocks.py +1422 -0
  47. agentconnect/runtime/ledger.py +94 -0
  48. agentconnect/runtime/listing.py +1307 -0
  49. agentconnect/runtime/registry.py +53 -0
  50. agentconnect/runtime/runtime.py +158 -0
  51. agentconnect/runtime/sas.py +1002 -0
  52. agentconnect/sas_emoji.py +83 -0
  53. agentconnect/schemas.py +381 -0
  54. agentconnect/shapes.py +56 -0
  55. agentconnect/sockets.py +86 -0
  56. agentconnect/sse.py +234 -0
  57. agentconnect/store.py +3282 -0
  58. agentconnect/tasks.py +35 -0
  59. agentconnect/tokens.py +63 -0
  60. agentconnect/webhook.py +199 -0
  61. agentconnect_cli-0.1.0a1.dist-info/METADATA +103 -0
  62. agentconnect_cli-0.1.0a1.dist-info/RECORD +64 -0
  63. agentconnect_cli-0.1.0a1.dist-info/WHEEL +4 -0
  64. agentconnect_cli-0.1.0a1.dist-info/entry_points.txt +3 -0
agentconnect/cli.py ADDED
@@ -0,0 +1,2324 @@
1
+ """`agentconnect` command-line interface.
2
+
3
+ A thin layer over the daemon's local API (``agentconnect.runtime.api``): it parses
4
+ arguments, calls the daemon, and renders the answer. Protocol decisions live
5
+ in the daemon. Every command takes ``--json`` for a stable machine-readable
6
+ shape (docs/cli.md); errors are then ``{"error": {"code", "message"}}`` on
7
+ stdout with a non-zero exit code.
8
+
9
+ ``approve`` and ``reject`` (joins, knocks and held messages), ``verify`` (and
10
+ the mismatch path inside it), ``remove``, ``group role``, ``group transfer``,
11
+ ``group policy`` (except turning ``--require-verified`` on) and ``group
12
+ create`` with a policy option, ``invite create --replica``, ``safety set``,
13
+ ``audit``, ``transcript`` and ``listing publish`` use the **human token**.
14
+ They are decisions (or records) only a person may make or read (SPEC 6.2,
15
+ 6.6.4, 9.2, 11): never give an agent the human token or let it run them for
16
+ the human.
17
+
18
+ ``identity export`` and ``identity import`` (provisioning a replica, SPEC
19
+ 9.4) don't use the API at all: they work on a stopped runtime's data
20
+ directory (:mod:`agentconnect.provision`), and nothing else offers them.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import asyncio
26
+ import contextlib
27
+ import json
28
+ import math
29
+ import re
30
+ import sys
31
+ from collections.abc import Callable
32
+ from datetime import UTC, datetime
33
+ from pathlib import Path
34
+ from typing import Annotated, Any
35
+
36
+ import typer
37
+
38
+ import agentconnect_core
39
+ from agentconnect import __version__, cli_support, log, provision
40
+ from agentconnect import daemon as daemon_host
41
+ from agentconnect.apiclient import ApiClient, ApiClientError
42
+ from agentconnect.cli_support import (
43
+ COMPARE_REMINDER,
44
+ EXIT_CODES_HELP,
45
+ EXIT_FAILED,
46
+ EXIT_INTERCEPTED,
47
+ EXIT_MISMATCH,
48
+ EXIT_REFUSED,
49
+ EXIT_USAGE,
50
+ HUMAN_ONLY_HELP,
51
+ INTERCEPTION_WARNING,
52
+ LOCAL_ONLY_HELP,
53
+ REPLICA_REMINDER,
54
+ SHARE_REMINDER,
55
+ CliError,
56
+ IdArgsCommand,
57
+ Sas,
58
+ clean,
59
+ connect,
60
+ emit_json,
61
+ events_after,
62
+ group_view,
63
+ guard,
64
+ load,
65
+ parse_ttl,
66
+ resolve_group,
67
+ unverified_hint,
68
+ )
69
+ from agentconnect.config import ConfigError
70
+ from agentconnect.isolation import IsolationError
71
+ from agentconnect.keystore import KeystoreError
72
+
73
+ # Pretty tracebacks with locals would print envelopes, plaintext and secrets.
74
+ app = typer.Typer(
75
+ no_args_is_help=True,
76
+ help="AgentConnect: E2E-encrypted agent-to-agent groups.\n\n"
77
+ f"{EXIT_CODES_HELP}\n\n"
78
+ "Approving joins and comparing short codes (approve, reject, verify) are "
79
+ "decisions for a person and use the human token. Never give an agent the "
80
+ "human token or let it run those commands for the human.",
81
+ pretty_exceptions_show_locals=False,
82
+ )
83
+ daemon_app = typer.Typer(
84
+ no_args_is_help=True,
85
+ help="Run the daemon (agentconnectd).",
86
+ pretty_exceptions_show_locals=False,
87
+ )
88
+ group_app = typer.Typer(
89
+ no_args_is_help=True,
90
+ help="Create groups, and change their policy, roles and owner (`policy` beyond "
91
+ "turning --require-verified on, `role` and `transfer` are HUMAN ONLY: human token).",
92
+ pretty_exceptions_show_locals=False,
93
+ )
94
+ invite_app = typer.Typer(
95
+ no_args_is_help=True, help="Create and revoke invites.", pretty_exceptions_show_locals=False
96
+ )
97
+ groups_app = typer.Typer(
98
+ invoke_without_command=True,
99
+ help="List groups, or `show <id>` one.",
100
+ pretty_exceptions_show_locals=False,
101
+ )
102
+ approvals_app = typer.Typer(
103
+ invoke_without_command=True,
104
+ help="Pending join requests, knocks and held messages. `approve` and `reject` are "
105
+ "HUMAN ONLY (human token): never give an agent the human token or run them for the human.",
106
+ pretty_exceptions_show_locals=False,
107
+ )
108
+ safety_app = typer.Typer(
109
+ no_args_is_help=True,
110
+ help="A group's safety policy (docs/safety.md). `set` is HUMAN ONLY (human token).",
111
+ pretty_exceptions_show_locals=False,
112
+ )
113
+ listing_app = typer.Typer(
114
+ no_args_is_help=True,
115
+ help="Your agent's listing in a directory (docs/discovery.md). `publish` is HUMAN ONLY "
116
+ "(human token): an agent can't put itself into a public directory. `withdraw` and "
117
+ "`status` work with the agent token.",
118
+ pretty_exceptions_show_locals=False,
119
+ )
120
+ identity_app = typer.Typer(
121
+ no_args_is_help=True,
122
+ help="Provision a replica: export this agent's identity key, and import it into a new "
123
+ "data directory with a fresh instance. HUMAN ONLY, and local: no API route, SDK call "
124
+ "or MCP tool does this, and both refuse to run while a runtime uses the data directory.",
125
+ pretty_exceptions_show_locals=False,
126
+ )
127
+ app.add_typer(daemon_app, name="daemon")
128
+ app.add_typer(identity_app, name="identity")
129
+ app.add_typer(listing_app, name="listing")
130
+ app.add_typer(group_app, name="group")
131
+ app.add_typer(invite_app, name="invite")
132
+ app.add_typer(groups_app, name="groups")
133
+ app.add_typer(approvals_app, name="approvals")
134
+ app.add_typer(safety_app, name="safety")
135
+
136
+ ConfigOpt = Annotated[Path | None, typer.Option("--config", help="Path of config.toml.")]
137
+ JsonOpt = Annotated[bool, typer.Option("--json", help="Machine-readable output.")]
138
+ YesOpt = Annotated[bool, typer.Option("--yes", "-y", help="Don't ask for confirmation.")]
139
+
140
+ WAIT_POLL_SECS = 1.0
141
+ FOLLOW_POLL_SECS = 1.0
142
+ MAX_NAME = 80 # SPEC 4.2: group names are 1 to 80 characters
143
+
144
+
145
+ def _say(text: str = "", *, as_json: bool = False) -> None:
146
+ """Human-readable output; in ``--json`` mode it goes to stderr."""
147
+ typer.echo(text, err=as_json)
148
+
149
+
150
+ def _when(ts: int) -> str:
151
+ return datetime.fromtimestamp(ts, tz=UTC).strftime("%Y-%m-%d %H:%M UTC")
152
+
153
+
154
+ def _require_yes(as_json: bool, yes: bool, what: str) -> None:
155
+ if as_json and not yes:
156
+ msg = f"--json can't prompt; pass --yes to {what}"
157
+ raise CliError("needs_yes", msg, EXIT_USAGE)
158
+
159
+
160
+ def _confirm(question: str, *, yes: bool, as_json: bool) -> bool:
161
+ return yes or typer.confirm(question, default=False, err=as_json)
162
+
163
+
164
+ # -- basics ---------------------------------------------------------------------
165
+
166
+
167
+ @app.command()
168
+ def version(as_json: JsonOpt = False) -> None:
169
+ """Show package and protocol versions."""
170
+ info = {
171
+ "version": __version__,
172
+ "protocol": agentconnect_core.PROTOCOL_VERSION,
173
+ "ciphersuite": agentconnect_core.CIPHERSUITE_V0,
174
+ }
175
+ if as_json:
176
+ typer.echo(json.dumps(info))
177
+ else:
178
+ typer.echo(f"agentconnect {info['version']} (protocol v{info['protocol']})")
179
+
180
+
181
+ @app.command()
182
+ def status(config: ConfigOpt = None, as_json: JsonOpt = False) -> None:
183
+ """Show the running daemon's status (reads the local API)."""
184
+ with guard(as_json):
185
+ info = connect(config).request("GET", "/v0/status")
186
+ if as_json:
187
+ emit_json(info)
188
+ return
189
+ relay = "connected" if info["relay"]["connected"] else "disconnected"
190
+ typer.echo(f"identity {info['identity']}")
191
+ typer.echo(f"fingerprint {info['fingerprint']}")
192
+ typer.echo(f"relay {info['relay']['url']} ({relay})")
193
+ typer.echo(f"groups {info['groups']} active, {len(info['out_of_sync'])} out of sync")
194
+ typer.echo(
195
+ f"queues {info['outbox_queued']} outbound, {info['inbox_pending']} inbound"
196
+ )
197
+ typer.echo(f"approvals {info['pending_approvals']} pending")
198
+ typer.echo(f"held {info.get('held_messages', 0)} message(s) waiting for approval")
199
+
200
+
201
+ @daemon_app.command("run")
202
+ def daemon_run(
203
+ config: ConfigOpt = None,
204
+ tcp_port: Annotated[
205
+ int, typer.Option("--tcp-port", help="Serve the API on 127.0.0.1:<port> instead.")
206
+ ] = 0,
207
+ ) -> None:
208
+ """Run agentconnectd in the foreground (JSON logs on stderr).
209
+
210
+ Needs relay.url (AC_RELAY_URL): there is no built-in relay. Starts the
211
+ adapters named by daemon.adapters (webhook, sse, mcp), and
212
+ the A2A adapter when a2a.local_agent_url or a2a.facade_port is set. On
213
+ SIGINT or SIGTERM, queued sends get up to 3 seconds to go out while the
214
+ relay is connected; the rest stay in the encrypted outbox for the next
215
+ start.
216
+ """
217
+ with guard(False):
218
+ cfg = load(config, need_relay=True)
219
+ if tcp_port:
220
+ cfg = cfg.with_overrides(api_tcp_port=tcp_port)
221
+ log.configure()
222
+ with guard(False):
223
+ try:
224
+ asyncio.run(daemon_host.serve(cfg))
225
+ except IsolationError as e:
226
+ raise CliError("isolation", f"refusing to start: {e}", EXIT_USAGE) from None
227
+
228
+
229
+ @app.command("mcp")
230
+ def mcp_run( # noqa: PLR0913, PLR0917
231
+ agent: Annotated[
232
+ str | None,
233
+ typer.Option(
234
+ "--agent",
235
+ help="The agent's name: its data directory is $XDG_DATA_HOME/agentconnect-<name> "
236
+ "(default: mcp). One name is one identity.",
237
+ ),
238
+ ] = None,
239
+ data_dir: Annotated[
240
+ Path | None, typer.Option("--data-dir", help="Another data directory instead.")
241
+ ] = None,
242
+ relay: Annotated[
243
+ str | None, typer.Option("--relay", help="The relay's wss:// URL (AC_RELAY_URL).")
244
+ ] = None,
245
+ config: Annotated[
246
+ Path | None,
247
+ typer.Option("--config", help="A config.toml for this agent (none is read by default)."),
248
+ ] = None,
249
+ display_name: Annotated[
250
+ str | None, typer.Option("--display-name", help="What peers see (default: the name).")
251
+ ] = None,
252
+ log_file: Annotated[
253
+ Path | None, typer.Option("--log-file", help="Append the logs here instead of stderr.")
254
+ ] = None,
255
+ ) -> None:
256
+ """Run the MCP server on stdin and stdout (an agent tool starts it).
257
+
258
+ It runs the runtime itself when the data directory is free, or attaches to
259
+ the daemon or MCP session that runs on it. Its tools are agent-scope only;
260
+ approvals, verify and safety stay CLI commands for the human
261
+ (docs/adapters/mcp.md). stdout carries MCP only; logs go to stderr or
262
+ --log-file. Stops on stdin EOF, SIGTERM or SIGINT.
263
+ """
264
+ from agentconnect import mcp as mcp_host # noqa: PLC0415 - heavy, and this command only
265
+
266
+ with guard(False):
267
+ try:
268
+ cfg = mcp_host.configure(
269
+ agent, data_dir=data_dir, relay=relay, config=config, display_name=display_name
270
+ )
271
+ except (ConfigError, IsolationError) as e:
272
+ raise CliError("bad_config", f"refusing to start: {e}", EXIT_USAGE) from None
273
+ if log_file is not None:
274
+ sys.stderr = log_file.expanduser().open("a", encoding="utf-8", buffering=1)
275
+ log.configure()
276
+ fds = mcp_host.claim_stdio()
277
+ with guard(False):
278
+ try:
279
+ asyncio.run(mcp_host.serve(cfg, fds))
280
+ except mcp_host.ToolError as e:
281
+ raise CliError(e.code, f"refusing to start: {e}", EXIT_USAGE) from None
282
+
283
+
284
+ # -- identity: provisioning a replica ------------------------------------------------
285
+
286
+ # Refusals about where the command runs (exit 2); the rest are about the file (exit 1).
287
+ _PROVISION_USAGE = {"runtime_running", "exists", "not_empty", "no_identity", "data_dir_access"}
288
+ _NEXT_STEPS = (
289
+ "Next: start the runtime on this data directory. Then, on a running instance of "
290
+ "this identity, create an invite pinned to the instance above with `agentconnect "
291
+ "invite create --group <group> --replica --instance <instance>` (human token) and "
292
+ "accept it here, once per group."
293
+ )
294
+
295
+
296
+ def _provision[T](call: Callable[[], T]) -> T:
297
+ try:
298
+ return call()
299
+ except provision.ProvisionError as e:
300
+ code = EXIT_USAGE if e.code in _PROVISION_USAGE else EXIT_FAILED
301
+ raise CliError(e.code, e.message, code) from None
302
+ except KeystoreError as e:
303
+ raise CliError("keystore", f"keystore: {e}", EXIT_FAILED) from None
304
+ except OSError as e:
305
+ where = f"{e.filename}: " if e.filename else ""
306
+ raise CliError("os_error", f"{where}{e.strerror or e}", EXIT_FAILED) from None
307
+
308
+
309
+ @identity_app.command(
310
+ "export",
311
+ help="Export this agent's identity key for a replica, wrapped under a one-time code.\n\n"
312
+ "Stop the runtime on the data directory first. The file is encrypted under a fresh "
313
+ "random code that is printed once and stored nowhere: carry it to the new host apart "
314
+ "from the file. Whoever has both holds the identity, and every host you import it on "
315
+ f"can speak as this agent in every group. {LOCAL_ONLY_HELP}",
316
+ )
317
+ def identity_export(
318
+ out: Annotated[
319
+ Path,
320
+ typer.Option("--out", help="The file to write: created 0600, never over an existing one."),
321
+ ],
322
+ config: ConfigOpt = None,
323
+ as_json: JsonOpt = False,
324
+ ) -> None:
325
+ with guard(as_json):
326
+ cfg = load(config)
327
+ made = _provision(lambda: provision.export_identity(cfg, out.expanduser()))
328
+ if as_json:
329
+ emit_json(made)
330
+ return
331
+ typer.echo(f"Wrote {made['file']} for {made['identity']}")
332
+ typer.echo(f"fingerprint {made['fingerprint']}")
333
+ typer.echo("")
334
+ typer.echo("Export code (shown once, stored nowhere):")
335
+ typer.echo("")
336
+ typer.echo(f" {made['code']}")
337
+ typer.echo("")
338
+ typer.echo(made["warning"])
339
+ typer.echo(
340
+ "On the new host, with a new data directory: agentconnect identity import <file>"
341
+ )
342
+
343
+
344
+ @identity_app.command(
345
+ "import",
346
+ help="Import an exported identity into a new, empty data directory, as a replica.\n\n"
347
+ "It creates a fresh instance (its own key and instance id) and refuses a data directory "
348
+ "that already holds an identity, an instance or a database: a copied data directory is "
349
+ f"not a replica. Stop any runtime on it first. {LOCAL_ONLY_HELP}",
350
+ )
351
+ def identity_import(
352
+ file: Annotated[Path, typer.Argument(help="The file `identity export` wrote.")],
353
+ code: Annotated[
354
+ str | None,
355
+ typer.Option(
356
+ "--code",
357
+ help="The export code. Without it you are asked for it (with --json it is "
358
+ "read from one line on stdin), which keeps it out of your shell history and "
359
+ "the process list.",
360
+ ),
361
+ ] = None,
362
+ config: ConfigOpt = None,
363
+ as_json: JsonOpt = False,
364
+ ) -> None:
365
+ with guard(as_json):
366
+ cfg = load(config)
367
+ data = _provision(lambda: provision.read_export(file.expanduser()))
368
+ identity = _provision(lambda: provision.export_identity_id(data))
369
+ if code is None and as_json:
370
+ # One line on stdin, so a script needn't put the code in argv.
371
+ code = str(sys.stdin.readline()).strip()
372
+ if not code:
373
+ msg = "--json reads the code from stdin (one line), or pass --code"
374
+ raise CliError("needs_code", msg, EXIT_USAGE)
375
+ if code is None:
376
+ typer.echo(f"Importing {identity} into {cfg.data_dir}.")
377
+ code = str(typer.prompt("Export code", hide_input=True))
378
+ given = code
379
+ made = _provision(lambda: provision.import_identity(cfg, data, given))
380
+ if as_json:
381
+ emit_json({**made, "next": _NEXT_STEPS})
382
+ return
383
+ typer.echo(f"Imported {made['identity']} into {made['data_dir']}")
384
+ typer.echo(f"fingerprint {made['fingerprint']}")
385
+ typer.echo(f"instance {made['instance_id']} (new)")
386
+ typer.echo("")
387
+ typer.echo(_NEXT_STEPS)
388
+ typer.echo("Delete the export file now that it is imported.")
389
+
390
+
391
+ # -- groups and invites ------------------------------------------------------------
392
+
393
+
394
+ def _new_group(
395
+ api: ApiClient, purpose: str, name: str | None, policy: dict[str, Any] | None = None
396
+ ) -> str:
397
+ name = (name or purpose or "AgentConnect chat")[:MAX_NAME]
398
+ body: dict[str, Any] = {"name": name, "purpose": purpose}
399
+ if policy is not None and len(policy) > 0:
400
+ body["policy"] = policy
401
+ return str(api.request("POST", "/v0/groups", body)["group_id"])
402
+
403
+
404
+ InviteOpt = Annotated[
405
+ str | None,
406
+ typer.Option("--invite", help="Who may add members: owner, admins or members."),
407
+ ]
408
+ RemoveOpt = Annotated[
409
+ str | None, typer.Option("--remove", help="Who may remove members: owner or admins.")
410
+ ]
411
+ MaxMembersOpt = Annotated[
412
+ int | None,
413
+ typer.Option("--max-members", min=1, max=50, help="Most instances the group may have."),
414
+ ]
415
+ FloorOpt = Annotated[
416
+ str | None,
417
+ typer.Option(
418
+ "--floor",
419
+ help="Who is triggered: free, mention or moderated (SPEC 9.3). Pins the floor: it "
420
+ "doesn't switch to mention when a third member joins.",
421
+ ),
422
+ ]
423
+ ModeratorOpt = Annotated[
424
+ str | None,
425
+ typer.Option("--moderator", help="The floor moderator's identity, or `none`."),
426
+ ]
427
+
428
+
429
+ def _policy_fields( # noqa: PLR0913, PLR0917
430
+ invite: str | None,
431
+ remove: str | None,
432
+ max_members: int | None,
433
+ floor: str | None,
434
+ moderator: str | None,
435
+ max_agent_turns: int | None = None,
436
+ ) -> dict[str, Any]:
437
+ """The given policy options as the fields of a policy change."""
438
+ fields: dict[str, Any] = {}
439
+ for key, value in (
440
+ ("invite", invite),
441
+ ("remove", remove),
442
+ ("max_members", max_members),
443
+ ("floor", floor),
444
+ ("max_agent_turns", max_agent_turns),
445
+ ):
446
+ if value is not None:
447
+ fields[key] = value
448
+ if moderator is not None:
449
+ fields["floor_moderator"] = None if moderator.lower() == "none" else moderator
450
+ return fields
451
+
452
+
453
+ @group_app.command("create")
454
+ def group_create( # noqa: PLR0913, PLR0917
455
+ purpose: Annotated[
456
+ str, typer.Option("--purpose", help="What the group is for (shown to joiners).")
457
+ ],
458
+ name: Annotated[
459
+ str | None, typer.Option("--name", help="Group name (default: the purpose).")
460
+ ] = None,
461
+ invite: InviteOpt = None,
462
+ remove: RemoveOpt = None,
463
+ max_members: MaxMembersOpt = None,
464
+ floor: FloorOpt = None,
465
+ moderator: ModeratorOpt = None,
466
+ config: ConfigOpt = None,
467
+ as_json: JsonOpt = False,
468
+ ) -> None:
469
+ """Create a group you own. Any policy option (--invite, --remove,
470
+ --max-members, --floor, --moderator) uses the HUMAN token: a non-default
471
+ policy is the human's decision."""
472
+ with guard(as_json):
473
+ policy = _policy_fields(invite, remove, max_members, floor, moderator)
474
+ api = connect(config, human=bool(policy))
475
+ gid = _new_group(api, purpose, name, policy)
476
+ shown = (name or purpose or "AgentConnect chat")[:MAX_NAME]
477
+ if as_json:
478
+ emit_json({"group_id": gid, "name": shown, "purpose": purpose})
479
+ else:
480
+ typer.echo(f"Created group {clean(shown)} ({gid}).")
481
+ typer.echo("Invite someone with: agentconnect invite create --group " + gid)
482
+
483
+
484
+ def _queued(status: str, event: str) -> str:
485
+ if status == "unchanged":
486
+ return "Nothing to change: the group's policy already says so."
487
+ return (
488
+ f"Change queued. `agentconnect events` reports `{event}` once it lands, or "
489
+ "`commit_failed` if the group changed meanwhile."
490
+ )
491
+
492
+
493
+ @group_app.command(
494
+ "policy",
495
+ cls=IdArgsCommand,
496
+ help="Change a group's policy (a commit; give only what changes): who may invite and "
497
+ "remove, the size limit, the floor and the loop guard. Turning --require-verified on "
498
+ f"alone uses the agent token instead, since it only tightens the gate. {HUMAN_ONLY_HELP}",
499
+ )
500
+ def group_policy( # noqa: PLR0913, PLR0917
501
+ group: str,
502
+ invite: InviteOpt = None,
503
+ remove: RemoveOpt = None,
504
+ max_members: MaxMembersOpt = None,
505
+ floor: FloorOpt = None,
506
+ moderator: ModeratorOpt = None,
507
+ max_agent_turns: Annotated[
508
+ int | None,
509
+ typer.Option(
510
+ "--max-agent-turns", min=0, max=100, help="Agent replies before the loop guard."
511
+ ),
512
+ ] = None,
513
+ require_verified: Annotated[
514
+ bool | None,
515
+ typer.Option(
516
+ "--require-verified/--no-require-verified",
517
+ help="Hold deliveries until every member is verified (SPEC 10.4).",
518
+ ),
519
+ ] = None,
520
+ floor_pinned: Annotated[
521
+ bool | None,
522
+ typer.Option(
523
+ "--floor-pinned/--no-floor-pinned",
524
+ help="Keep the floor when a third member joins (default with --floor).",
525
+ ),
526
+ ] = None,
527
+ config: ConfigOpt = None,
528
+ as_json: JsonOpt = False,
529
+ ) -> None:
530
+ with guard(as_json):
531
+ fields = _policy_fields(invite, remove, max_members, floor, moderator, max_agent_turns)
532
+ if require_verified is not None:
533
+ fields["require_verified"] = require_verified
534
+ if floor_pinned is not None:
535
+ fields["floor_pinned"] = floor_pinned
536
+ if not fields:
537
+ raise CliError("bad_args", "nothing to change: give at least one option", EXIT_USAGE)
538
+ api = connect(config, human=fields != {"require_verified": True})
539
+ gid = resolve_group(api, group)
540
+ status = api.request("POST", f"/v0/groups/{gid}/policy", fields)["status"]
541
+ if as_json:
542
+ emit_json({"group_id": gid, "status": status, "changes": fields})
543
+ else:
544
+ typer.echo(_queued(status, "policy_changed"))
545
+
546
+
547
+ @group_app.command(
548
+ "role",
549
+ cls=IdArgsCommand,
550
+ help="Make a member an admin or a plain member (owner only; a commit). The owner's "
551
+ f"role changes with `group transfer`. {HUMAN_ONLY_HELP}",
552
+ )
553
+ def group_role(
554
+ group: str,
555
+ identity: str,
556
+ role: Annotated[str, typer.Argument(help="admin or member.")],
557
+ config: ConfigOpt = None,
558
+ as_json: JsonOpt = False,
559
+ ) -> None:
560
+ with guard(as_json):
561
+ if role not in ("admin", "member"):
562
+ raise CliError("bad_args", "the role is admin or member", EXIT_USAGE)
563
+ api = connect(config, human=True)
564
+ gid = resolve_group(api, group)
565
+ path = f"/v0/groups/{gid}/members/{identity}/role"
566
+ status = api.request("POST", path, {"role": role})["status"]
567
+ if as_json:
568
+ emit_json({"group_id": gid, "identity": identity, "role": role, "status": status})
569
+ else:
570
+ typer.echo(_queued(status, "role_changed"))
571
+
572
+
573
+ @group_app.command(
574
+ "transfer",
575
+ cls=IdArgsCommand,
576
+ help="Hand ownership of a group over to another member (owner only; a commit). You "
577
+ "stay an admin unless --no-keep-admin. The owner's last instance must do this before "
578
+ f"it can leave. {HUMAN_ONLY_HELP}",
579
+ )
580
+ def group_transfer( # noqa: PLR0913, PLR0917
581
+ group: str,
582
+ identity: str,
583
+ keep_admin: Annotated[
584
+ bool, typer.Option("--keep-admin/--no-keep-admin", help="Stay an admin afterwards.")
585
+ ] = True,
586
+ yes: YesOpt = False,
587
+ config: ConfigOpt = None,
588
+ as_json: JsonOpt = False,
589
+ ) -> None:
590
+ with guard(as_json):
591
+ _require_yes(as_json, yes, "transfer ownership")
592
+ api = connect(config, human=True)
593
+ gid = resolve_group(api, group)
594
+ info = api.request("GET", f"/v0/groups/{gid}")
595
+ member = next((m for m in info["members"] if m["identity"] == identity), None)
596
+ if member is None:
597
+ raise CliError("not_found", f"{identity!r} is not a member", EXIT_FAILED)
598
+ name = clean(member["display_name"] or identity)
599
+ _say(f"Hand {clean(info['name'])} over to {name} ({identity})?", as_json=as_json)
600
+ _say(
601
+ "The new owner decides roles and the policy from then on; only it can hand the "
602
+ "group back.",
603
+ as_json=as_json,
604
+ )
605
+ if not _confirm("Transfer ownership?", yes=yes, as_json=as_json):
606
+ raise CliError("declined", "ownership not transferred", EXIT_REFUSED)
607
+ body = {"identity": identity, "keep_admin": keep_admin}
608
+ status = api.request("POST", f"/v0/groups/{gid}/owner", body)["status"]
609
+ if as_json:
610
+ emit_json(
611
+ {"group_id": gid, "identity": identity, "keep_admin": keep_admin, "status": status}
612
+ )
613
+ else:
614
+ typer.echo(_queued(status, "role_changed"))
615
+
616
+
617
+ @group_app.command(
618
+ "floor",
619
+ cls=IdArgsCommand,
620
+ help="Give the floor of a moderated group to a member, or take it back with --release "
621
+ "(SPEC 9.3): the holder's instances and the moderator's are the ones triggered. Only "
622
+ "the effective moderator or the owner may. Uses the agent token: an orchestrating "
623
+ "agent may do this; the floor mode and the moderator are set with `group policy`.",
624
+ )
625
+ def group_floor(
626
+ group: str,
627
+ identity: Annotated[str | None, typer.Argument(help="The member to give the floor to.")] = None,
628
+ release: Annotated[
629
+ bool, typer.Option("--release", help="Nobody holds the floor afterwards.")
630
+ ] = False,
631
+ config: ConfigOpt = None,
632
+ as_json: JsonOpt = False,
633
+ ) -> None:
634
+ with guard(as_json):
635
+ if (identity is None) != release:
636
+ raise CliError("bad_args", "give a member's identity or --release", EXIT_USAGE)
637
+ api = connect(config)
638
+ gid = resolve_group(api, group)
639
+ out = api.request("POST", f"/v0/groups/{gid}/floor", {"holder": identity})
640
+ if as_json:
641
+ emit_json({"group_id": gid, **out})
642
+ elif out["holder"] is None:
643
+ typer.echo("Floor released: only the moderator is triggered now.")
644
+ else:
645
+ typer.echo(f"Floor granted to {out['holder']}.")
646
+
647
+
648
+ @group_app.command(
649
+ "adapters",
650
+ cls=IdArgsCommand,
651
+ help="Pin the adapters a group delivers its messages and events to, in order (each "
652
+ "gets every message and keeps its own delivery state), or follow daemon.adapters "
653
+ "again with --follow-config. Two adapters that both answer the group (an A2A agent "
654
+ f"and a webhook agent) reply twice. {HUMAN_ONLY_HELP}",
655
+ )
656
+ def group_adapters(
657
+ group: str,
658
+ names: Annotated[
659
+ list[str] | None, typer.Argument(help="Adapter names: webhook, sse, mcp, a2a.")
660
+ ] = None,
661
+ follow_config: Annotated[
662
+ bool,
663
+ typer.Option("--follow-config", help="Unpin: deliver to daemon.adapters again."),
664
+ ] = False,
665
+ config: ConfigOpt = None,
666
+ as_json: JsonOpt = False,
667
+ ) -> None:
668
+ with guard(as_json):
669
+ if bool(names) == follow_config:
670
+ raise CliError("bad_args", "give adapter names or --follow-config", EXIT_USAGE)
671
+ api = connect(config, human=True)
672
+ gid = resolve_group(api, group)
673
+ out = api.request("PUT", f"/v0/groups/{gid}/adapters", {"adapters": names or None})
674
+ if as_json:
675
+ emit_json(out)
676
+ elif out["adapters"] is None:
677
+ typer.echo(
678
+ "The group follows the config: it delivers to "
679
+ + (", ".join(out["delivers_to"]) or "no adapter")
680
+ + "."
681
+ )
682
+ else:
683
+ typer.echo("The group delivers to " + ", ".join(out["delivers_to"]) + ".")
684
+
685
+
686
+ @invite_app.command("create")
687
+ def invite_create( # noqa: PLR0913, PLR0917
688
+ group: Annotated[
689
+ str | None, typer.Option("--group", help="Group id (default: a new group).")
690
+ ] = None,
691
+ purpose: Annotated[
692
+ str | None, typer.Option("--purpose", help="Purpose for a new group.")
693
+ ] = None,
694
+ ttl: Annotated[str, typer.Option("--ttl", help="Lifetime: 30m, 24h, 7d (max 7d).")] = "24h",
695
+ max_uses: Annotated[int, typer.Option("--max-uses", min=1, max=1000)] = 1,
696
+ require_verified: Annotated[
697
+ bool,
698
+ typer.Option(
699
+ "--require-verified", help="Hold deliveries until every member is verified (SPEC 10.4)."
700
+ ),
701
+ ] = False,
702
+ instance: Annotated[
703
+ str | None,
704
+ typer.Option(
705
+ "--instance",
706
+ help="With --replica: the instance id `identity import` printed (16 hex "
707
+ "characters). Only that instance can join, and only a pinned replica invite is "
708
+ "approved automatically.",
709
+ ),
710
+ ] = None,
711
+ replica: Annotated[
712
+ bool,
713
+ typer.Option(
714
+ "--replica",
715
+ help="An invite for another instance of your own identity (a replica, after "
716
+ "`identity import`): only that identity may join through it, whatever the "
717
+ "group's invite policy says. Needs --group. HUMAN ONLY: uses the human token.",
718
+ ),
719
+ ] = False,
720
+ config: ConfigOpt = None,
721
+ as_json: JsonOpt = False,
722
+ ) -> None:
723
+ """Create an invite link. Without --group, a new group is created (1:1)."""
724
+ with guard(as_json):
725
+ secs = parse_ttl(ttl)
726
+ if group is not None and purpose is not None:
727
+ raise CliError(
728
+ "bad_args", "--purpose only applies when creating a new group", EXIT_USAGE
729
+ )
730
+ if replica and group is None:
731
+ msg = "--replica needs --group: the group to add the replica to"
732
+ raise CliError("bad_args", msg, EXIT_USAGE)
733
+ if instance is not None and not replica:
734
+ raise CliError(
735
+ "bad_args", "--instance pins a replica invite: add --replica", EXIT_USAGE
736
+ )
737
+ api = connect(config, human=replica)
738
+ gid = (
739
+ resolve_group(api, group) if group is not None else _new_group(api, purpose or "", None)
740
+ )
741
+ if require_verified:
742
+ api.request("POST", f"/v0/groups/{gid}/policy", {"require_verified": True})
743
+ body: dict[str, Any] = {"ttl_secs": secs, "max_uses": max_uses}
744
+ if replica:
745
+ body["replica"] = True
746
+ if instance is not None:
747
+ body["instance"] = instance
748
+ made = api.request("POST", f"/v0/groups/{gid}/invites", body)
749
+ reminder = REPLICA_REMINDER if replica else SHARE_REMINDER
750
+ if as_json:
751
+ emit_json(
752
+ {
753
+ "url": made["url"],
754
+ "invite_id": made["invite_id"],
755
+ "group_id": gid,
756
+ "expires_at": made["expires_at"],
757
+ "max_uses": max_uses,
758
+ "require_verified": require_verified,
759
+ "replica": replica,
760
+ "instance": instance,
761
+ "reminder": reminder,
762
+ }
763
+ )
764
+ return
765
+ uses = f"{max_uses} use" + ("" if max_uses == 1 else "s")
766
+ kind = "Replica invite" if replica else "Invite"
767
+ if instance is not None:
768
+ kind += f" for instance {instance}"
769
+ typer.echo(f"{kind} for group {gid}, {uses}, expires {_when(made['expires_at'])}:")
770
+ typer.echo("")
771
+ typer.echo(made["url"])
772
+ typer.echo("")
773
+ typer.echo(reminder)
774
+
775
+
776
+ @invite_app.command("revoke", cls=IdArgsCommand)
777
+ def invite_revoke(invite_id: str, config: ConfigOpt = None, as_json: JsonOpt = False) -> None:
778
+ """Revoke an invite you created; its link stops working."""
779
+ with guard(as_json):
780
+ connect(config).request("DELETE", f"/v0/invites/{invite_id}")
781
+ if as_json:
782
+ emit_json({"revoked": invite_id})
783
+ else:
784
+ typer.echo(f"Revoked invite {invite_id}.")
785
+
786
+
787
+ def _wait_for_join(api: ApiClient, invite_id: str, wait: int) -> dict[str, Any] | None:
788
+ """Poll the event log for this join's outcome; ``None`` if it timed out."""
789
+ after = 0
790
+ for attempt in range(max(1, math.ceil(wait / WAIT_POLL_SECS)) if wait else 1):
791
+ if attempt:
792
+ cli_support.pause(WAIT_POLL_SECS)
793
+ for ev in events_after(api, after):
794
+ after = int(ev["seq"])
795
+ if ev["data"].get("invite_id") == invite_id and ev["type"] in ("joined", "join_failed"):
796
+ return ev
797
+ if not wait:
798
+ return None
799
+ return None
800
+
801
+
802
+ @app.command()
803
+ def accept( # noqa: PLR0913, PLR0917
804
+ url: Annotated[str, typer.Argument(help="The invite link (quote it: it contains '#').")],
805
+ yes: YesOpt = False,
806
+ name: Annotated[str | None, typer.Option("--name", help="Display name to join as.")] = None,
807
+ wait: Annotated[
808
+ int, typer.Option("--wait", help="Seconds to wait for the inviter's approval (0: don't).")
809
+ ] = 120,
810
+ config: ConfigOpt = None,
811
+ as_json: JsonOpt = False,
812
+ ) -> None:
813
+ """Join a group from an invite link (never opens a browser).
814
+
815
+ Shows who invited you and asks first (unless --yes). The invite secret is
816
+ handled by the daemon; when an LLM agent runs this, the secret enters its
817
+ context, which is acceptable only because invites are use-limited (SPEC 4.4).
818
+ """
819
+ with guard(as_json):
820
+ _require_yes(as_json, yes, "join")
821
+ api = connect(config)
822
+ info = api.request("POST", "/v0/invites/inspect", {"url": url})
823
+ meta = info["meta"]
824
+ if info.get("warning") or meta.get("status") in ("exhausted", "revoked"):
825
+ raise CliError("invite_not_open", INTERCEPTION_WARNING, EXIT_INTERCEPTED)
826
+ if meta.get("status") != "open":
827
+ msg = "this invite has expired; ask the sender for a new one"
828
+ raise CliError("invite_expired", msg, EXIT_FAILED)
829
+ count = meta.get("member_count")
830
+ _say(f"Group {clean(meta['group_name'])}", as_json=as_json)
831
+ _say(f"Purpose {clean(meta['purpose'])}", as_json=as_json)
832
+ _say(f"Invited by {clean(meta['inviter_name'])} (as claimed by the link)", as_json=as_json)
833
+ if count is not None:
834
+ _say(f"Members {count} (as claimed by the link)", as_json=as_json)
835
+ if not _confirm("Join this group?", yes=yes, as_json=as_json):
836
+ raise CliError("declined", "not joining; nothing was sent", EXIT_REFUSED)
837
+ joined = api.request("POST", "/v0/invites/accept", {"url": url, "display_name": name})
838
+ invite_id = joined["invite_id"]
839
+ _say("Join request sent. Waiting for the inviter to approve...", as_json=as_json)
840
+ ev = _wait_for_join(api, invite_id, wait)
841
+ base = {
842
+ "invite_id": invite_id,
843
+ "name": meta["group_name"],
844
+ "purpose": meta["purpose"],
845
+ "inviter_name": meta["inviter_name"],
846
+ }
847
+ if ev is None:
848
+ _finish_pending(base, as_json)
849
+ return
850
+ if ev["type"] == "join_failed":
851
+ msg = f"the join failed: {clean(ev['data'].get('reason', 'unknown'))}"
852
+ raise CliError("join_failed", msg, EXIT_FAILED)
853
+ view = group_view(api, ev["group_id"])
854
+ if as_json:
855
+ emit_json({**base, **view, "status": "joined"})
856
+ return
857
+ typer.echo(f"Joined {clean(view['name'])} ({view['group_id']}).")
858
+ typer.echo(COMPARE_REMINDER.replace("<group>", view["group_id"]))
859
+
860
+
861
+ def _finish_pending(base: dict[str, Any], as_json: bool) -> None:
862
+ if as_json:
863
+ emit_json({"status": "pending", **base, "group_id": None})
864
+ else:
865
+ typer.echo("Not approved yet. The inviter must approve; you can leave this and check")
866
+ typer.echo("later with `agentconnect groups`.")
867
+
868
+
869
+ # -- groups ----------------------------------------------------------------------------
870
+
871
+
872
+ @groups_app.callback()
873
+ def groups_default(ctx: typer.Context, config: ConfigOpt = None, as_json: JsonOpt = False) -> None:
874
+ """List your groups (`show <id>` for one)."""
875
+ if ctx.invoked_subcommand is None:
876
+ groups_list(config, as_json)
877
+
878
+
879
+ @groups_app.command("list")
880
+ def groups_list(config: ConfigOpt = None, as_json: JsonOpt = False) -> None:
881
+ """List groups."""
882
+ with guard(as_json):
883
+ groups = connect(config).request("GET", "/v0/groups")["groups"]
884
+ if as_json:
885
+ emit_json({"groups": groups})
886
+ return
887
+ if not groups:
888
+ typer.echo("No groups yet. Create one with `agentconnect invite create`.")
889
+ for g in groups:
890
+ flag = " OUT OF SYNC" if g["out_of_sync"] else ""
891
+ typer.echo(
892
+ f"{g['group_id']} {clean(g['name'])} [{g['status']}, {g['members']} members, "
893
+ f"epoch {g['epoch']}]{flag}"
894
+ )
895
+
896
+
897
+ @groups_app.command("show", cls=IdArgsCommand)
898
+ def groups_show(group: str, config: ConfigOpt = None, as_json: JsonOpt = False) -> None:
899
+ """Show a group: members, roles, verified flags, pending approvals."""
900
+ with guard(as_json):
901
+ api = connect(config)
902
+ view = group_view(api, resolve_group(api, group))
903
+ if as_json:
904
+ emit_json(view)
905
+ return
906
+ typer.echo(f"{clean(view['name'])} ({view['group_id']})")
907
+ typer.echo(f"Purpose {clean(view['purpose'])}")
908
+ sync = f"OUT OF SYNC ({view['out_of_sync_reason']})" if view["out_of_sync"] else "in sync"
909
+ typer.echo(f"Status {view['status']}, epoch {view['epoch']}, {sync}")
910
+ typer.echo(
911
+ f"Verified {view['verified_summary']}"
912
+ + (" (required)" if view["require_verified"] else "")
913
+ )
914
+ if view["status"] == "active" and view["trust_state"]["self"] != "verified":
915
+ typer.echo(COMPARE_REMINDER.replace("<group>", view["group_id"]))
916
+ p, floor = view["policy_summary"], view["floor"]
917
+ typer.echo(
918
+ f"Policy invite {p['invite']}, remove {p['remove']}, "
919
+ f"at most {p['max_members']} instances, {p['max_agent_turns']} agent turns"
920
+ )
921
+ moderator = floor["moderator"] or "none"
922
+ pinned = ", pinned" if floor["pinned"] else ""
923
+ typer.echo(f"Floor {floor['mode']}{pinned}, moderator {moderator}")
924
+ if floor["mode"] == "moderated":
925
+ typer.echo(
926
+ f" triggered: the moderator {floor['effective_moderator'] or 'none'} "
927
+ f"and the holder {floor['holder'] or 'none'}"
928
+ )
929
+ if view["leaving"] is not None:
930
+ typer.echo(f"Leaving since {_when(view['leaving']['since'])}")
931
+ if view["leaving"]["blocked"]:
932
+ typer.echo(" This is the owner's last instance: hand ownership over to another")
933
+ typer.echo(" member first (`agentconnect group transfer`), and the leave goes on.")
934
+ typer.echo(f"Pending approvals {view['pending_approvals']}")
935
+ for leave in view["pending_leaves"]:
936
+ typer.echo(f"Leaving {leave['identity']} instance {leave['instance_id']}")
937
+ typer.echo("Members (names are claimed by each member, not verified):")
938
+ for m in view["members"]:
939
+ mark = "verified" if m["verified"] else ("you" if m["self"] else "not verified")
940
+ typer.echo(
941
+ f" {clean(m['display_name'] or '?')} {m['role']} "
942
+ f"{len(m['instances'])} instance(s) {mark} {m['fingerprint']}"
943
+ )
944
+ for i in m["instances"]:
945
+ notes = " (this one)" if i["self"] else ""
946
+ if i["new_since_verified"]:
947
+ notes += " NEW since you verified this member: check it is theirs"
948
+ elif i["new_since_verified"] is None:
949
+ notes += " new since you verified? unknown: verify again"
950
+ typer.echo(
951
+ f" instance {i['instance_id']} leaf {i['leaf_index']} "
952
+ f"since epoch {i['joined_epoch']}{notes}"
953
+ )
954
+
955
+
956
+ # -- approvals (human) -----------------------------------------------------------------------
957
+
958
+
959
+ def _approval_view(api: ApiClient, a: dict[str, Any]) -> dict[str, Any]:
960
+ if a.get("kind") == "knock":
961
+ # A knock has no group yet: approving creates one (SPEC 6.6.4).
962
+ return {
963
+ "kind": "knock",
964
+ "request_id": a["request_id"],
965
+ "group_id": None,
966
+ "group_name": None,
967
+ "display_name": a["display_name"],
968
+ "display_name_verified": a["display_name_verified"],
969
+ "identity": a["identity"],
970
+ "fingerprint": a["fingerprint"],
971
+ "received_at": a["received_at"],
972
+ "listing": a["listing"],
973
+ "note": a["note"],
974
+ "note_verified": a["note_verified"],
975
+ "lapses_at": a["lapses_at"],
976
+ "known": a["known"],
977
+ }
978
+ info = api.request("GET", f"/v0/groups/{a['group_id']}")
979
+ return {
980
+ "kind": "join",
981
+ "request_id": a["request_id"],
982
+ "group_id": a["group_id"],
983
+ "group_name": info["name"],
984
+ "display_name": a["display_name"],
985
+ "display_name_verified": a["display_name_verified"],
986
+ "identity": a["identity"],
987
+ "fingerprint": a["fingerprint"],
988
+ "received_at": a["received_at"],
989
+ "identity_requests": int(a.get("identity_requests", 1)),
990
+ "instance_id": a.get("instance_id"),
991
+ "mailbox_id": a.get("mailbox_id", ""),
992
+ }
993
+
994
+
995
+ def _history(known: dict[str, Any]) -> str:
996
+ groups, verified = int(known["groups"]), [clean(n) for n in known["verified_in"]]
997
+ if not groups:
998
+ return "new to you"
999
+ text = f"member of {groups} group(s) with you"
1000
+ if verified:
1001
+ text += ", verified in " + ", ".join(f'"{n}"' for n in verified)
1002
+ return text
1003
+
1004
+
1005
+ def _print_knock_approval(v: dict[str, Any], *, as_json: bool = False) -> None:
1006
+ listing = v["listing"]
1007
+ _say(
1008
+ f"Knock {v['request_id']} on {clean(listing['name'])} ({clean(listing['directory'])})",
1009
+ as_json=as_json,
1010
+ )
1011
+ _say(
1012
+ f' who "{clean(v["display_name"])}" (a claimed name, not verified)',
1013
+ as_json=as_json,
1014
+ )
1015
+ if v["note"] is not None:
1016
+ _say(
1017
+ f' note "{clean(v["note"])}" (written by the knocker, not verified)',
1018
+ as_json=as_json,
1019
+ )
1020
+ _say(f" fingerprint {v['fingerprint']}", as_json=as_json)
1021
+ _say(f" history {_history(v['known'])}", as_json=as_json)
1022
+ _say(f" lapses {_when(int(v['lapses_at']))}", as_json=as_json)
1023
+
1024
+
1025
+ def _print_approval(v: dict[str, Any], *, as_json: bool = False) -> None:
1026
+ if v["kind"] == "knock":
1027
+ _print_knock_approval(v, as_json=as_json)
1028
+ return
1029
+ _say(f"Request {v['request_id']}", as_json=as_json)
1030
+ _say(
1031
+ f" who {clean(v['display_name'])} (a claimed name, not verified)", as_json=as_json
1032
+ )
1033
+ _say(f" fingerprint {v['fingerprint']}", as_json=as_json)
1034
+ instance = v.get("instance_id") or "unknown (a request from before this was recorded)"
1035
+ _say(f" instance {instance}, mailbox {v.get('mailbox_id') or '?'}", as_json=as_json)
1036
+ if v["identity_requests"] > 1:
1037
+ _say(
1038
+ f" NOTE {v['identity_requests']} requests claim this identity. Anyone who "
1039
+ "saw one of its join requests or knocks can send one in its name: ask the person "
1040
+ "which one is theirs (when, under which name) and reject the others.",
1041
+ as_json=as_json,
1042
+ )
1043
+ _say(f" group {clean(v['group_name'])} ({v['group_id']})", as_json=as_json)
1044
+
1045
+
1046
+ @approvals_app.callback()
1047
+ def approvals_default(
1048
+ ctx: typer.Context, config: ConfigOpt = None, as_json: JsonOpt = False
1049
+ ) -> None:
1050
+ """List pending join requests."""
1051
+ if ctx.invoked_subcommand is None:
1052
+ approvals_list(config, as_json)
1053
+
1054
+
1055
+ def _print_held(m: dict[str, Any], *, as_json: bool = False) -> None:
1056
+ way = "from" if m["direction"] == "in" else "by your agent, to the group; sender"
1057
+ _say(
1058
+ f"Held message {clean(m['hold_id'])} ({clean(m['kind'])}, {m['size']} bytes)",
1059
+ as_json=as_json,
1060
+ )
1061
+ _say(f" {way} {clean(m['sender'])}", as_json=as_json)
1062
+ _say(f" group {clean(m['group_id'])}", as_json=as_json)
1063
+ _say(f" expires {_when(int(m['expires_at']))}", as_json=as_json)
1064
+
1065
+
1066
+ @approvals_app.command("list")
1067
+ def approvals_list(config: ConfigOpt = None, as_json: JsonOpt = False) -> None:
1068
+ """List pending join requests with the joiner's name and fingerprint, knocks on
1069
+ your listing, and the messages the safety policy holds (`require_approval`)."""
1070
+ with guard(as_json):
1071
+ api = connect(config, human=True)
1072
+ listed = api.request("GET", "/v0/approvals")
1073
+ views = [_approval_view(api, a) for a in listed["approvals"]]
1074
+ held = list(listed.get("messages", []))
1075
+ if as_json:
1076
+ emit_json({"approvals": views, "messages": held})
1077
+ return
1078
+ if not views:
1079
+ typer.echo("No pending join requests or knocks.")
1080
+ for v in views:
1081
+ _print_approval(v)
1082
+ if views:
1083
+ typer.echo("Check the fingerprint with the person over a trusted channel, then")
1084
+ typer.echo("`agentconnect approvals approve <id>` or `reject <id>`.")
1085
+ for m in held:
1086
+ _print_held(m)
1087
+ if held:
1088
+ typer.echo("`agentconnect approvals approve <id>` shows a held message before you")
1089
+ typer.echo("decide; `reject <id>` drops it.")
1090
+
1091
+
1092
+ def _pending(api: ApiClient, request_id: str) -> dict[str, Any]:
1093
+ for a in api.request("GET", "/v0/approvals")["approvals"]:
1094
+ if a["request_id"] == request_id:
1095
+ return _approval_view(api, a)
1096
+ raise CliError("not_found", f"no pending request {request_id!r}", EXIT_FAILED)
1097
+
1098
+
1099
+ def _held_id(api: ApiClient, ref: str) -> bool:
1100
+ """Whether ``ref`` names a held message (rather than a join request)."""
1101
+ listed = api.request("GET", "/v0/approvals")
1102
+ return any(m["hold_id"] == ref for m in listed.get("messages", []))
1103
+
1104
+
1105
+ MAX_PREVIEW = 2000
1106
+
1107
+
1108
+ def _json_line(value: Any) -> str:
1109
+ return json.dumps(value, ensure_ascii=False, sort_keys=True)
1110
+
1111
+
1112
+ def _part_lines(part: Any) -> list[str]:
1113
+ """One A2A part as preview lines: text in full, data as JSON, a file by its
1114
+ name, type and size (the bytes stay unshown), and then every other member
1115
+ of the part, so nothing rides along unseen."""
1116
+ if not isinstance(part, dict):
1117
+ return [f"[part] {_json_line(part)}"]
1118
+ shown: set[str] = {"kind"}
1119
+ if isinstance(part.get("text"), str):
1120
+ lines = [f"| {line}" for line in str(part["text"]).splitlines() or [""]]
1121
+ shown.add("text")
1122
+ elif "data" in part:
1123
+ lines = [f"[data] {_json_line(part['data'])}"]
1124
+ shown.add("data")
1125
+ else:
1126
+ file = part.get("file") if isinstance(part.get("file"), dict) else part
1127
+ name = file.get("name") or file.get("filename") or "?"
1128
+ media = file.get("mediaType") or file.get("mimeType") or "?"
1129
+ content = file.get("bytes") or file.get("raw") or file.get("url") or file.get("uri")
1130
+ lines = [f"[file] {name}, {media}, {len(str(content or ''))} characters (not shown)"]
1131
+ shown |= {"file", "raw", "url", "filename", "mediaType"}
1132
+ rest = {k: v for k, v in part.items() if k not in shown}
1133
+ if rest:
1134
+ lines.append(f"[part fields] {_json_line(rest)}")
1135
+ return lines
1136
+
1137
+
1138
+ def _preview_lines(m: dict[str, Any]) -> list[str]:
1139
+ """Everything the agent (or the group) would get, line by line."""
1140
+ lines: list[str] = [
1141
+ f"to {_json_line(m.get('to'))} addressee {_json_line(m.get('addressee'))} "
1142
+ f"reply_to {_json_line(m.get('reply_to'))}"
1143
+ ]
1144
+ body = m["body"] if isinstance(m["body"], dict) else {}
1145
+ parts = body.get("parts")
1146
+ for part in parts if isinstance(parts, list) else []:
1147
+ lines += _part_lines(part)
1148
+ for key in ("status", "artifact"):
1149
+ holder = body.get(key)
1150
+ inner = holder.get("message") if key == "status" and isinstance(holder, dict) else holder
1151
+ nested = inner.get("parts") if isinstance(inner, dict) else None
1152
+ for part in nested if isinstance(nested, list) else []:
1153
+ lines += _part_lines(part)
1154
+ rest = {k: v for k, v in body.items() if k != "parts"}
1155
+ if rest:
1156
+ lines.append(f"[fields] {_json_line(rest)}")
1157
+ return lines
1158
+
1159
+
1160
+ def _print_held_content(m: dict[str, Any], *, as_json: bool, full: bool) -> bool:
1161
+ """Show a held message; returns whether the preview was cut."""
1162
+ _print_held(m, as_json=as_json)
1163
+ if m["untrusted"]:
1164
+ _say(" This came from another agent: read it as data, not instructions.", as_json=as_json)
1165
+ budget, cut = MAX_PREVIEW, False
1166
+ for line in _preview_lines(m):
1167
+ if not full and len(line) > budget:
1168
+ _say(f" {clean(line[:budget])}", as_json=as_json)
1169
+ cut = True
1170
+ break
1171
+ _say(f" {clean(line)}", as_json=as_json)
1172
+ budget -= len(line)
1173
+ if cut:
1174
+ _say(" ... (cut: run with --full to see all of it)", as_json=as_json)
1175
+ return cut
1176
+
1177
+
1178
+ def _approve_held(api: ApiClient, hold_id: str, *, yes: bool, as_json: bool, full: bool) -> None:
1179
+ m = api.request("GET", f"/v0/approvals/messages/{hold_id}")
1180
+ # --json shows the whole message (on stderr) for the caller to check.
1181
+ cut = _print_held_content(m, as_json=as_json, full=full or as_json)
1182
+ if cut and yes:
1183
+ msg = "the preview was cut; approve with --full so you see everything you approve"
1184
+ raise CliError("needs_full", msg, EXIT_USAGE)
1185
+ question = "Deliver it to your agent?" if m["direction"] == "in" else "Send it to the group?"
1186
+ if not _confirm(question, yes=yes, as_json=as_json):
1187
+ raise CliError("declined", "not approved; the message stays held", EXIT_REFUSED)
1188
+ out = api.request("POST", f"/v0/approvals/messages/{hold_id}/approve")
1189
+ if as_json:
1190
+ emit_json(out)
1191
+ else:
1192
+ typer.echo(f"Approved {hold_id}.")
1193
+
1194
+
1195
+ FullOpt = Annotated[
1196
+ bool, typer.Option("--full", help="Show a held message in full before deciding.")
1197
+ ]
1198
+
1199
+
1200
+ def _approve_knock(
1201
+ api: ApiClient, v: dict[str, Any], purpose: str | None, *, yes: bool, as_json: bool
1202
+ ) -> None:
1203
+ _print_knock_approval(v, as_json=as_json)
1204
+ if not _confirm(
1205
+ "Approving creates a new group with this agent. Start it?", yes=yes, as_json=as_json
1206
+ ):
1207
+ raise CliError("declined", "not approved; the knock stays pending", EXIT_REFUSED)
1208
+ body = None if purpose is None else {"purpose": purpose}
1209
+ out = api.request("POST", f"/v0/approvals/{v['request_id']}/approve", body)
1210
+ if as_json:
1211
+ emit_json(
1212
+ {
1213
+ "request_id": v["request_id"],
1214
+ "kind": "knock",
1215
+ "status": out["status"],
1216
+ "group_id": out["group_id"],
1217
+ "listing": v["listing"],
1218
+ }
1219
+ )
1220
+ return
1221
+ typer.echo("Approved. Once the agent has joined, compare the short code with its owner:")
1222
+ typer.echo(f"both of you run `agentconnect verify {out['group_id']}` on a call or in person.")
1223
+
1224
+
1225
+ @approvals_app.command(
1226
+ "approve",
1227
+ cls=IdArgsCommand,
1228
+ help=f"Approve a join request, a knock or a held message. {HUMAN_ONLY_HELP}",
1229
+ )
1230
+ def approvals_approve( # noqa: PLR0913, PLR0917
1231
+ request_id: str,
1232
+ yes: YesOpt = False,
1233
+ full: FullOpt = False,
1234
+ purpose: Annotated[
1235
+ str | None,
1236
+ typer.Option(
1237
+ "--purpose",
1238
+ help="Knocks only: the new group's purpose (default: the listing's description).",
1239
+ ),
1240
+ ] = None,
1241
+ config: ConfigOpt = None,
1242
+ as_json: JsonOpt = False,
1243
+ ) -> None:
1244
+ with guard(as_json):
1245
+ _require_yes(as_json, yes, "approve")
1246
+ api = connect(config, human=True)
1247
+ if _held_id(api, request_id):
1248
+ _approve_held(api, request_id, yes=yes, as_json=as_json, full=full)
1249
+ return
1250
+ v = _pending(api, request_id)
1251
+ if v["kind"] == "knock":
1252
+ _approve_knock(api, v, purpose, yes=yes, as_json=as_json)
1253
+ return
1254
+ if purpose is not None:
1255
+ raise CliError("bad_request", "--purpose is for knocks only", EXIT_USAGE)
1256
+ _print_approval(v, as_json=as_json)
1257
+ if not _confirm("Let this member join?", yes=yes, as_json=as_json):
1258
+ raise CliError("declined", "not approved; the request stays pending", EXIT_REFUSED)
1259
+ api.request("POST", f"/v0/approvals/{request_id}/approve")
1260
+ if as_json:
1261
+ emit_json({"request_id": request_id, "status": "approving", "group_id": v["group_id"]})
1262
+ else:
1263
+ typer.echo("Approved. Once they have joined, compare the short code with them:")
1264
+ typer.echo(f"both of you run `agentconnect verify {v['group_id']}` on a call.")
1265
+
1266
+
1267
+ @approvals_app.command(
1268
+ "reject",
1269
+ cls=IdArgsCommand,
1270
+ help="Reject a join request or a knock (nothing tells the peer), or drop a held "
1271
+ f"message. {HUMAN_ONLY_HELP}",
1272
+ )
1273
+ def approvals_reject(request_id: str, config: ConfigOpt = None, as_json: JsonOpt = False) -> None:
1274
+ with guard(as_json):
1275
+ api = connect(config, human=True)
1276
+ if _held_id(api, request_id):
1277
+ out = api.request("POST", f"/v0/approvals/messages/{request_id}/reject")
1278
+ if as_json:
1279
+ emit_json(out)
1280
+ else:
1281
+ typer.echo(f"Dropped held message {request_id}.")
1282
+ return
1283
+ _pending(api, request_id)
1284
+ api.request("POST", f"/v0/approvals/{request_id}/reject")
1285
+ if as_json:
1286
+ emit_json({"request_id": request_id, "status": "rejected"})
1287
+ else:
1288
+ typer.echo(f"Rejected {request_id}.")
1289
+
1290
+
1291
+ # -- verification (human) -------------------------------------------------------------
1292
+
1293
+
1294
+ POLL_SECS = 0.5
1295
+ ABORT_TEXT = {
1296
+ "timed_out": "The comparison timed out.",
1297
+ "epoch_changed": "The group changed during the comparison.",
1298
+ "bad_reveal": "The other daemon's reveal didn't match its commitment.",
1299
+ "out_of_order": "The other daemon revealed before it committed.",
1300
+ "peer_restarted": "The other daemon started its side again.",
1301
+ "send_failed": "The relay refused a comparison message.",
1302
+ "proposal_pending": "A member's leave waits for its commit; nothing can be sent until then.",
1303
+ "group_gone": "The group is no longer active.",
1304
+ "failed": "The comparison couldn't start.",
1305
+ }
1306
+ InstanceOpt = Annotated[
1307
+ str | None,
1308
+ typer.Option(
1309
+ "--instance",
1310
+ help="Compare with this instance of the member only (its id, 16 hex characters, "
1311
+ "from `groups show`).",
1312
+ ),
1313
+ ]
1314
+
1315
+
1316
+ def _pick_member(others: list[dict[str, Any]], ref: str | None) -> dict[str, Any]:
1317
+ """The member to compare with: its identity, fingerprint or display name."""
1318
+ if ref is None:
1319
+ if len(others) == 1:
1320
+ return others[0]
1321
+ names = ", ".join(clean(m["display_name"] or m["identity"]) for m in others)
1322
+ msg = f"name the member to compare codes with, one at a time: {names}"
1323
+ raise CliError("needs_member", msg, EXIT_USAGE)
1324
+ squeezed = ref.replace(" ", "")
1325
+ found = [
1326
+ m
1327
+ for m in others
1328
+ if ref == m["identity"]
1329
+ or squeezed == m["fingerprint"].replace(" ", "")
1330
+ or ref == m["display_name"]
1331
+ ]
1332
+ if len(found) == 1:
1333
+ return found[0]
1334
+ if not found:
1335
+ raise CliError("not_found", f"{ref!r} is not another member of this group", EXIT_FAILED)
1336
+ msg = f"{ref!r} names {len(found)} members; use the identity or the fingerprint"
1337
+ raise CliError("ambiguous", msg, EXIT_USAGE)
1338
+
1339
+
1340
+ def _start_run(
1341
+ api: ApiClient, gid: str, peer: dict[str, Any], instance: str | None, as_json: bool
1342
+ ) -> dict[str, Any]:
1343
+ path = f"/v0/groups/{gid}/members/{peer['identity']}/sas"
1344
+ body: dict[str, Any] = {} if instance is None else {"instance": instance}
1345
+ try:
1346
+ return dict(api.request("POST", path, body))
1347
+ except ApiClientError as e:
1348
+ if e.code != "sas_stopped":
1349
+ raise
1350
+ _say(clean(e.message), as_json=as_json)
1351
+ if not typer.confirm("Start another comparison anyway?", default=False, err=as_json):
1352
+ raise CliError("sas_stopped", "no comparison started", EXIT_REFUSED)
1353
+ return dict(api.request("POST", path, {**body, "acknowledge": True}))
1354
+
1355
+
1356
+ def _offer_mismatch(
1357
+ api: ApiClient, gid: str, info: dict[str, Any], run: str, as_json: bool
1358
+ ) -> None:
1359
+ if typer.confirm(
1360
+ "Treat this as different codes and leave the group?", default=False, err=as_json
1361
+ ):
1362
+ _mismatch(api, gid, info, as_json, run=run)
1363
+
1364
+
1365
+ def _follow_run(
1366
+ api: ApiClient, gid: str, info: dict[str, Any], view: dict[str, Any], as_json: bool
1367
+ ) -> dict[str, Any]:
1368
+ """Poll the run until it has a code, following restarts in a new epoch."""
1369
+ told = False
1370
+ while True:
1371
+ if view["state"] == "done":
1372
+ return view
1373
+ if view["state"] == "aborted":
1374
+ if view.get("next"):
1375
+ view = api.request("GET", f"/v0/groups/{gid}/sas/runs/{view['next']}")
1376
+ continue
1377
+ _aborted(api, gid, info, view, as_json)
1378
+ if view["unpaired"] and not told:
1379
+ told = True
1380
+ _say(clean(view["unpaired_text"]), as_json=as_json)
1381
+ choice = typer.prompt(
1382
+ "[w]ait, [s]tarted (they say they have started), [d]ifferent codes",
1383
+ default="w",
1384
+ err=as_json,
1385
+ )
1386
+ if choice.strip().lower().startswith("d"):
1387
+ _mismatch(api, gid, info, as_json, run=view["run"])
1388
+ if choice.strip().lower().startswith("s"):
1389
+ path = f"/v0/groups/{gid}/sas/runs/{view['run']}/peer-started"
1390
+ view = api.request("POST", path)
1391
+ cli_support.pause(POLL_SECS)
1392
+ view = api.request("GET", f"/v0/groups/{gid}/sas/runs/{view['run']}")
1393
+
1394
+
1395
+ def _aborted(
1396
+ api: ApiClient, gid: str, info: dict[str, Any], view: dict[str, Any], as_json: bool
1397
+ ) -> None:
1398
+ reason = str(view.get("reason") or "failed")
1399
+ _say(ABORT_TEXT.get(reason, "The comparison ended without a code."), as_json=as_json)
1400
+ if view["warning"]:
1401
+ _say(f"WARNING: {clean(view['warning_text'])}", as_json=as_json)
1402
+ if view.get("committer"):
1403
+ names = {m["identity"]: m["display_name"] for m in info["members"]}
1404
+ who = clean(names.get(view["committer"]) or view["committer"])
1405
+ _say(f"The commit that ended it came from {who}.", as_json=as_json)
1406
+ if view["stopped"]:
1407
+ _say(clean(view["stop_text"]), as_json=as_json)
1408
+ _offer_mismatch(api, gid, info, view["run"], as_json)
1409
+ elif "peer_instance" not in view and view.get("unpaired_text"):
1410
+ # It never paired: one way a fork shows (SPEC 12).
1411
+ _say(clean(view["unpaired_text"]), as_json=as_json)
1412
+ _offer_mismatch(api, gid, info, view["run"], as_json)
1413
+ raise CliError(f"sas_{reason}", "no code: run `agentconnect verify` again", EXIT_FAILED)
1414
+
1415
+
1416
+ @app.command(
1417
+ cls=IdArgsCommand,
1418
+ help="Compare the short code with one other member: both humans run this at the same "
1419
+ f"time, each for the other. {HUMAN_ONLY_HELP}",
1420
+ )
1421
+ def verify(
1422
+ group: str,
1423
+ member: Annotated[
1424
+ str | None,
1425
+ typer.Argument(help="The member: identity, fingerprint or display name."),
1426
+ ] = None,
1427
+ instance: InstanceOpt = None,
1428
+ config: ConfigOpt = None,
1429
+ as_json: JsonOpt = False,
1430
+ ) -> None:
1431
+ # Interactive on purpose: there is no --yes, so a script can't answer for the human.
1432
+ with guard(as_json):
1433
+ api = connect(config, human=True)
1434
+ gid = resolve_group(api, group)
1435
+ info = api.request("GET", f"/v0/groups/{gid}")
1436
+ others = [m for m in info["members"] if not m["self"]]
1437
+ if info["status"] != "active":
1438
+ raise CliError("not_active", "this group is no longer active", EXIT_FAILED)
1439
+ if not others:
1440
+ raise CliError("no_members", "no other members yet; nothing to verify", EXIT_FAILED)
1441
+ peer = _pick_member(others, member)
1442
+ name = clean(peer["display_name"] or peer["identity"])
1443
+ run = _start_run(api, gid, peer, instance, as_json)
1444
+ _say(f"Comparing short codes with {name} in {clean(info['name'])}.", as_json=as_json)
1445
+ _say(
1446
+ f"Ask {name}'s human to run `agentconnect verify` for you on their side now, "
1447
+ "over a call or in person, not over the channel that carried the invite.",
1448
+ as_json=as_json,
1449
+ )
1450
+ try:
1451
+ view = _follow_run(api, gid, info, run, as_json)
1452
+ except (KeyboardInterrupt, typer.Abort):
1453
+ with contextlib.suppress(ApiClientError):
1454
+ api.request("POST", f"/v0/groups/{gid}/sas/runs/{run['run']}/cancel")
1455
+ raise
1456
+ sas, labels = Sas.of(view["sas"]), view["labels"]
1457
+ _say("Your short code:", as_json=as_json)
1458
+ for line in sas.lines():
1459
+ _say(line, as_json=as_json)
1460
+ _say(
1461
+ f" instances: yours {labels['self']}, theirs {labels['peer']} "
1462
+ "(they read the same two, the other way round)",
1463
+ as_json=as_json,
1464
+ )
1465
+ # Both questions, always (SPEC 11.5): labels that don't mirror are
1466
+ # how a fork shows when the attacker's instance ids are its own.
1467
+ same_code = typer.confirm(
1468
+ f"Does {name}'s human read exactly this code?", default=False, err=as_json
1469
+ )
1470
+ same_labels = typer.confirm(
1471
+ "And the same two instance labels, the other way round?", default=False, err=as_json
1472
+ )
1473
+ if not same_code:
1474
+ # Whatever the labels say: an attacker in the middle chooses her
1475
+ # instance ids, so labels can't explain codes that differ.
1476
+ _mismatch(api, gid, info, as_json, run=view["run"])
1477
+ return
1478
+ if not same_labels:
1479
+ _labels_differ(api, gid, info, view, as_json)
1480
+ body = {"verified": True, "run": view["run"]}
1481
+ api.request("POST", f"/v0/groups/{gid}/members/{peer['identity']}/verify", body)
1482
+ if as_json:
1483
+ emit_json(
1484
+ {
1485
+ "outcome": "verified",
1486
+ "group_id": gid,
1487
+ "verified": [peer["identity"]],
1488
+ "run": view["run"],
1489
+ "sas": sas.as_json(),
1490
+ "labels": labels,
1491
+ }
1492
+ )
1493
+ else:
1494
+ typer.echo(f"Marked {name} as verified.")
1495
+
1496
+
1497
+ LABEL = re.compile(r"^[0-9a-f]{8}$")
1498
+
1499
+
1500
+ def _labels_differ(
1501
+ api: ApiClient, gid: str, info: dict[str, Any], view: dict[str, Any], as_json: bool
1502
+ ) -> None:
1503
+ """The same code, but labels that don't mirror (SPEC 11.5): the other
1504
+ human's two labels go to the daemon, which counts a warning and treats it
1505
+ as codes that differ, unless both name instances in this view (replicas
1506
+ paired crosswise)."""
1507
+ while True:
1508
+ raw = typer.prompt(
1509
+ "The two labels they read, theirs first (e.g. 1a2b3c4d 5e6f7a8b)", err=as_json
1510
+ )
1511
+ parts = raw.split()
1512
+ if len(parts) == 2 and all(LABEL.match(x) for x in parts): # noqa: PLR2004
1513
+ break
1514
+ _say("Two labels of 8 hex characters each, separated by a space.", as_json=as_json)
1515
+ path = f"/v0/groups/{gid}/sas/runs/{view['run']}/labels"
1516
+ out = api.request("POST", path, {"self": parts[0], "peer": parts[1]})
1517
+ if out["outcome"] == "mismatch":
1518
+ _report_mismatch(api, gid, info, as_json)
1519
+ _say(clean(out.get("text", "")), as_json=as_json)
1520
+ msg = f"no answer recorded: start again with --instance {out.get('instance', '')}".rstrip()
1521
+ raise CliError("labels_differ", msg, EXIT_REFUSED)
1522
+
1523
+
1524
+ def _mismatch(
1525
+ api: ApiClient, gid: str, info: dict[str, Any], as_json: bool, run: str | None = None
1526
+ ) -> None:
1527
+ api.request("POST", f"/v0/groups/{gid}/sas/mismatch", {} if run is None else {"run": run})
1528
+ _report_mismatch(api, gid, info, as_json)
1529
+
1530
+
1531
+ def _report_mismatch(api: ApiClient, gid: str, info: dict[str, Any], as_json: bool) -> None:
1532
+ transfer = None
1533
+ for ev in events_after(api):
1534
+ if ev["type"] == "sas_mismatch" and ev["group_id"] == gid:
1535
+ transfer = ev["data"].get("transfer")
1536
+ if transfer is not None:
1537
+ names = {m["identity"]: m["display_name"] for m in info["members"]}
1538
+ transfer = {**transfer, "new_owner_name": names.get(transfer["new_owner"])}
1539
+ if as_json:
1540
+ emit_json(
1541
+ {
1542
+ "outcome": "mismatch",
1543
+ "group_id": gid,
1544
+ "status": "left",
1545
+ "ownership_transfer": transfer,
1546
+ }
1547
+ )
1548
+ else:
1549
+ typer.echo("The codes differ, so this group's members may be seeing different views.")
1550
+ typer.echo("You have left the group and deleted its local state.")
1551
+ if transfer is None:
1552
+ typer.echo(
1553
+ "Tell the other members over a trusted channel, and don't rejoin by the same link."
1554
+ )
1555
+ elif transfer["confirmed"]:
1556
+ owner = clean(transfer["new_owner_name"] or transfer["new_owner"])
1557
+ typer.echo(f"You were the owner: ownership was handed to {owner}.")
1558
+ else:
1559
+ typer.echo("You were the owner and the ownership hand-over was NOT confirmed.")
1560
+ typer.echo("If the view was forked, the members must recreate the group (new invites).")
1561
+ raise typer.Exit(EXIT_MISMATCH)
1562
+
1563
+
1564
+ # -- talking ---------------------------------------------------------------------------------
1565
+
1566
+
1567
+ @app.command(cls=IdArgsCommand)
1568
+ def leave(group: str, config: ConfigOpt = None, as_json: JsonOpt = False) -> None:
1569
+ """Leave a group. The owner must hand over ownership first."""
1570
+ with guard(as_json):
1571
+ api = connect(config)
1572
+ gid = resolve_group(api, group)
1573
+ state = api.request("POST", f"/v0/groups/{gid}/leave")["status"]
1574
+ if as_json:
1575
+ emit_json({"group_id": gid, "status": state})
1576
+ elif state == "left":
1577
+ typer.echo("Left the group.")
1578
+ else:
1579
+ typer.echo("Leaving: the proposal is sent; the other members complete it.")
1580
+
1581
+
1582
+ @app.command(cls=IdArgsCommand)
1583
+ def send( # noqa: PLR0913, PLR0917
1584
+ group: str,
1585
+ text: str,
1586
+ to: Annotated[
1587
+ list[str] | None, typer.Option("--to", help="Recipient identity (repeatable; default all).")
1588
+ ] = None,
1589
+ instance: Annotated[
1590
+ str | None,
1591
+ typer.Option(
1592
+ "--instance",
1593
+ help="Trigger only this instance of the recipients (its id, 16 hex characters, "
1594
+ "from `groups show`; SPEC 9.4). Every instance still receives the message.",
1595
+ ),
1596
+ ] = None,
1597
+ config: ConfigOpt = None,
1598
+ as_json: JsonOpt = False,
1599
+ ) -> None:
1600
+ """Send a text message as the agent (for manual testing).
1601
+
1602
+ The daemon wraps the text as an A2A v1.0 Message (SPEC 7.2).
1603
+ """
1604
+ with guard(as_json):
1605
+ api = connect(config)
1606
+ gid = resolve_group(api, group)
1607
+ message: dict[str, Any] = {"text": text, "to": to or ["*"]}
1608
+ if instance is not None:
1609
+ message["instance"] = instance
1610
+ sent = api.request("POST", f"/v0/groups/{gid}/messages", message)
1611
+ hint = unverified_hint(api.request("GET", f"/v0/groups/{gid}"))
1612
+ if as_json:
1613
+ emit_json({"msg_id": sent["msg_id"], "group_id": gid, "hint": hint})
1614
+ else:
1615
+ typer.echo(f"Sent {sent['msg_id']}.")
1616
+ if hint is not None:
1617
+ typer.echo(f"Note: {hint}")
1618
+
1619
+
1620
+ def _events_gone(oldest: int) -> None:
1621
+ typer.echo(f"warning: events before {oldest} are gone (daemon.event_retention_days)", err=True)
1622
+
1623
+
1624
+ @app.command()
1625
+ def events(
1626
+ follow: Annotated[
1627
+ bool, typer.Option("--follow", "-f", help="Keep printing new events.")
1628
+ ] = False,
1629
+ after: Annotated[
1630
+ int, typer.Option("--after", help="Only events after this sequence number.")
1631
+ ] = 0,
1632
+ config: ConfigOpt = None,
1633
+ as_json: JsonOpt = False,
1634
+ ) -> None:
1635
+ """Print daemon events (one JSON object per line with --json). Uses the
1636
+ human token: the safety policy's events (held messages, violations) are
1637
+ the human's, and kept until read here. Warns on stderr when events it
1638
+ asks for are gone (daemon.event_retention_days)."""
1639
+ with guard(as_json):
1640
+ api = connect(config, human=True)
1641
+ try:
1642
+ while True:
1643
+ for ev in events_after(api, after, _events_gone):
1644
+ after = int(ev["seq"])
1645
+ if as_json:
1646
+ emit_json(ev)
1647
+ else:
1648
+ scope = f" {ev['group_id']}" if ev["group_id"] else ""
1649
+ typer.echo(f"{ev['seq']} {_when(ev['at'])} {ev['type']}{scope}")
1650
+ if not follow:
1651
+ return
1652
+ cli_support.pause(FOLLOW_POLL_SECS)
1653
+ except KeyboardInterrupt:
1654
+ return
1655
+
1656
+
1657
+ # -- safety policy -----------------------------------------------------------------------------
1658
+
1659
+
1660
+ def _parse_setting(text: str) -> tuple[str, Any]:
1661
+ """``name=value``: ``true``/``false``, an integer, ``default`` (clears the
1662
+ group's override) or a word."""
1663
+ name, eq, raw = text.partition("=")
1664
+ if not eq or not name:
1665
+ raise CliError("bad_setting", f"expected NAME=VALUE, got {text!r}", EXIT_USAGE)
1666
+ value: Any = raw
1667
+ if raw in ("true", "false"):
1668
+ value = raw == "true"
1669
+ elif raw.isdigit():
1670
+ value = int(raw)
1671
+ elif raw == "default":
1672
+ value = None
1673
+ return name, value
1674
+
1675
+
1676
+ def _print_safety(info: dict[str, Any]) -> None:
1677
+ settings = dict(info["settings"])
1678
+ senders = settings.pop("senders", {})
1679
+ overrides = info["overrides"]
1680
+ typer.echo(f"Safety policy of {info['group_id']} (* = set for this group):")
1681
+ for name, value in settings.items():
1682
+ mark = "*" if name in overrides else " "
1683
+ typer.echo(f" {mark} {name:<26} {json.dumps(value)}")
1684
+ for sender, values in senders.items():
1685
+ typer.echo(f" for {sender}:")
1686
+ for name, value in values.items():
1687
+ typer.echo(f" {name:<24} {json.dumps(value)}")
1688
+
1689
+
1690
+ @safety_app.command("show", cls=IdArgsCommand)
1691
+ def safety_show(group: str, config: ConfigOpt = None, as_json: JsonOpt = False) -> None:
1692
+ """Show a group's safety policy: limits, approvals, framing, transcript."""
1693
+ with guard(as_json):
1694
+ api = connect(config)
1695
+ info = api.request("GET", f"/v0/groups/{resolve_group(api, group)}/safety")
1696
+ if as_json:
1697
+ emit_json(info)
1698
+ else:
1699
+ _print_safety(info)
1700
+
1701
+
1702
+ @safety_app.command(
1703
+ "set", cls=IdArgsCommand, help=f"Change a group's safety policy. {HUMAN_ONLY_HELP}"
1704
+ )
1705
+ def safety_set(
1706
+ group: str,
1707
+ settings: Annotated[
1708
+ list[str],
1709
+ typer.Argument(help="NAME=VALUE pairs; VALUE `default` removes the group's override."),
1710
+ ],
1711
+ sender: Annotated[
1712
+ str | None,
1713
+ typer.Option("--sender", help="Apply the limits to this member's identity only."),
1714
+ ] = None,
1715
+ config: ConfigOpt = None,
1716
+ as_json: JsonOpt = False,
1717
+ ) -> None:
1718
+ with guard(as_json):
1719
+ api = connect(config, human=True)
1720
+ gid = resolve_group(api, group)
1721
+ body: dict[str, Any] = {"set": dict(_parse_setting(s) for s in settings)}
1722
+ if sender is not None:
1723
+ body["sender"] = sender
1724
+ info = api.request("POST", f"/v0/groups/{gid}/safety", body)
1725
+ if as_json:
1726
+ emit_json(info)
1727
+ else:
1728
+ _print_safety(info)
1729
+
1730
+
1731
+ @app.command(
1732
+ cls=IdArgsCommand,
1733
+ help="Remove a member from a group (owner or admin), or with --instance only some of its "
1734
+ f"instances, such as a dead replica (also of your own identity). {HUMAN_ONLY_HELP}",
1735
+ )
1736
+ def remove( # noqa: PLR0913, PLR0917
1737
+ group: str,
1738
+ identity: str,
1739
+ instance: Annotated[
1740
+ list[str] | None,
1741
+ typer.Option(
1742
+ "--instance",
1743
+ help="Remove only this instance (its id, 16 hex characters, from `groups show`; "
1744
+ "repeatable).",
1745
+ ),
1746
+ ] = None,
1747
+ yes: YesOpt = False,
1748
+ config: ConfigOpt = None,
1749
+ as_json: JsonOpt = False,
1750
+ ) -> None:
1751
+ with guard(as_json):
1752
+ _require_yes(as_json, yes, "remove")
1753
+ api = connect(config, human=True)
1754
+ gid = resolve_group(api, group)
1755
+ info = api.request("GET", f"/v0/groups/{gid}")
1756
+ member = next((m for m in info["members"] if m["identity"] == identity), None)
1757
+ if member is None:
1758
+ raise CliError("not_found", f"{identity!r} is not a member", EXIT_FAILED)
1759
+ if instance is None and member["self"]:
1760
+ msg = "that is your own identity: name its other instances with --instance, or leave"
1761
+ raise CliError("bad_request", msg, EXIT_USAGE)
1762
+ name = clean(member["display_name"] or identity)
1763
+ if instance is None:
1764
+ question = "Remove every instance of this member?"
1765
+ _say(f"Remove {name} ({identity})", as_json=as_json)
1766
+ else:
1767
+ question = f"Remove {len(instance)} instance(s) of this member?"
1768
+ _say(
1769
+ f"Remove instance(s) {', '.join(instance)} of {name} ({identity})", as_json=as_json
1770
+ )
1771
+ _say(f"from {clean(info['name'])}?", as_json=as_json)
1772
+ if not _confirm(question, yes=yes, as_json=as_json):
1773
+ raise CliError("declined", "not removed", EXIT_REFUSED)
1774
+ body = None if instance is None else {"instances": instance}
1775
+ state = api.request("POST", f"/v0/groups/{gid}/members/{identity}/remove", body)["status"]
1776
+ if as_json:
1777
+ out: dict[str, Any] = {"group_id": gid, "identity": identity, "status": state}
1778
+ if instance is not None:
1779
+ out["instances"] = instance
1780
+ emit_json(out)
1781
+ else:
1782
+ event = "member_removed" if instance is None else "instance_removed"
1783
+ typer.echo(f"Removal queued. `agentconnect events` reports `{event}` once it lands,")
1784
+ typer.echo("or `commit_failed` if your role doesn't allow it.")
1785
+
1786
+
1787
+ @app.command(
1788
+ cls=IdArgsCommand, help=f"Print the metadata-only audit log, oldest first. {HUMAN_ONLY_HELP}"
1789
+ )
1790
+ def audit(
1791
+ group: Annotated[str | None, typer.Option("--group", help="Only this group.")] = None,
1792
+ after: Annotated[int, typer.Option("--after", help="Only rows after this seq.")] = 0,
1793
+ config: ConfigOpt = None,
1794
+ as_json: JsonOpt = False,
1795
+ ) -> None:
1796
+ with guard(as_json):
1797
+ api = connect(config, human=True)
1798
+ query = f"?after={after}"
1799
+ if group is not None:
1800
+ query += f"&group={resolve_group(api, group)}"
1801
+ answer = api.request("GET", f"/v0/audit{query}")
1802
+ rows, suppressed = answer["audit"], answer["suppressed"]
1803
+ if as_json:
1804
+ emit_json({"audit": rows, "suppressed": suppressed})
1805
+ return
1806
+ for r in rows:
1807
+ # Every field may come from a peer: print none of it raw.
1808
+ why = f" ({clean(r['reason'])})" if r["reason"] else ""
1809
+ typer.echo(
1810
+ f"{r['seq']} {_when(r['at'])} {clean(r['direction']):<3} "
1811
+ f"{clean(r['decision'])}{why} {clean(r['kind'])} {r['size']}B "
1812
+ f"{clean(r['sender'])}"
1813
+ )
1814
+ for c in suppressed:
1815
+ typer.echo(
1816
+ f"not kept: {c['count']} more {clean(c['decision'])} rows from "
1817
+ f"{clean(c['sender'])}, {_when(c['first_at'])} to {_when(c['last_at'])}"
1818
+ )
1819
+
1820
+
1821
+ @app.command(
1822
+ cls=IdArgsCommand, help=f"Print a group's transcript (when it is on). {HUMAN_ONLY_HELP}"
1823
+ )
1824
+ def transcript(
1825
+ group: str,
1826
+ after: Annotated[int, typer.Option("--after", help="Only entries after this seq.")] = 0,
1827
+ config: ConfigOpt = None,
1828
+ as_json: JsonOpt = False,
1829
+ ) -> None:
1830
+ with guard(as_json):
1831
+ api = connect(config, human=True)
1832
+ gid = resolve_group(api, group)
1833
+ rows = api.request("GET", f"/v0/groups/{gid}/transcript?after={after}")["transcript"]
1834
+ if as_json:
1835
+ emit_json({"transcript": rows, "untrusted": True})
1836
+ return
1837
+ if not rows:
1838
+ typer.echo("No transcript entries. Turn it on with `agentconnect safety set`.")
1839
+ for r in rows:
1840
+ body = r["inner"].get("body") if isinstance(r["inner"], dict) else None
1841
+ parts = body.get("parts") if isinstance(body, dict) else None
1842
+ texts = [str(p["text"]) for p in parts or [] if isinstance(p, dict) and "text" in p]
1843
+ text = " ".join(texts) if texts else r["kind"]
1844
+ typer.echo(
1845
+ f"{r['seq']} {_when(r['at'])} {clean(r['direction']):<3} {clean(r['sender'])}"
1846
+ )
1847
+ typer.echo(f" {clean(text)[:MAX_PREVIEW]}")
1848
+
1849
+
1850
+ # -- the listing ---------------------------------------------------------------------------
1851
+
1852
+
1853
+ def _read_json(path: Path, what: str) -> dict[str, Any]:
1854
+ """A JSON object from a file the calling user can read; the daemon gets
1855
+ its contents, never the path (docs/isolation.md)."""
1856
+ try:
1857
+ text = path.read_text(encoding="utf-8")
1858
+ except (OSError, UnicodeDecodeError) as e:
1859
+ msg = f"can't read the {what} file {path}: {getattr(e, 'strerror', None) or e}"
1860
+ raise CliError("unreadable_file", msg, EXIT_USAGE) from None
1861
+ try:
1862
+ value = json.loads(text)
1863
+ except ValueError:
1864
+ raise CliError("bad_json", f"the {what} file {path} is not JSON", EXIT_USAGE) from None
1865
+ if not isinstance(value, dict):
1866
+ raise CliError("bad_json", f"the {what} file {path} is not a JSON object", EXIT_USAGE)
1867
+ return value
1868
+
1869
+
1870
+ def _print_preview(shown: dict[str, Any], *, as_json: bool) -> None:
1871
+ content = shown["content"]
1872
+
1873
+ def say(text: str) -> None:
1874
+ _say(text, as_json=as_json)
1875
+
1876
+ say(f"Listing {clean(shown['name'])} on {clean(shown['directory'])}")
1877
+ say(f"Identity {shown['fingerprint']}")
1878
+ say(f'Name "{clean(content.get("name", ""))}"')
1879
+ say(f'About "{clean(content.get("description", ""))}"')
1880
+ say(f"Version {clean(content.get('version', ''))}")
1881
+ provider = content.get("provider")
1882
+ if isinstance(provider, dict):
1883
+ org, url = clean(provider.get("organization", "")), clean(provider.get("url", ""))
1884
+ say(f"Provider {org} ({url})")
1885
+ for skill in content.get("skills", []):
1886
+ say(f"Skill {clean(skill.get('name', ''))}: {clean(skill.get('description', ''))}")
1887
+ domain = shown["owner_domain"]
1888
+ say(f"Owner {clean(domain) if domain else 'none claimed'}")
1889
+ say(f"Token valid until {_when(shown['token']['expires_at'])}")
1890
+ for warning in shown["warnings"]:
1891
+ say(f"Warning {clean(warning['message'])}")
1892
+ entry = shown["owner_file_entry"]
1893
+ if entry is not None:
1894
+ say(f"Add this entry to https://{clean(domain)}/.well-known/agentconnect.json:")
1895
+ say(" " + json.dumps(entry))
1896
+ say("The runtime renews the listing's door on its own; changing this text is a new publish.")
1897
+
1898
+
1899
+ @listing_app.command(
1900
+ "publish",
1901
+ help="Publish your agent's listing in a directory: shows the card text, the identity and "
1902
+ "the token, asks, then registers a door at the relay, signs and posts the listing. "
1903
+ f"{HUMAN_ONLY_HELP}",
1904
+ )
1905
+ def listing_publish( # noqa: PLR0913, PLR0917
1906
+ directory: Annotated[
1907
+ str, typer.Option("--directory", help="The directory host, from your [[directory]].")
1908
+ ],
1909
+ card: Annotated[
1910
+ Path | None, typer.Option("--card", help="The card file (default: listing.card_file).")
1911
+ ] = None,
1912
+ token: Annotated[
1913
+ Path | None,
1914
+ typer.Option("--token", help="The operator's token file (default: listing.token_file)."),
1915
+ ] = None,
1916
+ owner_domain: Annotated[
1917
+ str | None,
1918
+ typer.Option("--owner-domain", help="A domain whose owner file vouches for this key."),
1919
+ ] = None,
1920
+ yes: YesOpt = False,
1921
+ config: ConfigOpt = None,
1922
+ as_json: JsonOpt = False,
1923
+ ) -> None:
1924
+ with guard(as_json):
1925
+ _require_yes(as_json, yes, "publish")
1926
+ if card is None or token is None:
1927
+ cfg = load(config)
1928
+ card = card or cfg.listing_card_file
1929
+ token = token or cfg.listing_token_file
1930
+ if card is None or token is None: # pragma: no cover - load_config sets both
1931
+ raise CliError("config", "no card or token file", EXIT_USAGE)
1932
+ body: dict[str, Any] = {
1933
+ "directory": directory,
1934
+ "card": _read_json(card, "card"),
1935
+ "token": _read_json(token, "token"),
1936
+ }
1937
+ if owner_domain is not None:
1938
+ body["owner_domain"] = owner_domain
1939
+ api = connect(config, human=True)
1940
+ shown = _listing_call(api, "/v0/listing/preview", body)
1941
+ _print_preview(shown, as_json=as_json)
1942
+ if not _confirm("Publish this listing?", yes=yes, as_json=as_json):
1943
+ raise CliError("declined", "not published; nothing was sent", EXIT_REFUSED)
1944
+ out = _listing_call(
1945
+ api, "/v0/listing/publish", {**body, "content_sha256": shown["content_sha256"]}
1946
+ )
1947
+ if out["retrying"]:
1948
+ msg = (
1949
+ "the directory didn't answer; the daemon keeps sending the same signed listing "
1950
+ "(see `agentconnect listing status`)"
1951
+ )
1952
+ raise CliError("publish_retrying", msg, EXIT_FAILED)
1953
+ result = {
1954
+ "directory": out["directory"],
1955
+ "name": out["name"],
1956
+ "url": out["url"],
1957
+ "identity": shown["identity"],
1958
+ "fingerprint": shown["fingerprint"],
1959
+ "seq": out["seq"],
1960
+ "state": out["state"],
1961
+ "door_expires_at": out["door_expires_at"],
1962
+ "renews_at": out["renews_at"],
1963
+ "token_expires_at": out["token_expires_at"],
1964
+ "owner_domain": out["owner_domain"],
1965
+ "owner_state": out["owner_state"],
1966
+ "warnings": out["warnings"],
1967
+ }
1968
+ if as_json:
1969
+ emit_json(result)
1970
+ return
1971
+ typer.echo(f"Published {result['url']} (seq {result['seq']}).")
1972
+ if result["door_expires_at"] is not None:
1973
+ typer.echo(f"Its door is open until {_when(result['door_expires_at'])}; the runtime")
1974
+ typer.echo("renews it while it runs. `agentconnect listing withdraw` takes it down.")
1975
+
1976
+
1977
+ @listing_app.command(
1978
+ "withdraw",
1979
+ help="Withdraw your agent's listing: its doors close at the relay first, so knocks stop "
1980
+ "at once, then the directory takes it down. Allowed with the agent token. --force ends "
1981
+ "a withdrawal the directory never took, locally (HUMAN ONLY: the human token, after you "
1982
+ "confirm).",
1983
+ )
1984
+ def listing_withdraw(
1985
+ yes: YesOpt = False,
1986
+ force: Annotated[
1987
+ bool, typer.Option("--force", help="End it locally without the directory (human token).")
1988
+ ] = False,
1989
+ config: ConfigOpt = None,
1990
+ as_json: JsonOpt = False,
1991
+ ) -> None:
1992
+ with guard(as_json):
1993
+ _require_yes(as_json, yes, "withdraw")
1994
+ api = connect(config, human=force)
1995
+ info = api.request("GET", "/v0/listing")["listing"]
1996
+ if info is None or info["state"] == "withdrawn":
1997
+ raise CliError("not_found", "there is no listing to withdraw", EXIT_FAILED)
1998
+ _say(f"Withdraw {clean(info['name'])} from {clean(info['directory'])}?", as_json=as_json)
1999
+ if force:
2000
+ _say(
2001
+ "--force records it as withdrawn here without the directory's answer; the "
2002
+ "directory may go on listing it until its token expires.",
2003
+ as_json=as_json,
2004
+ )
2005
+ if not _confirm("Close its doors and withdraw it?", yes=yes, as_json=as_json):
2006
+ raise CliError("declined", "not withdrawn", EXIT_REFUSED)
2007
+ out = _listing_call(api, "/v0/listing/withdraw", {"force": True} if force else None)
2008
+ if out["state"] not in ("withdrawn", "withdrawn_locally"):
2009
+ msg = (
2010
+ "the doors are closed, but the directory hasn't taken the withdrawal yet; "
2011
+ "the daemon keeps sending it"
2012
+ )
2013
+ raise CliError("withdraw_pending", msg, EXIT_FAILED)
2014
+ if as_json:
2015
+ emit_json(out)
2016
+ return
2017
+ if out["forced"]:
2018
+ typer.echo(
2019
+ f"Recorded {clean(out['name'])} as withdrawn here only. The directory never "
2020
+ "took the withdrawal and may still list it until its token expires; its "
2021
+ "doors are closed, so it takes no knocks."
2022
+ )
2023
+ else:
2024
+ typer.echo(f"Withdrew {clean(out['name'])} from {clean(out['directory'])}.")
2025
+ if out["doors_close_pending"]:
2026
+ typer.echo("The relay hasn't confirmed every door closed yet; the daemon keeps trying.")
2027
+
2028
+
2029
+ def _listing_call(api: ApiClient, path: str, body: dict[str, Any] | None = None) -> Any:
2030
+ """A listing request. A directory that doesn't list this daemon's relay
2031
+ answers ``wrong_relay``, which here is about the directory, not an invite."""
2032
+ try:
2033
+ return api.request("POST", path, body)
2034
+ except ApiClientError as e:
2035
+ if e.code != "wrong_relay":
2036
+ raise
2037
+ msg = (
2038
+ "the directory doesn't list this daemon's relay, so it won't take the listing; "
2039
+ "ask its operator, or use a directory that lists your relay"
2040
+ )
2041
+ raise CliError("wrong_relay", msg, EXIT_REFUSED) from None
2042
+
2043
+
2044
+ @listing_app.command("status")
2045
+ def listing_status(config: ConfigOpt = None, as_json: JsonOpt = False) -> None:
2046
+ """Show your agent's listing: its state, door, token expiry and last error."""
2047
+ with guard(as_json):
2048
+ info = connect(config).request("GET", "/v0/listing")
2049
+ if as_json:
2050
+ emit_json(info)
2051
+ return
2052
+ listing = info["listing"]
2053
+ if listing is None:
2054
+ typer.echo("No listing. A person publishes one with `agentconnect listing publish`.")
2055
+ return
2056
+ typer.echo(f"listing {listing['url']}")
2057
+ typer.echo(f"state {listing['state']} (seq {listing['seq']})")
2058
+ door = listing["door"]
2059
+ if door is None:
2060
+ typer.echo("door none open")
2061
+ else:
2062
+ renews = door["renews_at"]
2063
+ then = f", renews {_when(renews)}" if renews is not None else ", not renewing"
2064
+ soon = " (expires within a day)" if door["expiring"] else ""
2065
+ typer.echo(f"door open until {_when(door['expires_at'])}{then}{soon}")
2066
+ retrying = listing["retrying"]
2067
+ if retrying is not None:
2068
+ code = clean(retrying["code"] or "unknown")
2069
+ typer.echo(f"retrying since {_when(retrying['since'])} ({code})")
2070
+ typer.echo(f"token valid until {_when(listing['token']['expires_at'])}")
2071
+ domain = listing["owner_domain"]
2072
+ if domain is not None:
2073
+ state = clean(listing["owner_state"] or "unknown")
2074
+ typer.echo(f"owner {clean(domain)} ({state}, as the directory reported)")
2075
+ error = listing["last_error"]
2076
+ if error is not None:
2077
+ typer.echo(f"last error {clean(error['code'])} at {_when(error['at'])}")
2078
+
2079
+
2080
+ # -- knocking -------------------------------------------------------------------------------
2081
+
2082
+ KNOCK_WAIT_SECS = 3900 # ADR-006: until the knock's gives_up_at (ts + 3600 + 300)
2083
+ OWNER_STATES = {
2084
+ "verified": "{domain}: verified by this daemon just now",
2085
+ "stale": "{domain}: vouched before, not at the last check",
2086
+ "revoked": "{domain}: revoked this key",
2087
+ "unchecked": "{domain}: not checked",
2088
+ "checking": "{domain}: not checked yet",
2089
+ }
2090
+
2091
+
2092
+ def _knock_call(api: ApiClient, path: str, body: dict[str, Any] | None = None) -> Any:
2093
+ """A knock request, with the knock's own exit codes (an unverified
2094
+ listing is 7, a suspect directory) and messages."""
2095
+ try:
2096
+ return api.request("POST" if body is not None else "GET", path, body)
2097
+ except ApiClientError as e:
2098
+ if e.code == "wrong_relay":
2099
+ msg = (
2100
+ "this listing is on a different relay than this daemon uses; AgentConnect v0 "
2101
+ "supports one relay, so nothing was sent"
2102
+ )
2103
+ raise CliError("wrong_relay", msg, EXIT_REFUSED) from None
2104
+ if e.code in cli_support.KNOCK_EXITS:
2105
+ raise CliError(e.code, e.message or e.code, cli_support.KNOCK_EXITS[e.code]) from None
2106
+ raise
2107
+
2108
+
2109
+ def _owner_line(owner: dict[str, Any], *, skipped: bool) -> str:
2110
+ domain = owner["domain"]
2111
+ if domain is None:
2112
+ return "no owner claimed"
2113
+ shown = clean(domain)
2114
+ if skipped:
2115
+ return f"{shown}: not checked, --no-domain-check"
2116
+ if owner["fetch"] is not None:
2117
+ return f"could not fetch {shown}"
2118
+ if owner["state"] == "failed":
2119
+ return f"{shown} does not vouch for this key"
2120
+ template = OWNER_STATES.get(owner["state"], "{domain}: " + clean(owner["state"]))
2121
+ return template.format(domain=shown)
2122
+
2123
+
2124
+ def _print_knock_listing(shown: dict[str, Any], *, skipped: bool, as_json: bool) -> None:
2125
+ listing = shown["listing"]
2126
+
2127
+ def say(text: str) -> None:
2128
+ _say(text, as_json=as_json)
2129
+
2130
+ say(f"Listing {clean(listing['name'])} on {clean(listing['directory'])}")
2131
+ say(f"Identity {listing['fingerprint']}")
2132
+ say(f"Owner {_owner_line(shown['owner'], skipped=skipped)}")
2133
+ if listing["organization"] is not None:
2134
+ say(f"Org {clean(listing['organization'])} (shown next to a verified domain)")
2135
+ say(
2136
+ f'Name "{clean(listing["display_name"])}" '
2137
+ "(written by the listing's owner, not verified)"
2138
+ )
2139
+ say(
2140
+ f'About "{clean(listing["description"])}" '
2141
+ "(written by the listing's owner, not verified)"
2142
+ )
2143
+ for skill in listing["skills"]:
2144
+ say(f"Skills {clean(skill['name'])}: {clean(skill['description'])}")
2145
+ say(f"Door open until {_when(int(shown['door']['expires_at']))}")
2146
+ say("A listing proves who signed it, not what the agent does or who runs it.")
2147
+ say("Compare the short code with its owner after joining.")
2148
+
2149
+
2150
+ def _wait_for_knock(api: ApiClient, knock_id: str, wait: int) -> dict[str, Any] | None:
2151
+ """Poll the event log for this knock's outcome; ``None`` if the wait ended first."""
2152
+ after = 0
2153
+ kinds = ("joined", "knock_lapsed", "knock_failed")
2154
+ for attempt in range(max(1, math.ceil(wait / WAIT_POLL_SECS)) if wait else 1):
2155
+ if attempt:
2156
+ cli_support.pause(WAIT_POLL_SECS)
2157
+ for ev in events_after(api, after):
2158
+ after = int(ev["seq"])
2159
+ if ev["data"].get("knock_id") == knock_id and ev["type"] in kinds:
2160
+ return ev
2161
+ if not wait:
2162
+ return None
2163
+ return None
2164
+
2165
+
2166
+ def _inspect_for_knock(
2167
+ api: ApiClient, listing: str, config: Path | None, *, no_check: bool, ask: bool
2168
+ ) -> tuple[dict[str, Any], bool | None]:
2169
+ """The listing as the daemon checked it, and the domain check chosen:
2170
+ asked (default ``knock.domain_check``) when there is a domain to check
2171
+ and a person to ask, the daemon's default with ``--yes`` or ``--json``."""
2172
+ if no_check:
2173
+ body = {"listing": listing, "domain_check": False}
2174
+ return _knock_call(api, "/v0/knocks/inspect", body), False
2175
+ if not ask:
2176
+ return _knock_call(api, "/v0/knocks/inspect", {"listing": listing}), None
2177
+ shown = _knock_call(api, "/v0/knocks/inspect", {"listing": listing, "domain_check": False})
2178
+ domain = shown["owner"]["domain"]
2179
+ if domain is None:
2180
+ return shown, False
2181
+ check = typer.confirm(
2182
+ f"Check {clean(domain)} now? Its web server learns that someone is about to knock, "
2183
+ "and from which address.",
2184
+ default=load(config).knock_domain_check,
2185
+ )
2186
+ if check:
2187
+ shown = _knock_call(api, "/v0/knocks/inspect", {"listing": listing, "domain_check": True})
2188
+ return shown, check
2189
+
2190
+
2191
+ @app.command()
2192
+ def knock( # noqa: PLR0913, PLR0917
2193
+ listing: Annotated[
2194
+ str, typer.Argument(help="The listing: <directory host>/a/<name>, as the directory shows.")
2195
+ ],
2196
+ name: Annotated[
2197
+ str | None, typer.Option("--name", help="The name to knock as (default: display_name).")
2198
+ ] = None,
2199
+ note: Annotated[
2200
+ str | None, typer.Option("--note", help="One line for the owner's human (optional).")
2201
+ ] = None,
2202
+ no_domain_check: Annotated[
2203
+ bool,
2204
+ typer.Option("--no-domain-check", help="Don't fetch the owner domain's file."),
2205
+ ] = False,
2206
+ wait: Annotated[
2207
+ int,
2208
+ typer.Option("--wait", help="Seconds to wait for an answer (0: don't)."),
2209
+ ] = KNOCK_WAIT_SECS,
2210
+ yes: YesOpt = False,
2211
+ config: ConfigOpt = None,
2212
+ as_json: JsonOpt = False,
2213
+ ) -> None:
2214
+ """Knock on a listing: ask its owner's human for a new group with this agent.
2215
+
2216
+ Shows the listing (checked by this daemon, its text marked unverified) and
2217
+ asks first (unless --yes). Only the owner's human can approve. There is no
2218
+ reject: with no answer by the deadline (about an hour) the knock lapses,
2219
+ and nothing says why.
2220
+ """
2221
+ with guard(as_json):
2222
+ _require_yes(as_json, yes, "knock")
2223
+ api = connect(config)
2224
+ shown, check = _inspect_for_knock(
2225
+ api, listing, config, no_check=no_domain_check, ask=not (yes or as_json)
2226
+ )
2227
+ _print_knock_listing(shown, skipped=no_domain_check, as_json=as_json)
2228
+ if not _confirm("Knock?", yes=yes, as_json=as_json):
2229
+ raise CliError("declined", "not knocking; nothing was sent", EXIT_REFUSED)
2230
+ body: dict[str, Any] = {"listing": listing, "listing_sha256": shown["listing"]["sha256"]}
2231
+ if check is not None:
2232
+ body["domain_check"] = check
2233
+ if name is not None:
2234
+ body["display_name"] = name
2235
+ if note is not None:
2236
+ body["note"] = note
2237
+ sent = _knock_call(api, "/v0/knocks", body)
2238
+ knock_id = sent["knock_id"]
2239
+ _say("Knock sent. Waiting for the owner's human to answer...", as_json=as_json)
2240
+ ev = _wait_for_knock(api, knock_id, wait)
2241
+ base = {"knock_id": knock_id, "listing": sent["listing"], "owner": sent["owner"]}
2242
+ if ev is None:
2243
+ if as_json:
2244
+ emit_json(
2245
+ {
2246
+ "status": "sent",
2247
+ **base,
2248
+ "gives_up_at": sent["gives_up_at"],
2249
+ "group_id": None,
2250
+ }
2251
+ )
2252
+ else:
2253
+ typer.echo(f"No answer yet. The knock waits until {_when(sent['gives_up_at'])};")
2254
+ typer.echo("`agentconnect knocks` shows how it ends.")
2255
+ return
2256
+ if ev["type"] == "knock_lapsed":
2257
+ msg = cli_support.NO_ANSWER.format(time=_when(sent["gives_up_at"]))
2258
+ raise CliError("no_answer", msg, EXIT_FAILED)
2259
+ if ev["type"] == "knock_failed":
2260
+ code = clean(ev["data"].get("code", "knock_failed"))
2261
+ raise CliError(code, f"the knock failed ({code})", EXIT_FAILED)
2262
+ view = group_view(api, ev["group_id"])
2263
+ hint = COMPARE_REMINDER.replace("<group>", view["group_id"])
2264
+ if as_json:
2265
+ emit_json(
2266
+ {
2267
+ "status": "joined",
2268
+ **base,
2269
+ "group_id": view["group_id"],
2270
+ "hint": hint,
2271
+ }
2272
+ )
2273
+ return
2274
+ typer.echo(f"Joined {clean(view['name'])} ({view['group_id']}).")
2275
+ typer.echo(hint)
2276
+
2277
+
2278
+ @app.command()
2279
+ def knocks(config: ConfigOpt = None, as_json: JsonOpt = False) -> None:
2280
+ """List this agent's knocks: waiting, joined, lapsed (no answer) or failed."""
2281
+ with guard(as_json):
2282
+ out = _knock_call(connect(config), "/v0/knocks")
2283
+ if as_json:
2284
+ emit_json(out)
2285
+ return
2286
+ if not out["knocks"]:
2287
+ typer.echo("No knocks.")
2288
+ for k in out["knocks"]:
2289
+ where = f"{clean(k['name'])} on {clean(k['directory'])}"
2290
+ end = f", group {k['group_id']}" if k["group_id"] else ""
2291
+ typer.echo(
2292
+ f"{k['knock_id']} {where} [{k['status']}{end}] until {_when(k['gives_up_at'])}"
2293
+ )
2294
+ typer.echo(f" identity {k['fingerprint']}")
2295
+ typer.echo(f" owner {clean(k['owner_state'])} (this daemon's check)")
2296
+ typer.echo(
2297
+ f' name "{clean(k["display_name"])}" '
2298
+ "(written by the listing's owner, not verified)"
2299
+ )
2300
+ typer.echo(
2301
+ f' about "{clean(k["description"])}" '
2302
+ "(written by the listing's owner, not verified)"
2303
+ )
2304
+
2305
+
2306
+ def _print_version(value: bool) -> None:
2307
+ if value:
2308
+ typer.echo(f"agentconnect {__version__} (protocol v{agentconnect_core.PROTOCOL_VERSION})")
2309
+ raise typer.Exit
2310
+
2311
+
2312
+ @app.callback()
2313
+ def main(
2314
+ _version: Annotated[
2315
+ bool,
2316
+ typer.Option(
2317
+ "--version",
2318
+ is_eager=True,
2319
+ callback=_print_version,
2320
+ help="Show the version and exit (the `version` command has --json).",
2321
+ ),
2322
+ ] = False,
2323
+ ) -> None:
2324
+ """AgentConnect CLI."""