fetch-hackkit 0.1.0__tar.gz → 0.2.0__tar.gz

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 (38) hide show
  1. {fetch_hackkit-0.1.0 → fetch_hackkit-0.2.0}/PKG-INFO +10 -1
  2. {fetch_hackkit-0.1.0 → fetch_hackkit-0.2.0}/README.md +9 -0
  3. {fetch_hackkit-0.1.0 → fetch_hackkit-0.2.0}/pyproject.toml +1 -1
  4. {fetch_hackkit-0.1.0 → fetch_hackkit-0.2.0}/src/fetch_hackkit/__init__.py +1 -1
  5. {fetch_hackkit-0.1.0 → fetch_hackkit-0.2.0}/src/fetch_hackkit/cli.py +38 -1
  6. fetch_hackkit-0.2.0/src/fetch_hackkit/scaffold.py +122 -0
  7. fetch_hackkit-0.2.0/src/fetch_hackkit/templates/agents_reference.md +237 -0
  8. fetch_hackkit-0.2.0/src/fetch_hackkit/templates/project/AGENTS.md.tmpl +33 -0
  9. fetch_hackkit-0.2.0/src/fetch_hackkit/templates/project/Dockerfile.tmpl +13 -0
  10. fetch_hackkit-0.2.0/src/fetch_hackkit/templates/project/README.md.tmpl +29 -0
  11. fetch_hackkit-0.2.0/src/fetch_hackkit/templates/project/agent.py.tmpl +102 -0
  12. fetch_hackkit-0.2.0/src/fetch_hackkit/templates/project/cursor_mcp.json.tmpl +10 -0
  13. fetch_hackkit-0.2.0/src/fetch_hackkit/templates/project/dockerignore.tmpl +7 -0
  14. fetch_hackkit-0.2.0/src/fetch_hackkit/templates/project/env.example.tmpl +17 -0
  15. fetch_hackkit-0.2.0/src/fetch_hackkit/templates/project/gitignore.tmpl +9 -0
  16. fetch_hackkit-0.2.0/src/fetch_hackkit/templates/project/mcp.json.tmpl +11 -0
  17. fetch_hackkit-0.2.0/src/fetch_hackkit/templates/project/opencode.json.tmpl +83 -0
  18. fetch_hackkit-0.2.0/src/fetch_hackkit/templates/project/pyproject.toml.tmpl +21 -0
  19. fetch_hackkit-0.2.0/src/fetch_hackkit/templates/project/python-version.tmpl +1 -0
  20. fetch_hackkit-0.2.0/src/fetch_hackkit/templates/project/tests/test_agent.py.tmpl +23 -0
  21. fetch_hackkit-0.2.0/tests/test_scaffold.py +95 -0
  22. {fetch_hackkit-0.1.0 → fetch_hackkit-0.2.0}/.gitignore +0 -0
  23. {fetch_hackkit-0.1.0 → fetch_hackkit-0.2.0}/LICENSE +0 -0
  24. {fetch_hackkit-0.1.0 → fetch_hackkit-0.2.0}/src/fetch_hackkit/agent.py +0 -0
  25. {fetch_hackkit-0.1.0 → fetch_hackkit-0.2.0}/src/fetch_hackkit/asi1.py +0 -0
  26. {fetch_hackkit-0.1.0 → fetch_hackkit-0.2.0}/src/fetch_hackkit/cards.py +0 -0
  27. {fetch_hackkit-0.1.0 → fetch_hackkit-0.2.0}/src/fetch_hackkit/doctor.py +0 -0
  28. {fetch_hackkit-0.1.0 → fetch_hackkit-0.2.0}/src/fetch_hackkit/mcp.py +0 -0
  29. {fetch_hackkit-0.1.0 → fetch_hackkit-0.2.0}/src/fetch_hackkit/payments.py +0 -0
  30. {fetch_hackkit-0.1.0 → fetch_hackkit-0.2.0}/src/fetch_hackkit/peers.py +0 -0
  31. {fetch_hackkit-0.1.0 → fetch_hackkit-0.2.0}/src/fetch_hackkit/py.typed +0 -0
  32. {fetch_hackkit-0.1.0 → fetch_hackkit-0.2.0}/src/fetch_hackkit/register.py +0 -0
  33. {fetch_hackkit-0.1.0 → fetch_hackkit-0.2.0}/tests/conftest.py +0 -0
  34. {fetch_hackkit-0.1.0 → fetch_hackkit-0.2.0}/tests/test_agent.py +0 -0
  35. {fetch_hackkit-0.1.0 → fetch_hackkit-0.2.0}/tests/test_asi1.py +0 -0
  36. {fetch_hackkit-0.1.0 → fetch_hackkit-0.2.0}/tests/test_cards.py +0 -0
  37. {fetch_hackkit-0.1.0 → fetch_hackkit-0.2.0}/tests/test_payments.py +0 -0
  38. {fetch_hackkit-0.1.0 → fetch_hackkit-0.2.0}/tests/test_register_doctor_mcp.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: fetch-hackkit
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Batteries for Fetch.ai hackathon agents: chat protocol, ASI:One interactive cards, ASI:One LLM + tools, payments, MCP, and Agentverse registration.
5
5
  Project-URL: Homepage, https://github.com/adylagad/fetch-hackathon-starter
6
6
  Project-URL: Documentation, https://github.com/adylagad/fetch-hackathon-starter/blob/main/AGENTS.md
