fetch-hackkit 0.1.0__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.
fetch_hackkit/cards.py ADDED
@@ -0,0 +1,526 @@
1
+ """ASI:One Interactive Cards: builders, validation, and selection parsing.
2
+
3
+ A card is a normal chat-protocol ``ChatMessage`` with two content blocks:
4
+ a ``TextContent`` narration (shown in the chat bubble) and a ``MetadataContent``
5
+ declaring the card. When the user taps a button, ASI:One sends the button's
6
+ ``selection`` back to your agent as text (JSON on a direct @mention, a prose
7
+ sentence when routed by the ASI:One planner). :func:`parse_selection` handles both.
8
+
9
+ Every builder here puts an ``action`` key in each selection, so
10
+ ``HackAgent.on_action("name")`` can route taps without any parsing code.
11
+
12
+ Spec: https://innovationlab.fetch.ai/resources/docs/interactive-cards/asi-interactive-cards
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import copy
18
+ import json
19
+ import re
20
+ from dataclasses import dataclass
21
+ from datetime import UTC, datetime
22
+ from typing import Any, Literal
23
+ from uuid import uuid4
24
+
25
+ from uagents_core.contrib.protocols.chat import ChatMessage, MetadataContent, TextContent
26
+
27
+ CardKind = Literal["carousel", "detail", "form", "review", "custom"]
28
+ CARD_KINDS: tuple[str, ...] = ("carousel", "detail", "form", "review", "custom")
29
+ FieldKind = Literal["text", "number", "email", "select", "checkbox"]
30
+ BadgeVariant = Literal["info", "success", "warning"]
31
+
32
+ CARD_PROTOCOL_VERSION = "1"
33
+ # ASI:One rejects payloads over 64 KB; stay under it with headroom.
34
+ MAX_PAYLOAD_BYTES = 60_000
35
+ # Custom card trees may nest at most 8 levels (root = level 1).
36
+ MAX_CUSTOM_DEPTH = 8
37
+
38
+ Selection = dict[str, Any]
39
+ Options = list[dict[str, str]] | list[tuple[str, str]] | list[str]
40
+ Rows = dict[str, str] | list[tuple[str, str]] | list[dict[str, str]]
41
+
42
+
43
+ class CardError(ValueError):
44
+ """Raised when a card payload would be rejected by ASI:One."""
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class Card:
49
+ """A card ready to send. Return it from a handler, or call :meth:`to_message`."""
50
+
51
+ kind: CardKind
52
+ payload: dict[str, Any]
53
+ narration: str = ""
54
+ terminal: bool = False
55
+ drawer_width: int | None = None
56
+
57
+ def to_message(self, *, card_id: str | None = None) -> ChatMessage:
58
+ return card_message(
59
+ self.narration,
60
+ self.kind,
61
+ self.payload,
62
+ terminal=self.terminal,
63
+ drawer_width=self.drawer_width,
64
+ card_id=card_id,
65
+ )
66
+
67
+
68
+ # --------------------------------------------------------------------------- #
69
+ # Small building blocks shared by the card kinds
70
+ # --------------------------------------------------------------------------- #
71
+
72
+
73
+ def selection(action: str, **data: Any) -> Selection:
74
+ """What a button sends back when tapped: ``{"action": action, **data}``."""
75
+ return {"action": action, **data}
76
+
77
+
78
+ def cta(label: str, action: str, *, primary: bool = False, **data: Any) -> dict[str, Any]:
79
+ """A call-to-action button. ``data`` is echoed back with the tap."""
80
+ out: dict[str, Any] = {"label": label, "selection": selection(action, **data)}
81
+ if primary:
82
+ out["primary"] = True
83
+ return out
84
+
85
+
86
+ def badge(label: str, variant: BadgeVariant = "info") -> dict[str, str]:
87
+ return {"label": label, "variant": variant}
88
+
89
+
90
+ def _options(options: Options) -> list[dict[str, str]]:
91
+ out: list[dict[str, str]] = []
92
+ for opt in options:
93
+ if isinstance(opt, dict):
94
+ out.append({"value": str(opt["value"]), "label": str(opt.get("label", opt["value"]))})
95
+ elif isinstance(opt, tuple):
96
+ out.append({"value": str(opt[0]), "label": str(opt[1])})
97
+ else:
98
+ out.append({"value": str(opt), "label": str(opt)})
99
+ return out
100
+
101
+
102
+ def _rows(rows: Rows) -> list[dict[str, str]]:
103
+ if isinstance(rows, dict):
104
+ return [{"label": str(k), "value": str(v)} for k, v in rows.items()]
105
+ out: list[dict[str, str]] = []
106
+ for row in rows:
107
+ if isinstance(row, dict):
108
+ out.append({"label": str(row["label"]), "value": str(row["value"])})
109
+ else:
110
+ out.append({"label": str(row[0]), "value": str(row[1])})
111
+ return out
112
+
113
+
114
+ def _drop_none(d: dict[str, Any]) -> dict[str, Any]:
115
+ return {k: v for k, v in d.items() if v is not None}
116
+
117
+
118
+ # --------------------------------------------------------------------------- #
119
+ # Card kinds
120
+ # --------------------------------------------------------------------------- #
121
+
122
+
123
+ def item(
124
+ id: str,
125
+ title: str,
126
+ *,
127
+ action: str,
128
+ cta_label: str = "Select",
129
+ subtitle: str | None = None,
130
+ image: str | None = None,
131
+ badges: list[dict[str, str]] | None = None,
132
+ secondary_text: str | None = None,
133
+ **data: Any,
134
+ ) -> dict[str, Any]:
135
+ """One carousel item. The tap sends ``{"action": action, "id": id, **data}``."""
136
+ return _drop_none(
137
+ {
138
+ "id": id,
139
+ "title": title,
140
+ "subtitle": subtitle,
141
+ "image": image,
142
+ "badges": badges,
143
+ "secondary_text": secondary_text,
144
+ "primary_cta": cta(cta_label, action, id=id, **data),
145
+ }
146
+ )
147
+
148
+
149
+ def carousel(
150
+ title: str,
151
+ items: list[dict[str, Any]],
152
+ *,
153
+ subtitle: str | None = None,
154
+ narration: str = "",
155
+ drawer_width: int | None = None,
156
+ ) -> Card:
157
+ """A horizontally scrollable list; each item has one primary button."""
158
+ payload = _drop_none({"title": title, "subtitle": subtitle, "items": items})
159
+ return Card("carousel", payload, narration, drawer_width=drawer_width)
160
+
161
+
162
+ def radio(name: str, label: str, choices: Options) -> dict[str, Any]:
163
+ """Radio sub-options for a detail card. The chosen value is sent as ``name``."""
164
+ return {"name": name, "kind": "radio", "label": label, "choices": _options(choices)}
165
+
166
+
167
+ def detail(
168
+ title: str,
169
+ *,
170
+ rows: Rows,
171
+ ctas: list[dict[str, Any]],
172
+ hero_image: str | None = None,
173
+ sub_options: dict[str, Any] | None = None,
174
+ narration: str = "",
175
+ drawer_width: int | None = None,
176
+ ) -> Card:
177
+ """A hero image, key/value rows, optional radio choice, and one or more buttons."""
178
+ payload = _drop_none(
179
+ {
180
+ "title": title,
181
+ "hero_image": hero_image,
182
+ "summary_rows": _rows(rows),
183
+ "sub_options": sub_options,
184
+ "ctas": ctas,
185
+ }
186
+ )
187
+ return Card("detail", payload, narration, drawer_width=drawer_width)
188
+
189
+
190
+ def field(
191
+ name: str,
192
+ label: str,
193
+ kind: FieldKind = "text",
194
+ *,
195
+ required: bool = False,
196
+ options: Options | None = None,
197
+ placeholder: str | None = None,
198
+ ) -> dict[str, Any]:
199
+ """A form field. ``select`` fields need ``options``."""
200
+ out: dict[str, Any] = {"name": name, "kind": kind, "label": label}
201
+ if required:
202
+ out["required"] = True
203
+ if options is not None:
204
+ out["options"] = _options(options)
205
+ if placeholder is not None:
206
+ out["placeholder"] = placeholder
207
+ return out
208
+
209
+
210
+ def form(
211
+ title: str,
212
+ fields: list[dict[str, Any]],
213
+ *,
214
+ submit: dict[str, Any],
215
+ narration: str = "",
216
+ drawer_width: int | None = None,
217
+ ) -> Card:
218
+ """Labeled inputs plus a submit button. All field values come back with the tap."""
219
+ payload = {"title": title, "fields": fields, "submit_cta": submit}
220
+ return Card("form", payload, narration, drawer_width=drawer_width)
221
+
222
+
223
+ def review(
224
+ title: str,
225
+ *,
226
+ rows: Rows,
227
+ approve: dict[str, Any],
228
+ reject: dict[str, Any],
229
+ narration: str = "",
230
+ drawer_width: int | None = None,
231
+ ) -> Card:
232
+ """Summary rows plus approve/reject. Use before anything irreversible (payments, bookings)."""
233
+ payload = {
234
+ "title": title,
235
+ "summary_rows": _rows(rows),
236
+ "approve_cta": approve,
237
+ "reject_cta": reject,
238
+ }
239
+ return Card("review", payload, narration, drawer_width=drawer_width)
240
+
241
+
242
+ def custom(root: dict[str, Any], *, narration: str = "", drawer_width: int | None = None) -> Card:
243
+ """A free-form layout built from :class:`ui` primitives."""
244
+ return Card("custom", {"root": root}, narration, drawer_width=drawer_width)
245
+
246
+
247
+ def info(title: str, *, rows: Rows, narration: str = "") -> Card:
248
+ """A terminal (read-only) detail card that expects no input."""
249
+ payload = {"title": title, "summary_rows": _rows(rows), "ctas": []}
250
+ return Card("detail", payload, narration, terminal=True)
251
+
252
+
253
+ class ui:
254
+ """Primitives for :func:`custom` cards, e.g. ``ui.section("Hi", [ui.text("...")])``."""
255
+
256
+ @staticmethod
257
+ def section(
258
+ title: str | None, children: list[dict[str, Any]], *, subtitle: str | None = None
259
+ ) -> dict[str, Any]:
260
+ return _drop_none(
261
+ {"type": "section", "title": title, "subtitle": subtitle, "children": children}
262
+ )
263
+
264
+ @staticmethod
265
+ def group(
266
+ children: list[dict[str, Any]],
267
+ *,
268
+ direction: Literal["row", "column"] = "row",
269
+ gap: int | None = None,
270
+ ) -> dict[str, Any]:
271
+ return _drop_none(
272
+ {"type": "group", "direction": direction, "gap": gap, "children": children}
273
+ )
274
+
275
+ @staticmethod
276
+ def divider() -> dict[str, Any]:
277
+ return {"type": "divider"}
278
+
279
+ @staticmethod
280
+ def text(
281
+ value: str, style: Literal["body", "muted", "emphasis"] | None = None
282
+ ) -> dict[str, Any]:
283
+ return _drop_none({"type": "text", "value": value, "style": style})
284
+
285
+ @staticmethod
286
+ def heading(value: str, level: Literal[1, 2, 3] | None = None) -> dict[str, Any]:
287
+ return _drop_none({"type": "heading", "value": value, "level": level})
288
+
289
+ @staticmethod
290
+ def image(src: str, alt: str | None = None, aspect_ratio: str | None = None) -> dict[str, Any]:
291
+ return _drop_none({"type": "image", "src": src, "alt": alt, "aspect_ratio": aspect_ratio})
292
+
293
+ @staticmethod
294
+ def badge(label: str, variant: BadgeVariant | None = None) -> dict[str, Any]:
295
+ return _drop_none({"type": "badge", "label": label, "variant": variant})
296
+
297
+ @staticmethod
298
+ def button(label: str, action: str, *, primary: bool = False, **data: Any) -> dict[str, Any]:
299
+ out: dict[str, Any] = {
300
+ "type": "button",
301
+ "label": label,
302
+ "action": {"selection": selection(action, **data)},
303
+ }
304
+ if primary:
305
+ out["primary"] = True
306
+ return out
307
+
308
+ @staticmethod
309
+ def input(
310
+ name: str,
311
+ label: str,
312
+ kind: FieldKind = "text",
313
+ *,
314
+ required: bool = False,
315
+ options: Options | None = None,
316
+ placeholder: str | None = None,
317
+ ) -> dict[str, Any]:
318
+ return {
319
+ "type": "input",
320
+ **field(name, label, kind, required=required, options=options, placeholder=placeholder),
321
+ }
322
+
323
+ @staticmethod
324
+ def list_items(items: list[dict[str, Any]]) -> dict[str, Any]:
325
+ """``items``: dicts from :meth:`list_item`."""
326
+ return {"type": "list", "items": items}
327
+
328
+ @staticmethod
329
+ def list_item(
330
+ children: list[dict[str, Any]], action: str | None = None, **data: Any
331
+ ) -> dict[str, Any]:
332
+ out: dict[str, Any] = {"children": children}
333
+ if action:
334
+ out["action"] = {"selection": selection(action, **data)}
335
+ return out
336
+
337
+ @staticmethod
338
+ def choice_grid(
339
+ name: str, choices: list[dict[str, str]], *, multi: bool = False
340
+ ) -> dict[str, Any]:
341
+ """``choices``: ``[{"value": ..., "label": ..., "image": optional}]``."""
342
+ out: dict[str, Any] = {"type": "choice_grid", "name": name, "choices": choices}
343
+ if multi:
344
+ out["multi"] = True
345
+ return out
346
+
347
+
348
+ # --------------------------------------------------------------------------- #
349
+ # Validation + message assembly
350
+ # --------------------------------------------------------------------------- #
351
+
352
+
353
+ def _depth(node: Any) -> int:
354
+ if isinstance(node, dict):
355
+ kids = node.get("children") or []
356
+ items = node.get("items") if node.get("type") == "list" else None
357
+ nested = list(kids) + [c for it in (items or []) for c in it.get("children", [])]
358
+ return 1 + max((_depth(c) for c in nested), default=0)
359
+ return 0
360
+
361
+
362
+ def _ctas_ok(ctas: Any) -> bool:
363
+ return isinstance(ctas, dict) and isinstance(ctas.get("selection"), dict) and "label" in ctas
364
+
365
+
366
+ def validate(kind: str, payload: dict[str, Any]) -> list[str]:
367
+ """Return the problems ASI:One would reject this card for (empty list = valid)."""
368
+ errors: list[str] = []
369
+ if kind not in CARD_KINDS:
370
+ return [f"unknown card kind {kind!r}; use one of {', '.join(CARD_KINDS)}"]
371
+ try:
372
+ size = len(json.dumps(payload, ensure_ascii=False).encode("utf-8"))
373
+ except (TypeError, ValueError) as exc:
374
+ return [f"payload is not JSON-serialisable: {exc}"]
375
+ if size > MAX_PAYLOAD_BYTES:
376
+ errors.append(f"payload is {size} bytes; keep it under {MAX_PAYLOAD_BYTES}")
377
+
378
+ if kind == "carousel":
379
+ items = payload.get("items")
380
+ if not isinstance(items, list) or not items:
381
+ errors.append("carousel needs a non-empty 'items' list")
382
+ else:
383
+ for i, it in enumerate(items):
384
+ if not it.get("title"):
385
+ errors.append(f"items[{i}] needs a 'title'")
386
+ if not _ctas_ok(it.get("primary_cta")):
387
+ errors.append(f"items[{i}] needs a 'primary_cta' with label + selection")
388
+ elif kind == "detail":
389
+ if not isinstance(payload.get("ctas"), list):
390
+ errors.append("detail needs a 'ctas' list")
391
+ for i, c in enumerate(payload.get("ctas") or []):
392
+ if not _ctas_ok(c):
393
+ errors.append(f"ctas[{i}] needs label + selection")
394
+ elif kind == "form":
395
+ fields = payload.get("fields")
396
+ if not isinstance(fields, list) or not fields:
397
+ errors.append("form needs a non-empty 'fields' list")
398
+ for f in fields or []:
399
+ if f.get("kind") == "select" and not f.get("options"):
400
+ errors.append(f"select field {f.get('name')!r} needs non-empty 'options'")
401
+ if not _ctas_ok(payload.get("submit_cta")):
402
+ errors.append("form needs a 'submit_cta' with label + selection")
403
+ elif kind == "review":
404
+ for key in ("approve_cta", "reject_cta"):
405
+ if not _ctas_ok(payload.get(key)):
406
+ errors.append(f"review needs '{key}' with label + selection")
407
+ elif kind == "custom":
408
+ root = payload.get("root")
409
+ if not isinstance(root, dict) or "type" not in root:
410
+ errors.append("custom card needs a 'root' node with a 'type'")
411
+ elif (d := _depth(root)) > MAX_CUSTOM_DEPTH:
412
+ errors.append(f"custom tree is {d} levels deep; max is {MAX_CUSTOM_DEPTH}")
413
+ return errors
414
+
415
+
416
+ def _stamp_card_id(node: Any, card_id: str) -> None:
417
+ if isinstance(node, dict):
418
+ sel = node.get("selection")
419
+ if isinstance(sel, dict):
420
+ sel["card_id"] = card_id
421
+ for value in node.values():
422
+ _stamp_card_id(value, card_id)
423
+ elif isinstance(node, list):
424
+ for value in node:
425
+ _stamp_card_id(value, card_id)
426
+
427
+
428
+ def card_message(
429
+ narration: str,
430
+ kind: str,
431
+ payload: dict[str, Any],
432
+ *,
433
+ terminal: bool = False,
434
+ drawer_width: int | None = None,
435
+ card_id: str | None = None,
436
+ ) -> ChatMessage:
437
+ """Build the ``ChatMessage`` for a card. Raises :class:`CardError` if invalid.
438
+
439
+ ``card_id`` is stamped into every selection so you can ignore taps on an
440
+ older card (``parse_selection(...)["card_id"]``).
441
+ """
442
+ errors = validate(kind, payload)
443
+ if errors:
444
+ raise CardError(f"invalid {kind} card: " + "; ".join(errors))
445
+ if card_id:
446
+ payload = copy.deepcopy(payload)
447
+ _stamp_card_id(payload, card_id)
448
+ metadata = {
449
+ "card_protocol_version": CARD_PROTOCOL_VERSION,
450
+ "requires_card_interaction": "true",
451
+ "card_kind": kind,
452
+ "card_payload": json.dumps(payload, ensure_ascii=False),
453
+ }
454
+ if terminal:
455
+ metadata["is_terminal"] = "true"
456
+ if drawer_width:
457
+ metadata["preferred_drawer_width_px"] = str(max(320, min(800, int(drawer_width))))
458
+ if card_id:
459
+ metadata["card_id"] = card_id
460
+ content: list[Any] = []
461
+ if narration:
462
+ content.append(TextContent(type="text", text=narration))
463
+ content.append(MetadataContent(type="metadata", metadata=metadata))
464
+ return ChatMessage(timestamp=datetime.now(UTC), msg_id=uuid4(), content=content)
465
+
466
+
467
+ # --------------------------------------------------------------------------- #
468
+ # Reading taps back
469
+ # --------------------------------------------------------------------------- #
470
+
471
+ _MENTION_PREFIX = re.compile(r"^(?:@\S+\s+)+")
472
+
473
+
474
+ def strip_mention(text: str) -> str:
475
+ """Remove leading ``@handle`` mentions (``"@my-agent hi"`` -> ``"hi"``)."""
476
+ return _MENTION_PREFIX.sub("", (text or "").strip()).strip()
477
+
478
+
479
+ def collect_actions(payload: dict[str, Any]) -> set[str]:
480
+ """Every ``action`` a card can send back. Useful as ``known_actions``."""
481
+ found: set[str] = set()
482
+
483
+ def walk(node: Any) -> None:
484
+ if isinstance(node, dict):
485
+ sel = node.get("selection")
486
+ if isinstance(sel, dict) and sel.get("action"):
487
+ found.add(str(sel["action"]))
488
+ for value in node.values():
489
+ walk(value)
490
+ elif isinstance(node, list):
491
+ for value in node:
492
+ walk(value)
493
+
494
+ walk(payload)
495
+ return found
496
+
497
+
498
+ def parse_selection(text: str, known_actions: set[str] | None = None) -> Selection:
499
+ """Recover a card tap from an inbound message; ``{}`` if it is ordinary text.
500
+
501
+ 1. Direct @mention: the selection arrives as JSON -> returned as a dict
502
+ (a nested ``"selection"`` object is flattened to the top level).
503
+ 2. Planner-routed: the selection arrives as prose that contains every
504
+ identifier from the selection -> we look for a known action token and
505
+ return ``{"action": ..., "prose": text}``.
506
+ """
507
+ raw = (text or "").strip()
508
+ if not raw:
509
+ return {}
510
+ for candidate in (strip_mention(raw), raw):
511
+ try:
512
+ data = json.loads(candidate)
513
+ except (TypeError, ValueError):
514
+ continue
515
+ if isinstance(data, dict):
516
+ nested = data.get("selection")
517
+ if not data.get("action") and isinstance(nested, dict):
518
+ data = {**{k: v for k, v in data.items() if k != "selection"}, **nested}
519
+ return data
520
+ if known_actions:
521
+ lowered = raw.lower()
522
+ # Longest first so "book_confirm" wins over "book".
523
+ for action in sorted(known_actions, key=len, reverse=True):
524
+ if action and re.search(rf"(?<![\w-]){re.escape(action.lower())}(?![\w-])", lowered):
525
+ return {"action": action, "prose": raw}
526
+ return {}
fetch_hackkit/cli.py ADDED
@@ -0,0 +1,90 @@
1
+ """``hackkit`` command line: seed, whoami, handle, doctor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import secrets
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from dotenv import find_dotenv, load_dotenv
12
+
13
+
14
+ def _seed(_: argparse.Namespace) -> int:
15
+ from .doctor import identity_from_seed
16
+
17
+ seed = secrets.token_urlsafe(32)
18
+ address, wallet = identity_from_seed(seed)
19
+ print(f"AGENT_SEED_PHRASE={seed}\n")
20
+ print(f"# agent address: {address}\n# FET wallet: {wallet}")
21
+ print("\nPut the first line in .env. Never commit it: the seed *is* your agent.")
22
+ return 0
23
+
24
+
25
+ def _whoami(_: argparse.Namespace) -> int:
26
+ from .doctor import identity_from_seed
27
+
28
+ seed = os.getenv("AGENT_SEED_PHRASE")
29
+ if not seed:
30
+ print("AGENT_SEED_PHRASE is not set (try `hackkit seed`)", file=sys.stderr)
31
+ return 1
32
+ address, wallet = identity_from_seed(seed)
33
+ print(f"agent address: {address}\nFET wallet: {wallet}")
34
+ print(f"profile: https://agentverse.ai/agents/details/{address}/profile")
35
+ return 0
36
+
37
+
38
+ def _handle(args: argparse.Namespace) -> int:
39
+ from .register import resolve, validate_handle
40
+
41
+ try:
42
+ handle = validate_handle(args.handle)
43
+ except ValueError as exc:
44
+ print(f"❌ {exc}")
45
+ return 1
46
+ record = resolve(handle)
47
+ if record is None:
48
+ print(f"✅ @{handle} is available")
49
+ return 0
50
+ print(f"❌ @{handle} is taken by {record.get('address')}")
51
+ return 1
52
+
53
+
54
+ def _doctor(args: argparse.Namespace) -> int:
55
+ from .doctor import run
56
+
57
+ handle = args.handle or os.getenv("AGENT_HANDLE")
58
+ checks = run(readme=Path(args.readme), handle=handle)
59
+ for c in checks:
60
+ print(f"{c.icon} {c.name}: {c.detail}")
61
+ if c.fix and c.status != "ok":
62
+ print(f" fix: {c.fix}")
63
+ failed = sum(c.status == "fail" for c in checks)
64
+ print(f"\n{'All good. Ship it!' if not failed else f'{failed} check(s) failed.'}")
65
+ return 1 if failed else 0
66
+
67
+
68
+ def main(argv: list[str] | None = None) -> int:
69
+ load_dotenv(find_dotenv(usecwd=True))
70
+ parser = argparse.ArgumentParser(prog="hackkit", description="Fetch.ai hackathon helpers")
71
+ sub = parser.add_subparsers(dest="cmd", required=True)
72
+ sub.add_parser("seed", help="generate a secure seed phrase + show its address").set_defaults(
73
+ fn=_seed
74
+ )
75
+ sub.add_parser(
76
+ "whoami", help="show the agent address + wallet for AGENT_SEED_PHRASE"
77
+ ).set_defaults(fn=_whoami)
78
+ p = sub.add_parser("handle", help="check if an Agentverse @handle is free")
79
+ p.add_argument("handle")
80
+ p.set_defaults(fn=_handle)
81
+ p = sub.add_parser("doctor", help="check your agent against the submission requirements")
82
+ p.add_argument("--readme", default="README.md", help="README to check (default README.md)")
83
+ p.add_argument("--handle", help="expected @handle (default $AGENT_HANDLE)")
84
+ p.set_defaults(fn=_doctor)
85
+ args = parser.parse_args(argv)
86
+ return int(args.fn(args))
87
+
88
+
89
+ if __name__ == "__main__":
90
+ raise SystemExit(main())