create-aimarket-agent 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.
@@ -0,0 +1,4 @@
1
+ """AIMarket agent project scaffolder."""
2
+
3
+ __version__ = "0.1.0"
4
+
@@ -0,0 +1,99 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import re
5
+ import shutil
6
+ import sys
7
+ import tempfile
8
+ from importlib.resources import files
9
+ from pathlib import Path
10
+
11
+ KINDS = ("tool", "data-provider", "orchestrator")
12
+ _SAFE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$")
13
+
14
+
15
+ def slug(value: str) -> str:
16
+ clean = re.sub(r"[^a-z0-9]+", "-", value.strip().lower()).strip("-")
17
+ if not clean:
18
+ raise ValueError("project name must contain a letter or digit")
19
+ return clean
20
+
21
+
22
+ def validate_name(value: str) -> str:
23
+ value = value.strip()
24
+ if not _SAFE_NAME.fullmatch(value):
25
+ raise ValueError(
26
+ "project name must be 1-64 characters and contain only letters, digits, spaces, dots, dashes, or underscores"
27
+ )
28
+ return value
29
+
30
+
31
+ def scaffold(target: Path, *, name: str, kind: str, metis: bool) -> Path:
32
+ name = validate_name(name)
33
+ if kind not in KINDS:
34
+ raise ValueError(f"kind must be one of: {', '.join(KINDS)}")
35
+ target = target.resolve()
36
+ if target.exists():
37
+ raise FileExistsError(f"target already exists: {target}")
38
+ template = files("create_aimarket_agent").joinpath("templates/base")
39
+ target.parent.mkdir(parents=True, exist_ok=True)
40
+ stage_root = Path(tempfile.mkdtemp(prefix=f".{target.name}.tmp-", dir=target.parent))
41
+ staged = stage_root / target.name
42
+ replacements = {
43
+ "__PROJECT_NAME__": name,
44
+ "__PROJECT_SLUG__": slug(name),
45
+ "__AGENT_KIND__": kind,
46
+ "__METIS_ENABLED__": "true" if metis else "false",
47
+ "__METIS_PYTHON__": "True" if metis else "False",
48
+ }
49
+ try:
50
+ shutil.copytree(
51
+ str(template),
52
+ staged,
53
+ ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo"),
54
+ )
55
+ for path in staged.rglob("*"):
56
+ if path.is_file():
57
+ text = path.read_text(encoding="utf-8")
58
+ for old, new in replacements.items():
59
+ text = text.replace(old, new)
60
+ path.write_text(text, encoding="utf-8")
61
+ if target.exists():
62
+ raise FileExistsError(f"target appeared while scaffolding: {target}")
63
+ staged.rename(target)
64
+ finally:
65
+ shutil.rmtree(stage_root, ignore_errors=True)
66
+ return target
67
+
68
+
69
+ def parser() -> argparse.ArgumentParser:
70
+ p = argparse.ArgumentParser(prog="create-aimarket-agent")
71
+ p.add_argument("name", nargs="?", help="project name")
72
+ p.add_argument("--kind", choices=KINDS, default="tool")
73
+ p.add_argument("--metis", action=argparse.BooleanOptionalAction, default=True)
74
+ p.add_argument("--directory", type=Path, help="output directory")
75
+ return p
76
+
77
+
78
+ def main(argv: list[str] | None = None) -> int:
79
+ args = parser().parse_args(argv)
80
+ name = args.name
81
+ if not name and sys.stdin.isatty():
82
+ name = input("Agent name: ").strip()
83
+ if not name:
84
+ parser().error("name is required")
85
+ try:
86
+ name = validate_name(name)
87
+ target = args.directory or Path.cwd() / slug(name)
88
+ scaffold(target, name=name, kind=args.kind, metis=args.metis)
89
+ except (ValueError, FileExistsError, OSError) as exc:
90
+ print(f"error: {exc}", file=sys.stderr)
91
+ return 2
92
+ print(f"Created {name} in {target}")
93
+ print(f"Next: cd {target} && uv sync --extra dev")
94
+ print("Then: uv run python configure_provider.py && uv run pytest && uv run python agent.py")
95
+ return 0
96
+
97
+
98
+ if __name__ == "__main__":
99
+ raise SystemExit(main())
@@ -0,0 +1,11 @@
1
+ .env
2
+ .git
3
+ .github
4
+ .aimarket
5
+ .venv
6
+ __pycache__
7
+ .pytest_cache
8
+ .coverage
9
+ build
10
+ dist
11
+ *.egg-info
@@ -0,0 +1,4 @@
1
+ PORT=8080
2
+ AIMARKET_HUB_URL=https://modelmarket.dev
3
+ AIMARKET_METIS_URL=https://metis.modelmarket.dev
4
+ AIMARKET_PROVIDER_KEY_FILE=.aimarket/provider.key
@@ -0,0 +1,18 @@
1
+ name: test
2
+ on: [push, pull_request]
3
+ permissions:
4
+ contents: read
5
+ jobs:
6
+ test:
7
+ runs-on: ubuntu-latest
8
+ timeout-minutes: 10
9
+ steps:
10
+ - uses: actions/checkout@v4
11
+ with:
12
+ persist-credentials: false
13
+ - uses: actions/setup-python@v5
14
+ with: {python-version: "3.12"}
15
+ - run: pip install -e '.[dev]'
16
+ - run: python configure_provider.py
17
+ - run: pytest -q
18
+ - run: python validate_manifest.py
@@ -0,0 +1,5 @@
1
+ .env
2
+ .venv/
3
+ __pycache__/
4
+ .pytest_cache/
5
+ .aimarket/
@@ -0,0 +1,15 @@
1
+ FROM python:3.12-slim
2
+ WORKDIR /app
3
+ COPY . .
4
+ RUN pip install --no-cache-dir .
5
+ RUN mkdir -p /data && chown 65532:65532 /data
6
+ ENV HOST=0.0.0.0 \
7
+ AIMARKET_PROVIDER_IDENTITY_FILE=/data/provider.key \
8
+ PYTHONDONTWRITEBYTECODE=1 \
9
+ PYTHONUNBUFFERED=1
10
+ USER 65532:65532
11
+ VOLUME ["/data"]
12
+ EXPOSE 8080
13
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
14
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=3).read()"
15
+ CMD ["python", "agent.py"]
@@ -0,0 +1,16 @@
1
+ .PHONY: configure dev test validate publish-dry-run
2
+
3
+ configure:
4
+ uv run python configure_provider.py
5
+
6
+ dev: configure
7
+ uv run python agent.py
8
+
9
+ test:
10
+ uv run pytest -q
11
+
12
+ validate: configure
13
+ uv run python validate_manifest.py
14
+
15
+ publish-dry-run: validate
16
+ @echo "Dry run only: aimarket publish capability.json --hub $${AIMARKET_HUB_URL:-http://127.0.0.1:9083}"
@@ -0,0 +1,27 @@
1
+ # __PROJECT_NAME__
2
+
3
+ Generated AIMarket Protocol v2 `__AGENT_KIND__` provider.
4
+
5
+ ```bash
6
+ uv sync --extra dev
7
+ uv run python configure_provider.py
8
+ uv run pytest
9
+ uv run python agent.py
10
+ make validate
11
+ make publish-dry-run
12
+ ```
13
+
14
+ Validate `capability.json`, then publish deliberately with the AIMarket Hub CLI. Never commit `.env`.
15
+
16
+ The Ed25519 provider key lives in `.aimarket/provider.key` and is gitignored. Back it up: changing
17
+ it after publish invalidates the `provider_pubkey` registered with the Hub. The provider accepts
18
+ only the `product_id` and `capability_id` declared in `capability.json`; never replace that check
19
+ with caller-selected signing identity. The validator rejects ambiguous JSON, malformed Ed25519
20
+ keys, non-finite prices, and public HTTP invoke URLs.
21
+
22
+ The Dockerfile is a development image; mount the same key as a secret before containerizing a
23
+ published provider. Keep the container port private until an HTTPS ingress adds production traffic,
24
+ concurrency, and rate limits. The generated endpoint authenticates its output with a signature; it
25
+ does not authenticate or bill clients that bypass Hub.
26
+ Responses use the replay-resistant AIMarket request-bound signature, covering the capability,
27
+ product, SHA-256 of the exact input, and result. Keep those request fields when extending `agent.py`.
@@ -0,0 +1,81 @@
1
+ """Minimal AIMarket capability provider generated by create-aimarket-agent."""
2
+ from __future__ import annotations
3
+
4
+ import hmac
5
+ import os
6
+ from fastapi import FastAPI, Request
7
+ from fastapi.responses import JSONResponse
8
+ from pydantic import BaseModel, Field
9
+
10
+ from provider_signing import ProviderSigner
11
+
12
+ app = FastAPI(
13
+ title="__PROJECT_NAME__",
14
+ docs_url=None,
15
+ redoc_url=None,
16
+ openapi_url=None,
17
+ )
18
+ METIS_ENABLED = __METIS_PYTHON__
19
+ SIGNER = ProviderSigner()
20
+ PRODUCT_ID = "__PROJECT_SLUG__"
21
+ CAPABILITY_ID = "__PROJECT_SLUG__.invoke@v1"
22
+
23
+
24
+ class InvokeInput(BaseModel):
25
+ input: dict = Field(default_factory=dict)
26
+ product_id: str = Field(default=PRODUCT_ID, max_length=128)
27
+ capability_id: str = Field(default=CAPABILITY_ID, max_length=192)
28
+
29
+
30
+ @app.middleware("http")
31
+ async def request_boundary(request: Request, call_next):
32
+ response = None
33
+ if request.method == "POST" and request.url.path == "/invoke":
34
+ raw_length = request.headers.get("content-length")
35
+ try:
36
+ length = int(raw_length) if raw_length is not None else -1
37
+ except ValueError:
38
+ length = -1
39
+ if length < 0 or length > 1_048_576:
40
+ response = JSONResponse({"detail": "request body must be 0-1048576 bytes"}, status_code=413)
41
+ if response is None:
42
+ response = await call_next(request)
43
+ response.headers["Cache-Control"] = "no-store"
44
+ response.headers["X-Content-Type-Options"] = "nosniff"
45
+ response.headers["Referrer-Policy"] = "no-referrer"
46
+ response.headers["X-Frame-Options"] = "DENY"
47
+ return response
48
+
49
+
50
+ @app.get("/health")
51
+ def health() -> dict:
52
+ return {"ok": True, "agent": "__PROJECT_SLUG__", "kind": "__AGENT_KIND__",
53
+ "provider_pubkey": SIGNER.public_key_b64}
54
+
55
+
56
+ @app.post("/invoke")
57
+ def invoke(request: InvokeInput) -> JSONResponse:
58
+ # Identity belongs to capability.json, not to an untrusted caller. Allowing
59
+ # arbitrary identifiers here would turn this key into a signing oracle for
60
+ # another product or capability that happens to reuse the provider key.
61
+ if not hmac.compare_digest(request.product_id, PRODUCT_ID):
62
+ return JSONResponse({"detail": "product_id does not match this provider"}, status_code=400)
63
+ if not hmac.compare_digest(request.capability_id, CAPABILITY_ID):
64
+ return JSONResponse({"detail": "capability_id does not match this provider"}, status_code=400)
65
+ # Replace this deterministic example with your capability logic.
66
+ result = {"message": "Hello from __PROJECT_NAME__", "received": request.input,
67
+ "verification_requested": METIS_ENABLED}
68
+ return JSONResponse(
69
+ {"success": True, "result": result},
70
+ headers={"X-Provider-Signature": SIGNER.sign_result(
71
+ result,
72
+ capability_id=CAPABILITY_ID,
73
+ product_id=PRODUCT_ID,
74
+ input_payload=request.input,
75
+ )},
76
+ )
77
+
78
+
79
+ if __name__ == "__main__":
80
+ import uvicorn
81
+ uvicorn.run(app, host=os.getenv("HOST", "127.0.0.1"), port=int(os.getenv("PORT", "8080")))
@@ -0,0 +1,13 @@
1
+ {
2
+ "product_id": "__PROJECT_SLUG__",
3
+ "capability_id": "__PROJECT_SLUG__.invoke@v1",
4
+ "name": "__PROJECT_NAME__",
5
+ "description": "A generated AIMarket __AGENT_KIND__ capability",
6
+ "invoke_url": "http://127.0.0.1:8080/invoke",
7
+ "publisher_id": "community",
8
+ "provider_pubkey": "",
9
+ "price_per_call_usd": 0.001,
10
+ "input_schema": {"type": "object", "additionalProperties": true},
11
+ "output_schema": {"type": "object", "additionalProperties": true},
12
+ "verification": {"metis": __METIS_ENABLED__}
13
+ }
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from provider_signing import ProviderSigner
7
+
8
+
9
+ def main() -> int:
10
+ signer = ProviderSigner()
11
+ path = Path("capability.json")
12
+ manifest = json.loads(path.read_text(encoding="utf-8"))
13
+ manifest["provider_pubkey"] = signer.public_key_b64
14
+ temporary = path.with_suffix(".json.tmp")
15
+ temporary.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
16
+ temporary.replace(path)
17
+ print(f"provider identity ready: {signer.public_key_b64}")
18
+ return 0
19
+
20
+
21
+ if __name__ == "__main__":
22
+ raise SystemExit(main())
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import stat
8
+ from pathlib import Path
9
+
10
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
11
+
12
+
13
+ class ProviderSigner:
14
+ def __init__(self) -> None:
15
+ self.path = Path(
16
+ os.getenv(
17
+ "AIMARKET_PROVIDER_KEY_FILE",
18
+ os.getenv("AIMARKET_PROVIDER_IDENTITY_FILE", ".aimarket/provider.key"),
19
+ )
20
+ )
21
+ if self.path.parent.exists() and self.path.parent.is_symlink():
22
+ raise RuntimeError(f"provider key directory {self.path.parent} must not be a link")
23
+ self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
24
+ if self.path.exists():
25
+ seed = self._read_existing_seed()
26
+ else:
27
+ seed = Ed25519PrivateKey.generate().private_bytes_raw()
28
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
29
+ descriptor = os.open(self.path, flags, 0o600)
30
+ with os.fdopen(descriptor, "wb") as handle:
31
+ handle.write(seed)
32
+ self.private = Ed25519PrivateKey.from_private_bytes(seed)
33
+
34
+ def _read_existing_seed(self) -> bytes:
35
+ path_info = self.path.lstat()
36
+ if self.path.is_symlink() or not stat.S_ISREG(path_info.st_mode):
37
+ raise RuntimeError(f"provider key {self.path} must be a regular file, not a link")
38
+ flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
39
+ try:
40
+ descriptor = os.open(self.path, flags)
41
+ except OSError as exc:
42
+ raise RuntimeError(f"provider key {self.path} could not be opened safely") from exc
43
+ with os.fdopen(descriptor, "rb") as handle:
44
+ opened_info = os.fstat(handle.fileno())
45
+ if (
46
+ not stat.S_ISREG(opened_info.st_mode)
47
+ or (opened_info.st_dev, opened_info.st_ino) != (path_info.st_dev, path_info.st_ino)
48
+ ):
49
+ raise RuntimeError(f"provider key {self.path} changed while it was being opened")
50
+ os.fchmod(handle.fileno(), 0o600)
51
+ seed = handle.read(33)
52
+ if len(seed) != 32:
53
+ raise RuntimeError(f"provider key {self.path} is corrupted; expected 32 bytes")
54
+ return seed
55
+
56
+ @property
57
+ def public_key_b64(self) -> str:
58
+ return base64.b64encode(self.private.public_key().public_bytes_raw()).decode()
59
+
60
+ def sign_result(
61
+ self, result: dict, *, capability_id: str, product_id: str, input_payload: dict
62
+ ) -> str:
63
+ input_json = json.dumps(
64
+ input_payload or {}, sort_keys=True, separators=(",", ":"), ensure_ascii=False
65
+ )
66
+ canonical = json.dumps(
67
+ {
68
+ "capability_id": capability_id,
69
+ "product_id": product_id,
70
+ "input_sha256": hashlib.sha256(input_json.encode()).hexdigest(),
71
+ "result": result,
72
+ },
73
+ sort_keys=True,
74
+ separators=(",", ":"),
75
+ ensure_ascii=False,
76
+ )
77
+ return base64.b64encode(self.private.sign(canonical.encode())).decode()
@@ -0,0 +1,18 @@
1
+ [project]
2
+ name = "__PROJECT_SLUG__"
3
+ version = "0.1.0"
4
+ requires-python = ">=3.11"
5
+ dependencies = ["cryptography>=44", "fastapi>=0.115", "uvicorn>=0.30"]
6
+
7
+ [project.optional-dependencies]
8
+ dev = ["httpx>=0.27", "pytest>=8"]
9
+
10
+ [build-system]
11
+ requires = ["setuptools>=77"]
12
+ build-backend = "setuptools.build_meta"
13
+
14
+ [tool.setuptools]
15
+ py-modules = ["agent", "provider_signing", "configure_provider", "validate_manifest"]
16
+
17
+ [tool.pytest.ini_options]
18
+ testpaths = ["."]
@@ -0,0 +1,73 @@
1
+ import base64
2
+ import hashlib
3
+ import json
4
+
5
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
6
+ from fastapi.testclient import TestClient
7
+ from agent import app
8
+
9
+
10
+ def test_health_and_invoke():
11
+ client = TestClient(app)
12
+ assert client.get("/health").json()["ok"] is True
13
+ payload = {
14
+ "input": {"hello": "world"},
15
+ "product_id": "__PROJECT_SLUG__",
16
+ "capability_id": "__PROJECT_SLUG__.invoke@v1",
17
+ }
18
+ response = client.post("/invoke", json=payload)
19
+ body = response.json()
20
+ assert body["success"] is True
21
+ assert body["result"]["received"] == {"hello": "world"}
22
+ signature = base64.b64decode(response.headers["x-provider-signature"], validate=True)
23
+ input_json = json.dumps(payload["input"], sort_keys=True, separators=(",", ":"), ensure_ascii=False)
24
+ canonical = json.dumps({
25
+ "capability_id": payload["capability_id"],
26
+ "product_id": payload["product_id"],
27
+ "input_sha256": hashlib.sha256(input_json.encode()).hexdigest(),
28
+ "result": body["result"],
29
+ }, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
30
+ public_key = base64.b64decode(client.get("/health").json()["provider_pubkey"], validate=True)
31
+ Ed25519PublicKey.from_public_bytes(public_key).verify(signature, canonical.encode())
32
+ assert response.headers["cache-control"] == "no-store"
33
+ assert response.headers["x-content-type-options"] == "nosniff"
34
+ assert response.headers["x-frame-options"] == "DENY"
35
+
36
+
37
+ def test_signature_is_bound_to_request_input():
38
+ client = TestClient(app)
39
+ first = client.post("/invoke", json={"input": {"hello": "world"}})
40
+ second = client.post("/invoke", json={"input": {"hello": "another-world"}})
41
+ assert first.headers["x-provider-signature"] != second.headers["x-provider-signature"]
42
+
43
+
44
+ def test_provider_identity_cannot_be_selected_by_the_caller():
45
+ client = TestClient(app)
46
+ assert client.post("/invoke", json={"product_id": "another-product"}).status_code == 400
47
+ assert client.post("/invoke", json={"capability_id": "another.invoke@v1"}).status_code == 400
48
+
49
+
50
+ def test_unused_framework_documentation_routes_are_closed():
51
+ client = TestClient(app)
52
+ assert client.get("/docs").status_code == 404
53
+ assert client.get("/openapi.json").status_code == 404
54
+
55
+
56
+ def test_invalid_payload_is_not_cached():
57
+ response = TestClient(app).post("/invoke", json={"product_id": "x" * 129})
58
+ assert response.status_code == 422
59
+ assert response.headers["cache-control"] == "no-store"
60
+
61
+
62
+ def test_oversized_invoke_is_rejected():
63
+ response = TestClient(app).post("/invoke", content=b"x" * 1_048_577)
64
+ assert response.status_code == 413
65
+
66
+
67
+ def test_invalid_content_length_is_rejected():
68
+ response = TestClient(app).post(
69
+ "/invoke",
70
+ content=b"{}",
71
+ headers={"content-type": "application/json", "content-length": "invalid"},
72
+ )
73
+ assert response.status_code == 413
@@ -0,0 +1,145 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import binascii
5
+ import ipaddress
6
+ import json
7
+ import math
8
+ import re
9
+ from pathlib import Path
10
+ from urllib.parse import urlsplit
11
+
12
+
13
+ MANIFEST = Path("capability.json")
14
+ MAX_MANIFEST_BYTES = 65_536
15
+ SAFE_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}")
16
+ CAPABILITY_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*@[vV]\d+")
17
+ PUBLISHER_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}")
18
+ RESERVED_PUBLISHER_PREFIXES = ("tx-consumed:", "unverified-dev-credit")
19
+
20
+
21
+ def fail(message: str) -> None:
22
+ raise SystemExit(message)
23
+
24
+
25
+ def unique_object(pairs):
26
+ result = {}
27
+ for key, value in pairs:
28
+ if key in result:
29
+ fail(f"capability.json contains duplicate key: {key}")
30
+ result[key] = value
31
+ return result
32
+
33
+
34
+ def read_manifest() -> dict:
35
+ try:
36
+ info = MANIFEST.lstat()
37
+ except OSError as exc:
38
+ fail(f"cannot inspect capability.json: {exc}")
39
+ if MANIFEST.is_symlink() or not MANIFEST.is_file():
40
+ fail("capability.json must be a regular file, not a link")
41
+ if info.st_size > MAX_MANIFEST_BYTES:
42
+ fail(f"capability.json exceeds {MAX_MANIFEST_BYTES} bytes")
43
+ try:
44
+ data = json.loads(MANIFEST.read_text(encoding="utf-8"), object_pairs_hook=unique_object)
45
+ except (OSError, UnicodeError, json.JSONDecodeError, RecursionError) as exc:
46
+ fail(f"capability.json is not valid bounded UTF-8 JSON: {exc}")
47
+ if not isinstance(data, dict):
48
+ fail("capability.json must contain one JSON object")
49
+ return data
50
+
51
+
52
+ def finite_number(data: dict, key: str, *, minimum: float, maximum: float) -> float:
53
+ value = data.get(key)
54
+ if isinstance(value, bool):
55
+ fail(f"{key} must be a finite number between {minimum} and {maximum}")
56
+ try:
57
+ number = float(value)
58
+ except (TypeError, ValueError):
59
+ fail(f"{key} must be a finite number between {minimum} and {maximum}")
60
+ if not math.isfinite(number) or not minimum <= number <= maximum:
61
+ fail(f"{key} must be a finite number between {minimum} and {maximum}")
62
+ return number
63
+
64
+
65
+ def validate_invoke_url(raw: object) -> None:
66
+ if not isinstance(raw, str):
67
+ fail("invoke_url must be an absolute http(s) URL")
68
+ try:
69
+ url = urlsplit(raw.strip())
70
+ _ = url.port # Force validation of malformed/out-of-range ports.
71
+ except ValueError as exc:
72
+ fail(f"invoke_url is malformed: {exc}")
73
+ if url.scheme not in {"http", "https"} or not url.hostname:
74
+ fail("invoke_url must be an absolute http(s) URL")
75
+ if url.username or url.password:
76
+ fail("invoke_url must not contain credentials")
77
+ if url.query or url.fragment:
78
+ fail("invoke_url must not contain a query string or fragment")
79
+ hostname = url.hostname.casefold()
80
+ is_loopback = hostname == "localhost"
81
+ try:
82
+ is_loopback = is_loopback or ipaddress.ip_address(hostname).is_loopback
83
+ except ValueError:
84
+ pass
85
+ if url.scheme != "https" and not is_loopback:
86
+ fail("public invoke_url must use HTTPS; HTTP is allowed only for loopback development")
87
+
88
+
89
+ def main() -> int:
90
+ data = read_manifest()
91
+ required = (
92
+ "product_id",
93
+ "capability_id",
94
+ "name",
95
+ "invoke_url",
96
+ "publisher_id",
97
+ "provider_pubkey",
98
+ "price_per_call_usd",
99
+ "input_schema",
100
+ "output_schema",
101
+ )
102
+ missing = [key for key in required if data.get(key) in (None, "")]
103
+ if missing:
104
+ fail(f"missing required fields: {', '.join(missing)}")
105
+
106
+ if not isinstance(data["product_id"], str) or not SAFE_ID.fullmatch(data["product_id"]):
107
+ fail("product_id must be alphanumeric (dots, dashes, underscores allowed), max 128 chars")
108
+ if not isinstance(data["capability_id"], str) or not CAPABILITY_ID.fullmatch(data["capability_id"]):
109
+ fail("capability_id must look like my.tool@v1")
110
+ if not isinstance(data["name"], str) or not 1 <= len(data["name"].strip()) <= 128:
111
+ fail("name must contain 1-128 characters")
112
+
113
+ publisher_id = data["publisher_id"]
114
+ if not isinstance(publisher_id, str) or not PUBLISHER_ID.fullmatch(publisher_id):
115
+ fail("publisher_id must be a stable 1-128 character identifier")
116
+ if publisher_id.startswith(RESERVED_PUBLISHER_PREFIXES):
117
+ fail("publisher_id uses a reserved Hub bookkeeping prefix")
118
+
119
+ validate_invoke_url(data["invoke_url"])
120
+ finite_number(data, "price_per_call_usd", minimum=0.0, maximum=1000.0)
121
+ if "p50_latency_ms" in data:
122
+ finite_number(data, "p50_latency_ms", minimum=0.0, maximum=86_400_000.0)
123
+ if "success_rate_30d" in data:
124
+ finite_number(data, "success_rate_30d", minimum=0.0, maximum=1.0)
125
+
126
+ for key in ("input_schema", "output_schema"):
127
+ schema = data[key]
128
+ if not isinstance(schema, dict):
129
+ fail(f"{key} must be a JSON object")
130
+ if schema.get("type") != "object":
131
+ fail(f"{key}.type must be object")
132
+
133
+ try:
134
+ provider_key = base64.b64decode(str(data["provider_pubkey"]), validate=True)
135
+ except (ValueError, binascii.Error):
136
+ fail("provider_pubkey must be canonical base64")
137
+ if len(provider_key) != 32:
138
+ fail("provider_pubkey must encode exactly 32 Ed25519 public-key bytes")
139
+
140
+ print("capability.json is structurally ready for AIMarket Hub publish")
141
+ return 0
142
+
143
+
144
+ if __name__ == "__main__":
145
+ raise SystemExit(main())
@@ -0,0 +1,128 @@
1
+ Metadata-Version: 2.4
2
+ Name: create-aimarket-agent
3
+ Version: 0.1.0
4
+ Summary: Scaffold a minimal AIMarket Protocol v2 capability provider
5
+ Project-URL: Documentation, https://github.com/alexar76/create-aimarket-agent#readme
6
+ Project-URL: Issues, https://github.com/alexar76/create-aimarket-agent/issues
7
+ Project-URL: Repository, https://github.com/alexar76/create-aimarket-agent
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Requires-Python: >=3.11
11
+ Provides-Extra: dev
12
+ Requires-Dist: build>=1.2; extra == 'dev'
13
+ Requires-Dist: cryptography>=44; extra == 'dev'
14
+ Requires-Dist: fastapi>=0.115; extra == 'dev'
15
+ Requires-Dist: httpx>=0.27; extra == 'dev'
16
+ Requires-Dist: pytest-cov>=5; extra == 'dev'
17
+ Requires-Dist: pytest>=8; extra == 'dev'
18
+ Description-Content-Type: text/markdown
19
+
20
+ # create-aimarket-agent
21
+
22
+ <p align="center">
23
+ <strong>From an empty directory to a signed AIMarket Protocol v2 provider.</strong><br>
24
+ A safe, deterministic project generator with tests, Docker packaging, and explicit publishing.
25
+ </p>
26
+
27
+ <!-- aicom-readme-badges -->
28
+ <p align="center">
29
+ <a href="https://github.com/alexar76/create-aimarket-agent/actions/workflows/ci.yml"><img src="https://github.com/alexar76/create-aimarket-agent/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
30
+ <img src="https://img.shields.io/badge/python-%3E%3D3.11-3776AB" alt="Python >=3.11" />
31
+ <img src="https://img.shields.io/badge/tests-45%20passing-4c1" alt="45 tests passing" />
32
+ <img src="https://img.shields.io/badge/branch%20coverage-100%25-4c1" alt="100% branch coverage" />
33
+ <img src="https://img.shields.io/badge/docs-EN%20RU%20ES%20FR%20ZH-9c70ff" alt="Documentation in 5 languages" />
34
+ <img src="https://img.shields.io/badge/AIMarket-Protocol%20v2-35e7ff" alt="AIMarket Protocol v2" />
35
+ <img src="https://img.shields.io/badge/signing-Ed25519-8d83ff" alt="Ed25519 signing" />
36
+ <a href="https://github.com/alexar76/create-aimarket-agent/blob/main/LICENSE"><img src="https://raw.githubusercontent.com/alexar76/create-aimarket-agent/main/docs/badges/license.svg" alt="License: MIT" /></a>
37
+ </p>
38
+ <!-- /aicom-readme-badges -->
39
+
40
+ <p align="center">
41
+ <a href="README.md"><b>English</b></a> ·
42
+ <a href="docs/README.ru.md">Русский</a> ·
43
+ <a href="docs/README.es.md">Español</a> ·
44
+ <a href="docs/README.fr.md">Français</a> ·
45
+ <a href="docs/README.zh.md">中文</a> ·
46
+ <a href="https://github.com/alexar76/aicom/blob/main/docs/localization-glossary.md">Localization glossary</a>
47
+ </p>
48
+
49
+ ## Quick start
50
+
51
+ ```bash
52
+ uvx create-aimarket-agent my-agent --kind tool --metis
53
+ cd my-agent
54
+ uv sync --extra dev
55
+ uv run python configure_provider.py
56
+ uv run pytest
57
+ uv run python validate_manifest.py
58
+ uv run python agent.py
59
+ ```
60
+
61
+ The command, identifiers, flags, and generated filenames are stable in every language.
62
+
63
+ ## Generated repository
64
+
65
+ | Path | Role |
66
+ |---|---|
67
+ | `agent.py` | FastAPI health and invoke endpoints |
68
+ | `capability.json` | AIMarket Protocol v2 capability manifest |
69
+ | `provider_signing.py` | Persistent Ed25519 identity and request-bound response signing |
70
+ | `configure_provider.py` | Writes the public provider key into the manifest atomically |
71
+ | `validate_manifest.py` | Fail-closed structural validation before publishing |
72
+ | `test_agent.py` | API, request-bound signature, and request-size tests |
73
+ | `Dockerfile` · `.dockerignore` | Non-root container packaging with persistent key volume |
74
+ | `.github/workflows/test.yml` | Generated-project CI |
75
+
76
+ ## Security model
77
+
78
+ - Scaffolding is atomic: a failed copy or replacement leaves no partial target repository.
79
+ - Project names are allow-listed before they enter source or JSON files.
80
+ - The provider key is a 32-byte Ed25519 seed stored with mode `0600`; symlinks and non-regular files are rejected.
81
+ - Responses sign a canonical request-bound envelope containing `capability_id`, `product_id`, the SHA-256 input digest, and the result. This prevents replay across requests.
82
+ - The generated service accepts only the product and capability identity declared in its manifest,
83
+ so an untrusted caller cannot make the provider key sign another identity.
84
+ - The manifest validator rejects duplicate JSON keys, malformed Ed25519 keys, non-finite prices,
85
+ malformed URLs, and public HTTP endpoints before the publish step.
86
+ - The default invoke endpoint rejects missing, malformed, or oversized request bodies.
87
+ - Publishing is never automatic. Stake, publisher identity, trust policy, and Hub registration remain explicit operator actions.
88
+
89
+ ## Project kinds
90
+
91
+ ```bash
92
+ create-aimarket-agent my-tool --kind tool
93
+ create-aimarket-agent my-data --kind data-provider
94
+ create-aimarket-agent my-orchestrator --kind orchestrator
95
+ create-aimarket-agent my-agent --no-metis
96
+ ```
97
+
98
+ ## Docker
99
+
100
+ The generated image runs as an unprivileged user, includes a health check, listens on
101
+ `0.0.0.0:8080`, and stores the provider key in `/data/provider.key`.
102
+
103
+ ```bash
104
+ docker build -t my-agent .
105
+ docker run --read-only --tmpfs /tmp -p 127.0.0.1:8080:8080 -v my-agent-key:/data my-agent
106
+ ```
107
+
108
+ Back up the provider key before publishing. Replacing it invalidates the `provider_pubkey`
109
+ registered with the Hub. Keep the port loopback-only until an HTTPS ingress provides production
110
+ traffic, concurrency, and rate limits; the response signature does not authorize direct callers.
111
+
112
+ ## Development of this generator
113
+
114
+ ```bash
115
+ uv sync --extra dev
116
+ uv run pytest
117
+ uv build --wheel
118
+ ```
119
+
120
+ ## Localization contract
121
+
122
+ Documentation is available in English, Russian, Spanish, French, and Chinese and follows the
123
+ canonical [AICOM localization glossary](https://github.com/alexar76/aicom/blob/main/docs/localization-glossary.md).
124
+ Code, commands, flags, filenames, API fields, env vars, brands, `LIVE`, and `SIM` are never translated.
125
+
126
+ ## License
127
+
128
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,21 @@
1
+ create_aimarket_agent/__init__.py,sha256=a3hVErWOI8xlZ919MlT0i1zi8BnS9KD3rUJjmK4F0q0,65
2
+ create_aimarket_agent/cli.py,sha256=WMhT53kCbWiLUVy6N3AN4XihOI1WfbN_6C44BU2J4_4,3480
3
+ create_aimarket_agent/templates/base/.dockerignore,sha256=L2U65N_3GNGvPw8vrY3n_EXIz4tXfuNSGql2DRih6d0,92
4
+ create_aimarket_agent/templates/base/.env.example,sha256=n1vI7J0hLF-zZI2YmB4NTbZ01A6P2HGlBquW-gDDEks,150
5
+ create_aimarket_agent/templates/base/.gitignore,sha256=UMQI127xBnYS8xS2wwbJ-fk-FdNjCJGvMmfyrn7Au2s,51
6
+ create_aimarket_agent/templates/base/Dockerfile,sha256=qhr3gzGsl0EoCkUsXN-HuAMIF99mVkrdw4tspDnoE9w,514
7
+ create_aimarket_agent/templates/base/Makefile,sha256=2dPtuBw4xyiW9cAY6tcM44Q_hZTtzv-y-hnl83yWeZc,356
8
+ create_aimarket_agent/templates/base/README.md,sha256=ZrEksAf94rKQqLvkKzZQOomPwBF55tC6i9DfC3_1kOQ,1311
9
+ create_aimarket_agent/templates/base/agent.py,sha256=X4s-9C1qOl3L8sHprVAGKlXLg_ervcQ-96MC2YWuXKQ,3033
10
+ create_aimarket_agent/templates/base/capability.json,sha256=bcJW_7hF4q36UGMmrS7oGZ5I9l4VoZm4M7ntxoBsql4,505
11
+ create_aimarket_agent/templates/base/configure_provider.py,sha256=yNSBGTgKRYFKbvjHjz_aXf315GIp7Ocjst7YerU3rLw,630
12
+ create_aimarket_agent/templates/base/provider_signing.py,sha256=RelBQ_CagtB1J7nCd8veT78apXYZ1reXUwpZRart74c,3129
13
+ create_aimarket_agent/templates/base/pyproject.toml,sha256=jqR_v-ampGkB_g1DwFsAA7GIdIlaFvkJGMHfSyy29eo,456
14
+ create_aimarket_agent/templates/base/test_agent.py,sha256=AJi2exDGnRjy2zerh7iYAhBSJKrWsXgmqfX3ZYBDbyI,2931
15
+ create_aimarket_agent/templates/base/validate_manifest.py,sha256=pclbnQzlpDqpo2GMbA6LfNJqkyYaJ3AIpc2iUhOI9IQ,5401
16
+ create_aimarket_agent/templates/base/.github/workflows/test.yml,sha256=8Dk4cul0DNP5714U4e2_CJqRzd90ATRMrQWNf0hR2RU,447
17
+ create_aimarket_agent-0.1.0.dist-info/METADATA,sha256=rTSCbvdAnvy2Q-NU7ZQjbv_9DdJIwR2-BHGY7BAUzCM,5735
18
+ create_aimarket_agent-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
19
+ create_aimarket_agent-0.1.0.dist-info/entry_points.txt,sha256=QWlRLMJna5icJZQA1K-da6zlBlnMnqAHmI40W9_rWik,73
20
+ create_aimarket_agent-0.1.0.dist-info/licenses/LICENSE,sha256=fxsRdr6-VWt-7ybEuLwV-2l0amNFfK0HNh1NrqUDKjU,816
21
+ create_aimarket_agent-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ create-aimarket-agent = create_aimarket_agent.cli:main
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AICOM Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.