@@ -34,6 +34,14 @@ Batteries for [Fetch.ai](https://fetch.ai) hackathon agents. Build a uAgent that
34
34
  - **Multi-agent**: `ask_agent(ctx, "@handle", "question")`.
35
35
  - **Agentverse**: mailbox auto-connect with name, `@handle`, description and README, plus the `hackkit doctor` pre-submission checks.
36
36
 
37
+ Start a new project in one command (fresh seed, a free `@handle`, README, tests, Dockerfile, AGENTS.md and coding-agent configs):
38
+
39
+ ```bash
40
+ uvx --from "fetch-hackkit[mcp]" hackkit new "Trip Planner"
41
+ ```
42
+
43
+ Or add it to an existing project:
44
+
37
45
  ```bash
38
46
  pip install fetch-hackkit # or: pip install "fetch-hackkit[mcp]"
39
47
  ```
@@ -63,6 +71,7 @@ Environment (`.env` is loaded automatically):
63
71
  ## CLI
64
72
 
65
73
  ```
74
+ hackkit new NAME create a ready-to-run agent project
66
75
  hackkit seed generate a secure seed phrase (+ its address and wallet)
67
76
  hackkit whoami address, wallet and Agentverse profile URL for your seed
68
77
  hackkit handle NAME is @NAME free on Agentverse?
@@ -10,6 +10,14 @@ Batteries for [Fetch.ai](https://fetch.ai) hackathon agents. Build a uAgent that
10
10
  - **Multi-agent**: `ask_agent(ctx, "@handle", "question")`.
11
11
  - **Agentverse**: mailbox auto-connect with name, `@handle`, description and README, plus the `hackkit doctor` pre-submission checks.
12
12
 
13
+ Start a new project in one command (fresh seed, a free `@handle`, README, tests, Dockerfile, AGENTS.md and coding-agent configs):
14
+
15
+ ```bash
16
+ uvx --from "fetch-hackkit[mcp]" hackkit new "Trip Planner"
17
+ ```
18
+
19
+ Or add it to an existing project:
20
+
13
21
  ```bash
14
22
  pip install fetch-hackkit # or: pip install "fetch-hackkit[mcp]"
15
23
  ```
@@ -39,6 +47,7 @@ Environment (`.env` is loaded automatically):
39
47
  ## CLI
40
48
 
41
49
  ```
50
+ hackkit new NAME create a ready-to-run agent project
42
51
  hackkit seed generate a secure seed phrase (+ its address and wallet)
43
52
  hackkit whoami address, wallet and Agentverse profile URL for your seed
44
53
  hackkit handle NAME is @NAME free on Agentverse?
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "fetch-hackkit"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Batteries for Fetch.ai hackathon agents: chat protocol, ASI:One interactive cards, ASI:One LLM + tools, payments, MCP, and Agentverse registration."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -9,7 +9,7 @@ from .asi1 import ASI1, ASI1Error, Tool, history, remember, tool
9
9
  from .cards import Card, CardError, parse_selection
10
10
  from .register import connect_mailbox, handle_available, resolve, validate_handle
11
11
 
12
- __version__ = "0.1.0"
12
+ __version__ = "0.2.0"
13
13
 
14
14
  __all__ = [
15
15
  "ASI1",
@@ -1,4 +1,4 @@
1
- """``hackkit`` command line: seed, whoami, handle, doctor."""
1
+ """``hackkit`` command line: new, seed, whoami, handle, doctor."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -51,6 +51,37 @@ def _handle(args: argparse.Namespace) -> int:
51
51
  return 1
52
52
 
53
53
 
54
+ def _new(args: argparse.Namespace) -> int:
55
+ from .scaffold import create_project
56
+
57
+ try:
58
+ project = create_project(
59
+ args.name,
60
+ directory=Path(args.dir) if args.dir else None,
61
+ handle=args.handle,
62
+ check_handle=not args.no_check,
63
+ )
64
+ except (FileExistsError, ValueError) as exc:
65
+ print(f"❌ {exc}")
66
+ return 1
67
+ try:
68
+ where = project.path.relative_to(Path.cwd())
69
+ except ValueError:
70
+ where = project.path
71
+ print(f"✅ Created {project.name} in {where}/")
72
+ print(f" handle: @{project.handle} ({project.handle_note})")
73
+ print(f" address: {project.address}")
74
+ print(" identity: a fresh seed is in .env (never commit it)\n")
75
+ print("Next:")
76
+ print(f" cd {where}")
77
+ print(" # add ASI_ONE_API_KEY and AGENTVERSE_API_KEY to .env")
78
+ print(" uv sync")
79
+ print(" uv run python agent.py")
80
+ print(f" # then on https://asi1.ai say: @{project.handle} hi")
81
+ print("\nCoding agent: run `opencode` here (ASI:One + Agentverse MCP), or Claude Code/Cursor.")
82
+ return 0
83
+
84
+
54
85
  def _doctor(args: argparse.Namespace) -> int:
55
86
  from .doctor import run
56
87
 
@@ -69,6 +100,12 @@ def main(argv: list[str] | None = None) -> int:
69
100
  load_dotenv(find_dotenv(usecwd=True))
70
101
  parser = argparse.ArgumentParser(prog="hackkit", description="Fetch.ai hackathon helpers")
71
102
  sub = parser.add_subparsers(dest="cmd", required=True)
103
+ p = sub.add_parser("new", help="create a ready-to-run agent project")
104
+ p.add_argument("name", help='agent name, e.g. "Trip Planner"')
105
+ p.add_argument("--dir", help="where to create it (default: ./<name-slug>)")
106
+ p.add_argument("--handle", help="Agentverse @handle (default: from the name)")
107
+ p.add_argument("--no-check", action="store_true", help="don't check handle availability")
108
+ p.set_defaults(fn=_new)
72
109
  sub.add_parser("seed", help="generate a secure seed phrase + show its address").set_defaults(
73
110
  fn=_seed
74
111
  )
@@ -0,0 +1,122 @@
1
+ """``hackkit new``: generate a minimal, ready-to-run agent project.
2
+
3
+ The project gets an agent with a menu card and an ASI:One tool, a README in
4
+ Agentverse profile shape, a fresh seed in ``.env``, tests, a Dockerfile, and
5
+ AGENTS.md plus opencode/Claude Code/Cursor configs for coding agents.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import re
12
+ import secrets
13
+ from dataclasses import dataclass
14
+ from importlib.resources import files
15
+ from pathlib import Path
16
+
17
+ from .register import HANDLE_MAX_LEN, resolve, validate_handle
18
+
19
+ # template file (under templates/project) -> path in the new project
20
+ FILES = {
21
+ "agent.py.tmpl": "agent.py",
22
+ "README.md.tmpl": "README.md",
23
+ "pyproject.toml.tmpl": "pyproject.toml",
24
+ "python-version.tmpl": ".python-version",
25
+ "env.example.tmpl": ".env.example",
26
+ "gitignore.tmpl": ".gitignore",
27
+ "dockerignore.tmpl": ".dockerignore",
28
+ "Dockerfile.tmpl": "Dockerfile",
29
+ "tests/test_agent.py.tmpl": "tests/test_agent.py",
30
+ "opencode.json.tmpl": "opencode.json",
31
+ "mcp.json.tmpl": ".mcp.json",
32
+ "cursor_mcp.json.tmpl": ".cursor/mcp.json",
33
+ }
34
+
35
+
36
+ @dataclass
37
+ class Project:
38
+ path: Path
39
+ name: str
40
+ handle: str
41
+ address: str
42
+ handle_note: str = ""
43
+
44
+
45
+ def slugify(name: str) -> str:
46
+ slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
47
+ return slug or "my-agent"
48
+
49
+
50
+ def _template(rel: str) -> str:
51
+ return (files("fetch_hackkit") / "templates" / rel).read_text(encoding="utf-8")
52
+
53
+
54
+ def _choose_handle(wanted: str, check: bool) -> tuple[str, str]:
55
+ """Return (handle, note). Picks a free variant if ``wanted`` is taken."""
56
+ handle = validate_handle(wanted[:HANDLE_MAX_LEN].strip("-") or "my-agent")
57
+ if not check:
58
+ return handle, "not checked (--no-check)"
59
+ try:
60
+ if resolve(handle) is None:
61
+ return handle, "available"
62
+ for _ in range(5):
63
+ candidate = validate_handle(f"{handle[: HANDLE_MAX_LEN - 5]}-{secrets.token_hex(2)}")
64
+ if resolve(candidate) is None:
65
+ return candidate, f"@{handle} was taken"
66
+ except Exception as exc: # offline etc.: keep the wanted handle
67
+ return handle, f"could not check availability ({type(exc).__name__})"
68
+ return handle, f"@{handle} is taken; pick another with `hackkit handle`"
69
+
70
+
71
+ def create_project(
72
+ name: str,
73
+ *,
74
+ directory: Path | None = None,
75
+ handle: str | None = None,
76
+ check_handle: bool = True,
77
+ ) -> Project:
78
+ """Write a new agent project and return where it is and who it is."""
79
+ from . import __version__
80
+ from .doctor import identity_from_seed
81
+
82
+ path = (directory or Path.cwd() / slugify(name)).resolve()
83
+ if path.exists() and any(path.iterdir()):
84
+ raise FileExistsError(f"{path} already exists and is not empty")
85
+
86
+ chosen, note = _choose_handle(
87
+ handle.removeprefix("@") if handle else slugify(name), check_handle
88
+ )
89
+ seed = secrets.token_urlsafe(32)
90
+ address, _wallet = identity_from_seed(seed)
91
+ values = {
92
+ "__AGENT_NAME__": name,
93
+ "__AGENT_HANDLE__": chosen,
94
+ "__AGENT_ADDRESS__": address,
95
+ "__PROJECT_SLUG__": slugify(name),
96
+ "__HACKKIT_VERSION__": __version__,
97
+ }
98
+
99
+ def render(text: str) -> str:
100
+ for key, value in values.items():
101
+ text = text.replace(key, value)
102
+ return text
103
+
104
+ for src, dest in FILES.items():
105
+ target = path / dest
106
+ target.parent.mkdir(parents=True, exist_ok=True)
107
+ target.write_text(render(_template(f"project/{src}")), encoding="utf-8")
108
+
109
+ agents_md = render(_template("project/AGENTS.md.tmpl")) + _template("agents_reference.md")
110
+ (path / "AGENTS.md").write_text(agents_md, encoding="utf-8")
111
+ try:
112
+ os.symlink("AGENTS.md", path / "CLAUDE.md")
113
+ except OSError: # e.g. Windows without symlink rights
114
+ (path / "CLAUDE.md").write_text(agents_md, encoding="utf-8")
115
+
116
+ env = render(_template("project/env.example.tmpl")).replace(
117
+ "AGENT_SEED_PHRASE=\n", f"AGENT_SEED_PHRASE={seed}\n", 1
118
+ )
119
+ env_path = path / ".env"
120
+ env_path.write_text(env, encoding="utf-8")
121
+ env_path.chmod(0o600)
122
+ return Project(path=path, name=name, handle=chosen, address=address, handle_note=note)
@@ -0,0 +1,237 @@
1
+ ## Golden rules
2
+
3
+ 1. **Use `HackAgent`, not a bare `uagents.Agent`.** It includes the chat protocol, publishes its manifest, sends acknowledgements, and turns handler exceptions into a friendly reply.
4
+ 2. **Handlers return replies; they don't call `ctx.send` for chat.** Return a `str`, a `cards.Card`, a `ChatMessage`, or a list of these.
5
+ 3. **Never block the event loop.** Use `httpx.AsyncClient` / `async` SDKs. Wrap blocking calls in `await asyncio.to_thread(fn, ...)`.
6
+ 4. **Secrets live in `.env`** (gitignored): `AGENT_SEED_PHRASE`, `ASI_ONE_API_KEY`, `AGENTVERSE_API_KEY`.
7
+ - Never hard-code them. Never print the seed.
8
+ - The seed *is* the agent: the same seed always gives the same address and wallet.
9
+ 5. **Keep per-conversation state in `ctx.storage`, keyed by `msg.session`.** ASI:One sends your agent one message at a time.
10
+ 6. **Every card has a narration.** It's the fallback text if a card fails to render.
11
+ 7. **Run `uv run pytest -q` and `uv run ruff check .` after changes.**
12
+
13
+ ## The agent pattern
14
+
15
+ ```python
16
+ from fetch_hackkit import HackAgent, cards
17
+
18
+ agent = HackAgent(
19
+ name="Trip Planner", # display name on Agentverse
20
+ handle="trip-planner", # @handle: unique, <= 40 chars
21
+ description="Plans weekend trips with live prices.", # short description
22
+ readme_path="README.md", # long description (ASI:One routing reads it)
23
+ )
24
+
25
+ @agent.on_text # free text (leading @mentions already stripped)
26
+ async def on_text(ctx, msg): # msg: Inbound(sender, text, session, selection, raw)
27
+ return cards.carousel("Pick a city", [
28
+ cards.item("sf", "San Francisco", action="pick_city"),
29
+ cards.item("la", "Los Angeles", action="pick_city"),
30
+ ], narration="Where to?")
31
+
32
+ @agent.on_action("pick_city") # card taps, routed by selection["action"]
33
+ async def pick_city(ctx, msg, sel): # sel == {"action": "pick_city", "id": "sf"}
34
+ return f"Planning a trip to {sel['id']}..."
35
+
36
+ if __name__ == "__main__":
37
+ agent.run()
38
+ ```
39
+
40
+ `HackAgent` subclasses `uagents.Agent`, so `@agent.on_interval(period=60)`, `@agent.on_event("startup")`, `agent.include(protocol)` and `ctx.storage` all work.
41
+
42
+ ## Interactive cards (ASI:One UI)
43
+
44
+ A card is a chat message with narration text plus a `MetadataContent` declaration. ASI:One renders it as UI. When the user taps a button, the button's **selection** comes back to your agent as a message.
45
+
46
+ ### Pick the right card
47
+
48
+ | Card | Use for | Builder |
49
+ |---|---|---|
50
+ | `carousel` | Menus, search results, options (one button per item) | `cards.carousel(title, [cards.item(id, title, action=...)])` |
51
+ | `detail` | One thing in depth: image, key/value rows, radio choice, several buttons | `cards.detail(title, rows={...}, ctas=[cards.cta(...)], sub_options=cards.radio(...))` |
52
+ | `form` | Collect input (`text`, `number`, `email`, `select`, `checkbox`) | `cards.form(title, [cards.field(...)], submit=cards.cta(...))` |
53
+ | `review` | Confirm before anything irreversible (payment, booking, sending) | `cards.review(title, rows=..., approve=cards.cta(...), reject=cards.cta(...))` |
54
+ | `custom` | Anything else, built from primitives | `cards.custom(ui.section(title, [ui.text(...), ui.button(...)]))` |
55
+ | read-only | Show a result that needs no input | `cards.info(title, rows=...)` |
56
+
57
+ Custom primitives live in `fetch_hackkit.cards.ui`:
58
+ - **Layout:** `section`, `group(direction="row"|"column")`, `divider`.
59
+ - **Content:** `text(style=)`, `heading(level=)`, `image`, `badge(variant="info"|"success"|"warning")`.
60
+ - **Interactive:** `button`, `input`, `list_items` + `list_item`, `choice_grid` (image picker).
61
+
62
+ ### Selections: what comes back
63
+
64
+ Every button built with `cards.cta(label, action, **data)`, `cards.item(..., action=...)` or `ui.button(label, action, **data)` sends `{"action": action, **data}`. The values that come back depend on the card:
65
+
66
+ | Card | What arrives |
67
+ |---|---|
68
+ | carousel item | `{"action", "id", ...data}` |
69
+ | detail | the button's selection, plus the radio value under `sub_options.name` |
70
+ | form | the submit button's selection, plus **every field value** keyed by field name |
71
+ | review | the approve or the reject selection |
72
+ | custom | the button's selection, plus every `input` / `choice_grid` value keyed by `name` |
73
+
74
+ It arrives in one of two formats, and `HackAgent` handles both:
75
+ - **Direct @mention:** JSON. `sel` is the full dict.
76
+ - **Routed by the ASI:One planner:** a prose sentence containing the selection's identifiers. `HackAgent` finds the action word and gives you `{"action": ..., "prose": "<sentence>"}`. Field values may only be inside `msg.text`.
77
+
78
+ Design rules that follow:
79
+ - **Make `action` names distinctive tokens** (`book_hotel`, not `ok`) so they can be found in prose.
80
+ - **For important values, check `"prose" in sel`** and either recover them from `msg.text` or ask again.
81
+ - **Unique ids in `data`** (e.g. `id="trip_42"`) also appear in the prose.
82
+ - **One active card:** pass `card_id=` to `Card.to_message(card_id=...)` (or `cards.card_message`), and ignore taps whose `sel.get("card_id")` isn't the latest one.
83
+
84
+ ### Limits (enforced by `cards.validate`, which raises `CardError`)
85
+ - Payload of 64 KB or less (the kit aims for under 60 KB).
86
+ - Custom trees at most 8 levels deep.
87
+ - `select` fields need `options`.
88
+ - Only these card kinds exist; there is no standalone "buttons" kind. Use `custom` with `ui.button`.
89
+
90
+ If ASI:One rejects a card, the user sees only the narration. That's why every card needs one.
91
+
92
+ ## ASI:One LLM (`fetch_hackkit.asi1`)
93
+
94
+ The API is OpenAI-compatible (`https://api.asi1.ai/v1`). Models: `asi1` (default), `asi1-ultra`, `asi1-mini`. Set `ASI_ONE_MODEL` to switch.
95
+
96
+ ```python
97
+ from fetch_hackkit import ASI1, history, remember
98
+
99
+ llm = ASI1() # raises ASI1Error if no key
100
+ text = await llm.ask("Summarise this", system="Be brief")
101
+ plan = await llm.structured("Plan a trip to SF", TripPlan) # Pydantic model, validated
102
+ answer = await llm.run_tools(question, tools=[get_weather, search_flights])
103
+ ```
104
+
105
+ - **Tools are plain functions,** sync or async, with type hints and a docstring. Google-style `Args:` lines become parameter descriptions.
106
+ - **Failures don't crash the agent:** a tool exception goes back to the model as `{"error": ...}`.
107
+ - **Use `run_tools`** to make the agent *act*, not just chat. Judges penalise "a thin wrapper around an API".
108
+ - **Multi-turn memory:** `history(ctx, msg.session)` and `remember(ctx, msg.session, role, text)`.
109
+
110
+ ## Payments (`@agent.paid`)
111
+
112
+ ```python
113
+ @agent.paid("premium_report", amount="0.5", description="Premium market report")
114
+ async def premium(ctx, msg, sel): # runs only after payment is verified on-chain
115
+ return await build_report(sel)
116
+ ```
117
+
118
+ 1. A tap with `action="premium_report"`, or `await agent.charge(ctx, msg, "premium_report")`, sends `RequestPayment` (FET, `fet_direct`, the agent's wallet as recipient).
119
+ 2. The user approves in ASI:One. The agent receives `CommitPayment(transaction_id)`.
120
+ 3. The kit verifies the transfer on-chain (recipient, amount, buyer) with cosmpy, retrying while the transaction indexes. It then sends `CompletePayment` and runs your handler. Otherwise it sends `CancelPayment(reason)`.
121
+ 4. Replayed commits are acknowledged but never delivered twice.
122
+
123
+ Notes:
124
+ - **Network:** stable testnet by default (`FET_NETWORK=mainnet` for real FET). Test FET is free from the Fetch.ai testnet faucet.
125
+ - **Show the price first:** put a `review` card before an expensive paid action.
126
+ - **Never deliver paid work before the handler runs.**
127
+
128
+ ## MCP
129
+
130
+ **Use an MCP server's tools inside your agent:**
131
+ ```python
132
+ from fetch_hackkit.mcp import mcp_tools, call_mcp_tool
133
+ tools = await mcp_tools("https://mcp.deepwiki.com/mcp", include={"ask_wiki_question"})
134
+ answer = await ASI1().run_tools("How does X work in fetchai/uAgents?", tools=tools)
135
+ ```
136
+ Pass `headers={"Authorization": "Bearer ..."}` for authenticated servers.
137
+
138
+ **Publish an MCP server as an Agentverse agent:** use `uagents-adapter[mcp]` (`MCPServerAdapter`). It's not part of this kit.
139
+
140
+ ## Multi-agent
141
+
142
+ ```python
143
+ from fetch_hackkit.peers import ask_agent, PeerError
144
+ reply = await ask_agent(ctx, "@some-handle", "question", timeout=60) # or an agent1... address
145
+ ```
146
+
147
+ `ask_agent` works with any chat-protocol agent on Agentverse. For your own specialist, run a second `HackAgent` with its own seed and port, then call it by address or `@handle`.
148
+
149
+ ## Registration and the Agentverse profile
150
+
151
+ ### How the agent gets onto Agentverse
152
+
153
+ `HackAgent` uses a **mailbox** by default, so it runs on your laptop but is reachable through Agentverse.
154
+ - **With `AGENTVERSE_API_KEY` set:** on startup it proves ownership of its address and publishes its profile. No clicks needed.
155
+ - **Without the key:** open the inspector link the agent logs and click **Connect → Mailbox**.
156
+ - **Agent at a public URL** (Render, Fly, a VM): call `fetch_hackkit.register.register_endpoint_agent(agent, "https://.../submit")`.
157
+
158
+ ### Where each profile field comes from
159
+
160
+ | Profile field | Set it in code | Change it later via the Agentverse MCP |
161
+ |---|---|---|
162
+ | Name | `HackAgent(name=...)` | `update_user_agent_mailbox` (mailbox/local) / `update_user_agent` (hosted): `name` |
163
+ | **@handle** | `HackAgent(handle=...)` or `AGENT_HANDLE` in `.env` | **Not possible via MCP.** Change it in code and restart. |
164
+ | Short description | `HackAgent(description=...)` | same tools: `short_description` |
165
+ | README | `HackAgent(readme_path=...)` | same tools: `readme` |
166
+ | Avatar | `HackAgent(avatar_url=...)` | same tools: `avatar_url` |
167
+ | Protocols | automatic: chat, plus payment if `@agent.paid` is used | read-only |
168
+
169
+ Handle rules:
170
+ - 1–40 characters: letters, digits, `-`, `_` and `.`.
171
+ - Unique across all of Agentverse.
172
+ - Check availability first with `uv run hackkit handle <name>`. A taken handle fails registration with HTTP 409.
173
+ - Agentverse only applies `handle` when it *updates* an existing agent, not when it first creates one. `HackAgent`'s auto-connect checks and re-sends for you. If you connect another way (e.g. the inspector button), restart the agent once or run `hackkit doctor` to confirm the handle stuck.
174
+
175
+ ### Agentverse MCP (configured in `opencode.json`, `.mcp.json`, `.cursor/mcp.json`)
176
+
177
+ - **Server:** `https://mcp.agentverse.ai/mcp` (streamable HTTP, ~126 tools), enabled by default.
178
+ - **Lite server** (`https://mcp-lite.agentverse.ai/mcp`): fewer tools, so it's friendlier to smaller models. It's included but disabled in `opencode.json` because it wasn't responding when this starter was tested (2026-10-22). Enable it if it's back up.
179
+ - **Auth:** an `Authorization: Bearer $AGENTVERSE_API_KEY` header covers public lookups (`get_agent`, `get_agent_search_api_internal`, `search_agents`). Tools that act on *your* agents (`get_user_agent_*`, `update_user_agent_*`, `create_user_agent`, …) also need the key passed as their `api_token` argument. Read it from the environment; never paste it into chat.
180
+
181
+ Useful workflows:
182
+ - **Inspect your agent:**
183
+ - `get_user_agent_details` / `get_agent` with your address;
184
+ - `agent_readiness_probe_mailbox` (mailbox) / `agent_readiness_probe` (hosted).
185
+ - **Edit your profile:** `update_user_agent_mailbox(address, name, short_description, readme)`.
186
+ - **Hosted agent** (runs on Agentverse, no laptop): `create_user_agent` → `update_user_agent_code` → `start_specific_user_agent` → `get_latest_logs_for_user_agent`. See *Keeping your agent online* for the catch.
187
+ - **Discoverability:**
188
+ - `search_agents` with the words a user would type, to see where you rank;
189
+ - `start_agent_seo_eval` → `get_agent_seo_eval_results` to see how well ASI:One can match you.
190
+
191
+ ### Getting discovered by ASI:One
192
+
193
+ ASI:One routes users by your **name, description, README and handle**:
194
+ - Write the README for routing: what the agent does, concrete example prompts, and the domains it covers.
195
+ - Keep the agent running (status `active`). A stopped mailbox agent isn't routed to.
196
+
197
+ ## Keeping your agent online
198
+
199
+ A mailbox agent only answers while its process is running. Judges will message yours, so plan for it.
200
+
201
+ - **It needs only outbound internet:** no public URL, no open ports, no domain.
202
+ - **While hacking:** run it on your laptop. On macOS, keep the laptop awake with `caffeinate -i uv run python agent.py`.
203
+ - **For judging and after:** run the `Dockerfile` on any always-on host as a *background worker* (no HTTP service), with your `.env` values as environment variables:
204
+ ```bash
205
+ docker build -t my-agent .
206
+ docker run -d --restart unless-stopped --env-file .env my-agent
207
+ ```
208
+ Any container host works: a small VM, Render (Background Worker), Railway, Fly.io Machines.
209
+ - **Run exactly one copy per seed.** Two processes with the same seed poll the same mailbox and split your messages.
210
+ - **`ctx.storage` is a local JSON file.** On hosts with throwaway disks it resets on redeploy. That's fine for a demo; use a database for anything you must keep.
211
+ - **Agentverse-hosted agents** (`create_user_agent` via MCP) run in Agentverse's own sandbox with a fixed set of packages, so they can't install `fetch-hackkit`. Prefer the mailbox + Docker route.
212
+
213
+ ## Submission checklist (Fetch.ai prize)
214
+
215
+ - [ ] Registered on Agentverse, status active, chat protocol published. `hackkit doctor` checks all of this.
216
+ - [ ] The agent stays online through judging (see *Keeping your agent online*).
217
+ - [ ] Works end-to-end inside ASI:One chat, with no custom frontend.
218
+ - [ ] Real tool use, MCP, or multi-agent orchestration, not just one LLM call.
219
+ - [ ] (Bonus) Interactive cards, the Payment Protocol, and error handling and recovery.
220
+ - [ ] Public GitHub repo README with **agent name + address** and both badges:
221
+ `![tag:innovationlab](https://img.shields.io/badge/innovationlab-3D8BD3)`
222
+ `![tag:hackathon](https://img.shields.io/badge/hackathon-5F43F1)`
223
+ - [ ] A 3–5 minute demo video.
224
+
225
+ ## Troubleshooting
226
+
227
+ | Symptom | Fix |
228
+ |---|---|
229
+ | `No seed phrase` | `uv run hackkit seed`, then put the phrase in `.env` |
230
+ | Agent not found in ASI:One | Is it running? Run `hackkit doctor`. Make the README specific, then try `@your-handle` directly. |
231
+ | `HTTP 409` on connect | Handle (or address) taken by another account. Pick a new handle. |
232
+ | `HTTP 401` on connect | Bad or expired `AGENTVERSE_API_KEY` |
233
+ | Card shows as plain text | The card is invalid. Look for `CardError` in the logs. |
234
+ | Button tap ignored | No `@agent.on_action` for that action, so it falls through to `on_text`. Check `msg.selection`. |
235
+ | Payment "not found" | The transaction is still indexing; the kit retries 3 times. On mainnet, check `FET_NETWORK`. |
236
+ | Log warns `not enough funds to register on Almanac contract` | Harmless. Agentverse (API) registration is what ASI:One uses; the on-chain contract registration is optional. |
237
+ | `There is no current event loop` (Python 3.14) | Use `HackAgent`, which creates the loop for you, or run on Python 3.13 |
@@ -0,0 +1,33 @@
1
+ # AGENTS.md
2
+
3
+ Guide for AI coding agents (opencode, Claude Code, Cursor, Codex, and others) working in this project. Humans: this is also the fastest complete reference.
4
+
5
+ You are helping a hackathon team build a **Fetch.ai agent** called **__AGENT_NAME__** (`@__AGENT_HANDLE__`). To win, the agent must:
6
+ - be registered on **Agentverse**;
7
+ - be discoverable and usable inside **ASI:One** chat (the Agent Chat Protocol);
8
+ - do real work: tools, APIs, MCP, or other agents;
9
+ - ideally use **interactive cards** and the **Payment Protocol**.
10
+
11
+ The `fetch-hackkit` package does the plumbing. Write agent code against it.
12
+
13
+ ## Project map
14
+
15
+ | Path | What it is |
16
+ |---|---|
17
+ | `agent.py` | The agent: `HackAgent`, its handlers, cards and tools. Start here. |
18
+ | `README.md` | Shown on the Agentverse profile; ASI:One reads it to route users here. Keep it specific. |
19
+ | `tests/` | Tests. At minimum, every card you send must pass `cards.validate`. |
20
+ | `.env` | Secrets and identity (gitignored). `.env.example` documents every variable. |
21
+ | `Dockerfile` | Runs the agent on an always-on host (see *Keeping your agent online*). |
22
+
23
+ ## Commands
24
+
25
+ ```bash
26
+ uv sync # install (Python 3.11+)
27
+ uv run python agent.py # run the agent
28
+ uv run hackkit whoami # address, wallet, Agentverse profile URL
29
+ uv run hackkit handle my-agent # is @my-agent free on Agentverse?
30
+ uv run hackkit doctor # pre-submission checks
31
+ uv run pytest -q && uv run ruff check .
32
+ ```
33
+
@@ -0,0 +1,13 @@
1
+ # Runs the agent as a background worker. It needs outbound internet only:
2
+ # docker build -t __PROJECT_SLUG__ .
3
+ # docker run -d --restart unless-stopped --env-file .env __PROJECT_SLUG__
4
+ FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim
5
+
6
+ ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy PYTHONUNBUFFERED=1
7
+ WORKDIR /app
8
+
9
+ COPY pyproject.toml uv.lock* ./
10
+ RUN uv sync --no-dev --no-install-project
11
+
12
+ COPY . .
13
+ CMD ["uv", "run", "--no-dev", "python", "agent.py"]
@@ -0,0 +1,29 @@
1
+ ![tag:innovationlab](https://img.shields.io/badge/innovationlab-3D8BD3)
2
+ ![tag:hackathon](https://img.shields.io/badge/hackathon-5F43F1)
3
+
4
+ # __AGENT_NAME__
5
+
6
+ TODO: what your agent does, for whom, and why it's useful, in 2-3 sentences. Agentverse shows this README on your agent's profile, and ASI:One reads it to decide when to send users to you.
7
+
8
+ **Handle:** `@__AGENT_HANDLE__`
9
+
10
+ **Agent address:** `__AGENT_ADDRESS__`
11
+
12
+ ## What it can do
13
+
14
+ - TODO: capability 1
15
+ - TODO: capability 2
16
+
17
+ ## Example prompts
18
+
19
+ - Hi
20
+ - What's the weather in Berkeley?
21
+
22
+ ## Run it
23
+
24
+ ```bash
25
+ uv sync
26
+ uv run python agent.py
27
+ ```
28
+
29
+ Built with [fetch-hackkit](https://pypi.org/project/fetch-hackkit/), uAgents, the Agent Chat Protocol and ASI:One.
@@ -0,0 +1,102 @@
1
+ """__AGENT_NAME__: a Fetch.ai agent built with fetch-hackkit.
2
+
3
+ Run it: uv run python agent.py
4
+ Then chat with it on ASI:One (https://asi1.ai): @__AGENT_HANDLE__ hi
5
+
6
+ Start here: change the description, replace get_weather with your own tools,
7
+ and add cards for the steps of your workflow. AGENTS.md has every pattern.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+
14
+ import httpx
15
+ from fetch_hackkit import ASI1, ASI1Error, HackAgent, cards, history, remember
16
+
17
+ agent = HackAgent(
18
+ name="__AGENT_NAME__",
19
+ handle=os.getenv("AGENT_HANDLE") or None,
20
+ # One sentence ASI:One users would search for. ASI:One routes by this + README.md.
21
+ description="TODO: describe what your agent does in one sentence.",
22
+ readme_path="README.md",
23
+ )
24
+
25
+ SYSTEM = (
26
+ "You are __AGENT_NAME__, an agent on Fetch.ai. Be concise. "
27
+ "Use your tools for facts instead of guessing."
28
+ )
29
+
30
+ GREETINGS = {"hi", "hello", "hey", "menu", "start", "help"}
31
+
32
+
33
+ async def get_weather(city: str) -> str:
34
+ """Current weather for a city.
35
+
36
+ Args:
37
+ city: City name, e.g. Berkeley.
38
+ """
39
+ async with httpx.AsyncClient(timeout=15) as client:
40
+ resp = await client.get(f"https://wttr.in/{city}", params={"format": "3"})
41
+ resp.raise_for_status()
42
+ return resp.text.strip()
43
+
44
+
45
+ def menu() -> cards.Card:
46
+ return cards.carousel(
47
+ "__AGENT_NAME__",
48
+ [
49
+ cards.item(
50
+ "ask",
51
+ "Ask a question",
52
+ action="ask_help",
53
+ subtitle="ASI:One answers, calling tools when needed",
54
+ ),
55
+ cards.item(
56
+ "about", "What can you do?", action="about", subtitle="A quick tour of this agent"
57
+ ),
58
+ ],
59
+ narration="Hi! I'm **__AGENT_NAME__**. Pick an option or just ask me something.",
60
+ )
61
+
62
+
63
+ @agent.on_text
64
+ async def on_text(ctx, msg):
65
+ """Greetings open the menu; everything else goes to ASI:One with tools."""
66
+ if msg.text.strip().lower() in GREETINGS:
67
+ return menu()
68
+ try:
69
+ llm = ASI1()
70
+ except ASI1Error:
71
+ return "Add ASI_ONE_API_KEY to .env so I can answer questions."
72
+ past = history(ctx, msg.session)
73
+ reply = await llm.run_tools(
74
+ [*past, {"role": "user", "content": msg.text}],
75
+ tools=[get_weather],
76
+ system=SYSTEM,
77
+ )
78
+ remember(ctx, msg.session, "user", msg.text)
79
+ remember(ctx, msg.session, "assistant", reply)
80
+ return reply
81
+
82
+
83
+ @agent.on_action("ask_help")
84
+ async def ask_help(ctx, msg, sel):
85
+ return "Ask me anything. Try: *What's the weather in Berkeley?*"
86
+
87
+
88
+ @agent.on_action("about")
89
+ async def about(ctx, msg, sel):
90
+ return cards.info(
91
+ "About __AGENT_NAME__",
92
+ rows={
93
+ "Talks via": "Agent Chat Protocol (ASI:One)",
94
+ "Brain": "ASI:One LLM with tool calling",
95
+ "Tools": "get_weather (replace with yours)",
96
+ },
97
+ narration="Here's what I'm built from.",
98
+ )
99
+
100
+
101
+ if __name__ == "__main__":
102
+ agent.run()
@@ -0,0 +1,10 @@
1
+ {
2
+ "mcpServers": {
3
+ "agentverse": {
4
+ "url": "https://mcp.agentverse.ai/mcp",
5
+ "headers": {
6
+ "Authorization": "Bearer ${env:AGENTVERSE_API_KEY}"
7
+ }
8
+ }
9
+ }
10
+ }
@@ -0,0 +1,7 @@
1
+ .env
2
+ .venv/
3
+ __pycache__/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ agent*_data.json
7
+ .git/
@@ -0,0 +1,17 @@
1
+ # Your agent's identity. `hackkit new` already generated one into .env.
2
+ # Never commit .env: the seed *is* your agent (address + wallet).
3
+ AGENT_SEED_PHRASE=
4
+
5
+ # ASI:One LLM key: https://asi1.ai/developer
6
+ ASI_ONE_API_KEY=
7
+ # ASI_ONE_MODEL=asi1
8
+
9
+ # Agentverse API key: https://agentverse.ai/profile/api-keys
10
+ # With it, the agent registers itself (name, @handle, description, README) on startup.
11
+ AGENTVERSE_API_KEY=
12
+
13
+ # Your unique @handle on Agentverse (max 40 chars). Check: uv run hackkit handle <name>
14
+ AGENT_HANDLE=__AGENT_HANDLE__
15
+
16
+ # AGENT_PORT=8000
17
+ # FET_NETWORK=stable-testnet # or mainnet (real money!)
@@ -0,0 +1,9 @@
1
+ .env
2
+ .venv/
3
+ __pycache__/
4
+ *.pyc
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ # uAgents local storage
8
+ agent*_data.json
9
+ private_keys.json
@@ -0,0 +1,11 @@
1
+ {
2
+ "mcpServers": {
3
+ "agentverse": {
4
+ "type": "http",
5
+ "url": "https://mcp.agentverse.ai/mcp",
6
+ "headers": {
7
+ "Authorization": "Bearer ${AGENTVERSE_API_KEY}"
8
+ }
9
+ }
10
+ }
11
+ }
@@ -0,0 +1,83 @@
1
+ {
2
+ "$schema": "https://opencode.ai/config.json",
3
+ "model": "asi1/asi1",
4
+ "small_model": "asi1/asi1-mini",
5
+ "provider": {
6
+ "asi1": {
7
+ "npm": "@ai-sdk/openai-compatible",
8
+ "name": "ASI:One",
9
+ "options": {
10
+ "baseURL": "https://api.asi1.ai/v1",
11
+ "apiKey": "{env:ASI_ONE_API_KEY}"
12
+ },
13
+ "models": {
14
+ "asi1": {
15
+ "name": "ASI1",
16
+ "limit": {
17
+ "context": 196000,
18
+ "output": 40000
19
+ },
20
+ "modalities": {
21
+ "input": [
22
+ "text",
23
+ "image"
24
+ ],
25
+ "output": [
26
+ "text"
27
+ ]
28
+ }
29
+ },
30
+ "asi1-ultra": {
31
+ "name": "ASI1 Ultra",
32
+ "limit": {
33
+ "context": 196000,
34
+ "output": 40000
35
+ },
36
+ "modalities": {
37
+ "input": [
38
+ "text"
39
+ ],
40
+ "output": [
41
+ "text"
42
+ ]
43
+ }
44
+ },
45
+ "asi1-mini": {
46
+ "name": "ASI1 Mini",
47
+ "limit": {
48
+ "context": 196000,
49
+ "output": 40000
50
+ },
51
+ "modalities": {
52
+ "input": [
53
+ "text"
54
+ ],
55
+ "output": [
56
+ "text"
57
+ ]
58
+ }
59
+ }
60
+ }
61
+ }
62
+ },
63
+ "mcp": {
64
+ "agentverse": {
65
+ "type": "remote",
66
+ "url": "https://mcp.agentverse.ai/mcp",
67
+ "enabled": true,
68
+ "headers": {
69
+ "Authorization": "Bearer {env:AGENTVERSE_API_KEY}"
70
+ },
71
+ "oauth": false
72
+ },
73
+ "agentverse-lite": {
74
+ "type": "remote",
75
+ "url": "https://mcp-lite.agentverse.ai/mcp",
76
+ "enabled": false,
77
+ "headers": {
78
+ "Authorization": "Bearer {env:AGENTVERSE_API_KEY}"
79
+ },
80
+ "oauth": false
81
+ }
82
+ }
83
+ }
@@ -0,0 +1,21 @@
1
+ [project]
2
+ name = "__PROJECT_SLUG__"
3
+ version = "0.1.0"
4
+ description = "__AGENT_NAME__: a Fetch.ai agent built with fetch-hackkit"
5
+ requires-python = ">=3.11"
6
+ dependencies = ["fetch-hackkit[mcp]>=__HACKKIT_VERSION__"]
7
+
8
+ [dependency-groups]
9
+ dev = ["pytest>=8", "pytest-asyncio>=0.24", "ruff>=0.8"]
10
+
11
+ [tool.uv]
12
+ package = false
13
+
14
+ [tool.pytest.ini_options]
15
+ asyncio_mode = "auto"
16
+
17
+ [tool.ruff]
18
+ line-length = 100
19
+
20
+ [tool.ruff.format]
21
+ exclude = ["*.md"] # keep doc snippets compact
@@ -0,0 +1,23 @@
1
+ """Every card your agent can send must pass ASI:One's validation."""
2
+
3
+ import os
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ os.environ.setdefault("AGENT_SEED_PHRASE", "unit test seed phrase, not a real agent")
8
+ os.environ["AGENTVERSE_API_KEY"] = "" # never connect to Agentverse from tests
9
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
10
+
11
+ from fetch_hackkit.cards import collect_actions, validate
12
+
13
+ import agent
14
+
15
+
16
+ def test_menu_card_is_valid() -> None:
17
+ card = agent.menu()
18
+ assert validate(card.kind, card.payload) == []
19
+
20
+
21
+ def test_every_menu_button_has_a_handler() -> None:
22
+ actions = collect_actions(agent.menu().payload)
23
+ assert actions <= set(agent.agent._action_handlers)
@@ -0,0 +1,95 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.util
4
+ import stat
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+ from fetch_hackkit import __version__, scaffold
10
+ from fetch_hackkit.cards import collect_actions, validate
11
+ from fetch_hackkit.cli import main
12
+
13
+
14
+ def _load_agent(path: Path):
15
+ spec = importlib.util.spec_from_file_location("generated_agent", path / "agent.py")
16
+ module = importlib.util.module_from_spec(spec)
17
+ sys.modules["generated_agent"] = module
18
+ spec.loader.exec_module(module)
19
+ return module
20
+
21
+
22
+ async def test_create_project_writes_a_runnable_project(tmp_path: Path, monkeypatch) -> None:
23
+ project = scaffold.create_project(
24
+ "Trip Planner", directory=tmp_path / "trip", check_handle=False
25
+ )
26
+ root = project.path
27
+ expected = {
28
+ "agent.py",
29
+ "README.md",
30
+ "pyproject.toml",
31
+ ".python-version",
32
+ ".env",
33
+ ".env.example",
34
+ ".gitignore",
35
+ ".dockerignore",
36
+ "Dockerfile",
37
+ "AGENTS.md",
38
+ "CLAUDE.md",
39
+ "opencode.json",
40
+ ".mcp.json",
41
+ ".cursor/mcp.json",
42
+ "tests/test_agent.py",
43
+ }
44
+ assert expected <= {str(p.relative_to(root)) for p in root.rglob("*") if p.is_file()}
45
+
46
+ for path in root.rglob("*"):
47
+ if path.is_file():
48
+ assert "__AGENT_" not in path.read_text() and "__PROJECT_" not in path.read_text(), path
49
+
50
+ assert project.handle == "trip-planner"
51
+ env = (root / ".env").read_text()
52
+ assert "AGENT_HANDLE=trip-planner" in env
53
+ seed = next(
54
+ line.split("=", 1)[1] for line in env.splitlines() if line.startswith("AGENT_SEED_PHRASE=")
55
+ )
56
+ assert len(seed) >= 32
57
+ assert stat.S_IMODE((root / ".env").stat().st_mode) == 0o600
58
+ assert project.address in (root / "README.md").read_text()
59
+ assert f'"fetch-hackkit[mcp]>={__version__}"' in (root / "pyproject.toml").read_text()
60
+ agents_md = (root / "AGENTS.md").read_text()
61
+ assert "Trip Planner" in agents_md and "## Interactive cards (ASI:One UI)" in agents_md
62
+ assert (root / "CLAUDE.md").resolve() == (root / "AGENTS.md").resolve()
63
+
64
+ monkeypatch.setenv("AGENT_SEED_PHRASE", seed)
65
+ monkeypatch.chdir(root)
66
+ generated = _load_agent(root)
67
+ menu = generated.menu()
68
+ assert validate(menu.kind, menu.payload) == []
69
+ assert collect_actions(menu.payload) <= set(generated.agent._action_handlers)
70
+ assert generated.agent.address == project.address
71
+
72
+
73
+ def test_refuses_non_empty_directory(tmp_path: Path) -> None:
74
+ (tmp_path / "x.txt").write_text("hi")
75
+ with pytest.raises(FileExistsError):
76
+ scaffold.create_project("A", directory=tmp_path, check_handle=False)
77
+
78
+
79
+ def test_taken_handle_gets_a_free_variant(monkeypatch) -> None:
80
+ monkeypatch.setattr(scaffold, "resolve", lambda h: {"address": "x"} if h == "taken" else None)
81
+ handle, note = scaffold._choose_handle("taken", check=True)
82
+ assert handle.startswith("taken-") and "taken" in note
83
+ assert scaffold._choose_handle("free", check=True) == ("free", "available")
84
+
85
+
86
+ def test_slugify() -> None:
87
+ assert scaffold.slugify("Trip Planner 2.0!") == "trip-planner-2-0"
88
+ assert scaffold.slugify("🙂") == "my-agent"
89
+
90
+
91
+ def test_cli_new(tmp_path: Path, capsys) -> None:
92
+ assert main(["new", "Rent Splitter", "--dir", str(tmp_path / "rs"), "--no-check"]) == 0
93
+ out = capsys.readouterr().out
94
+ assert "@rent-splitter" in out and "uv run python agent.py" in out
95
+ assert main(["new", "Rent Splitter", "--dir", str(tmp_path / "rs"), "--no-check"]) == 1
File without changes
File without changes