440 lines
14 KiB
Python
440 lines
14 KiB
Python
"""``a2a`` CLI: scaffold, validate, build, deploy."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from importlib import resources
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import typer
|
|
import yaml
|
|
from rich.console import Console
|
|
from rich.panel import Panel
|
|
|
|
from . import credentials
|
|
from .api_client import ApiError, ControlPlaneClient
|
|
from .loader import load_agent_class
|
|
from .manifests import INGRESS_HOST_TEMPLATE, NAMESPACE, render_manifests
|
|
|
|
app = typer.Typer(
|
|
add_completion=False,
|
|
no_args_is_help=True,
|
|
help="Build, package, and deploy A2A agents.",
|
|
)
|
|
console = Console()
|
|
|
|
DEFAULT_REGISTRY_HOST = "registry.a2acloud.io"
|
|
SDK_PYPI_PACKAGE = "a2a_pack"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# helpers #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def _fail(msg: str, code: int = 1) -> None:
|
|
console.print(f"[bold red]error:[/] {msg}")
|
|
raise typer.Exit(code)
|
|
|
|
|
|
def _read_yaml(path: Path) -> dict[str, Any]:
|
|
if not path.exists():
|
|
_fail(f"missing {path.name} (run `a2a init` first)")
|
|
return yaml.safe_load(path.read_text()) or {}
|
|
|
|
|
|
def _render_template(template: str, /, **vars: Any) -> str:
|
|
text = (
|
|
resources.files("a2a_pack.cli.templates")
|
|
.joinpath(template)
|
|
.read_text(encoding="utf-8")
|
|
)
|
|
# tiny mustache-style substitution; avoids pulling Jinja just for {{ x }}
|
|
for k, v in vars.items():
|
|
text = text.replace("{{ " + k + " }}", str(v))
|
|
return text
|
|
|
|
|
|
def _slug_to_class(slug: str) -> str:
|
|
parts = re.split(r"[-_\s]+", slug.strip())
|
|
return "".join(p[:1].upper() + p[1:].lower() for p in parts if p) or "MyAgent"
|
|
|
|
|
|
def _git_short_sha(project: Path) -> str | None:
|
|
try:
|
|
out = subprocess.run(
|
|
["git", "-C", str(project), "rev-parse", "--short=12", "HEAD"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
).stdout.strip()
|
|
return out or None
|
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
return None
|
|
|
|
|
|
def _sdk_source_dir() -> Path:
|
|
"""Locate the on-disk a2a_pack source for inclusion in the build context."""
|
|
pkg_dir = Path(__file__).resolve().parents[1] # .../a2a_pack
|
|
project_root = pkg_dir.parent # .../apps/a2a
|
|
if not (project_root / "pyproject.toml").exists():
|
|
_fail(f"could not find a2a-pack source root from {pkg_dir}")
|
|
return project_root
|
|
|
|
|
|
def _run(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
|
|
console.print(f"[dim]$ {' '.join(cmd)}[/]")
|
|
return subprocess.run(cmd, check=True, text=True, **kwargs)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# auth #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def _client(api: str | None = None) -> ControlPlaneClient:
|
|
api_url = credentials.resolve_api_url(api)
|
|
creds = credentials.load()
|
|
token = creds.token if creds is not None else None
|
|
return ControlPlaneClient(api_url, token=token)
|
|
|
|
|
|
@app.command()
|
|
def signup(
|
|
email: str = typer.Option(..., "--email", "-e", prompt=True),
|
|
password: str = typer.Option(
|
|
..., "--password", "-p", prompt=True, hide_input=True, confirmation_prompt=True
|
|
),
|
|
api: str = typer.Option(credentials.DEFAULT_API_URL, "--api"),
|
|
) -> None:
|
|
"""Create an account on the control plane and store the JWT locally."""
|
|
try:
|
|
out = ControlPlaneClient(api).signup(email, password)
|
|
except ApiError as exc:
|
|
_fail(str(exc))
|
|
credentials.save(api, out["access_token"], out["user"]["email"])
|
|
console.print(f"[green]signed up[/] as [cyan]{out['user']['email']}[/] @ {api}")
|
|
|
|
|
|
@app.command()
|
|
def login(
|
|
email: str = typer.Option(..., "--email", "-e", prompt=True),
|
|
password: str = typer.Option(..., "--password", "-p", prompt=True, hide_input=True),
|
|
api: str = typer.Option(credentials.DEFAULT_API_URL, "--api"),
|
|
) -> None:
|
|
"""Authenticate with the control plane and cache the JWT."""
|
|
try:
|
|
out = ControlPlaneClient(api).login(email, password)
|
|
except ApiError as exc:
|
|
_fail(str(exc))
|
|
credentials.save(api, out["access_token"], out["user"]["email"])
|
|
console.print(f"[green]logged in[/] as [cyan]{out['user']['email']}[/]")
|
|
|
|
|
|
@app.command()
|
|
def logout() -> None:
|
|
"""Forget the cached JWT."""
|
|
cleared = credentials.clear()
|
|
console.print("[green]logged out[/]" if cleared else "(not logged in)")
|
|
|
|
|
|
@app.command()
|
|
def whoami() -> None:
|
|
"""Show the currently logged-in user."""
|
|
creds = credentials.load()
|
|
if creds is None:
|
|
_fail("not logged in (run `a2a login` or `a2a signup`)")
|
|
try:
|
|
me = _client().me()
|
|
except ApiError as exc:
|
|
_fail(str(exc))
|
|
console.print(f"[cyan]{me['email']}[/] ({creds.api_url})")
|
|
|
|
|
|
@app.command(name="agents")
|
|
def list_agents(
|
|
api: str | None = typer.Option(None, "--api"),
|
|
) -> None:
|
|
"""List agents visible to the current user."""
|
|
try:
|
|
rows = _client(api).list_agents()
|
|
except ApiError as exc:
|
|
_fail(str(exc))
|
|
if not rows:
|
|
console.print("(no agents)")
|
|
return
|
|
for r in rows:
|
|
url = r.get("url") or "-"
|
|
console.print(
|
|
f" [cyan]{r['name']}[/] v{r['version']} [{r['status']}] {url}"
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# init #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
@app.command()
|
|
def init(
|
|
name: str = typer.Argument(..., help="Agent / project slug, e.g. research-agent"),
|
|
description: str = typer.Option("A new A2A agent", "--description", "-d"),
|
|
target: Path = typer.Option(Path("."), "--target", "-t", help="Parent dir for the new project"),
|
|
) -> None:
|
|
"""Scaffold a new agent project."""
|
|
project = target / name
|
|
if project.exists():
|
|
_fail(f"{project} already exists")
|
|
project.mkdir(parents=True)
|
|
class_name = _slug_to_class(name)
|
|
|
|
files = {
|
|
"agent.py": _render_template(
|
|
"agent.py.tmpl",
|
|
name=name,
|
|
class_name=class_name,
|
|
description=description,
|
|
),
|
|
"a2a.yaml": _render_template(
|
|
"a2a.yaml.tmpl", name=name, class_name=class_name
|
|
),
|
|
"requirements.txt": _render_template("requirements.txt.tmpl"),
|
|
}
|
|
for relpath, content in files.items():
|
|
target_path = project / relpath
|
|
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
target_path.write_text(content)
|
|
console.print(f" [green]+[/] {project}/{relpath}")
|
|
|
|
console.print(
|
|
Panel.fit(
|
|
f"[bold]{name}[/] scaffolded at [cyan]{project}[/]\n\n"
|
|
"next:\n"
|
|
f" cd {project}\n"
|
|
" a2a card # see what your agent declares\n"
|
|
" a2a deploy # ship it",
|
|
title="ok",
|
|
)
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# validate / card / run #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
@app.command()
|
|
def validate(
|
|
project: Path = typer.Option(Path("."), "--project", "-p"),
|
|
) -> None:
|
|
"""Load the agent and print its Card schema. Exits non-zero on errors."""
|
|
cfg = _read_yaml(project / "a2a.yaml")
|
|
cls = load_agent_class(cfg["entrypoint"], project_dir=project)
|
|
console.print(f"[green]ok[/] {cls.name} v{cls.version} ({len(cls._skills)} skills)")
|
|
|
|
|
|
@app.command()
|
|
def card(
|
|
project: Path = typer.Option(Path("."), "--project", "-p"),
|
|
) -> None:
|
|
"""Print the Agent Card JSON for the project's agent."""
|
|
cfg = _read_yaml(project / "a2a.yaml")
|
|
cls = load_agent_class(cfg["entrypoint"], project_dir=project)
|
|
console.print_json(cls().card().model_dump_json())
|
|
|
|
|
|
@app.command()
|
|
def run(
|
|
entrypoint: str = typer.Option(..., "--entrypoint", "-e", help="module:Class"),
|
|
host: str = typer.Option("0.0.0.0", "--host"),
|
|
port: int = typer.Option(8000, "--port"),
|
|
project: Path = typer.Option(Path("."), "--project", "-p"),
|
|
) -> None:
|
|
"""Run the agent's HTTP server locally (used inside the container too)."""
|
|
from ..serve import serve
|
|
|
|
cls = load_agent_class(entrypoint, project_dir=project)
|
|
serve(cls(), host=host, port=port)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# build / deploy #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def _resolve_image_ref(name: str, version: str, project: Path, registry: str) -> str:
|
|
sha = _git_short_sha(project)
|
|
tag = f"{version}-{sha}" if sha else version
|
|
return f"{registry}/agents/{name}:{tag}"
|
|
|
|
|
|
def _stage_build_context(project: Path, dst: Path) -> None:
|
|
"""Copy project files + bundled SDK source into a temp build dir."""
|
|
for item in project.iterdir():
|
|
if item.name in {".git", ".venv", "__pycache__", "_a2a_sdk"}:
|
|
continue
|
|
target = dst / item.name
|
|
if item.is_dir():
|
|
shutil.copytree(item, target, ignore=shutil.ignore_patterns("__pycache__"))
|
|
else:
|
|
shutil.copy2(item, target)
|
|
sdk_src = _sdk_source_dir()
|
|
sdk_dst = dst / "_a2a_sdk"
|
|
shutil.copytree(
|
|
sdk_src,
|
|
sdk_dst,
|
|
ignore=shutil.ignore_patterns(".venv", "dist", "build", "__pycache__", "*.egg-info", ".pytest_cache"),
|
|
)
|
|
|
|
|
|
@app.command()
|
|
def build(
|
|
project: Path = typer.Option(Path("."), "--project", "-p"),
|
|
registry: str = typer.Option(DEFAULT_REGISTRY_HOST, "--registry"),
|
|
push: bool = typer.Option(False, "--push", help="Also push the built image"),
|
|
) -> None:
|
|
"""Build (and optionally push) the container image for the agent."""
|
|
cfg = _read_yaml(project / "a2a.yaml")
|
|
image = _resolve_image_ref(cfg["name"], cfg["version"], project, registry)
|
|
|
|
with tempfile.TemporaryDirectory(prefix="a2a-build-") as tmp:
|
|
ctx = Path(tmp)
|
|
_stage_build_context(project, ctx)
|
|
_run(["docker", "build", "-t", image, str(ctx)])
|
|
|
|
console.print(f"[green]built[/] [cyan]{image}[/]")
|
|
if push:
|
|
_run(["docker", "push", image])
|
|
console.print(f"[green]pushed[/] [cyan]{image}[/]")
|
|
|
|
|
|
def _make_tarball(project: Path) -> bytes:
|
|
"""Tar.gz the user's project directory.
|
|
|
|
Excludes platform/dev artifacts so build images stay small. The
|
|
platform stamps in Dockerfile/workflow/manifests on the server side.
|
|
"""
|
|
import io
|
|
import tarfile
|
|
|
|
excluded_dirs = {
|
|
"__pycache__",
|
|
".venv",
|
|
".git",
|
|
".pytest_cache",
|
|
".mypy_cache",
|
|
"node_modules",
|
|
"dist",
|
|
"build",
|
|
".gitea",
|
|
"deploy",
|
|
}
|
|
excluded_files = {"Dockerfile", ".dockerignore"}
|
|
|
|
buf = io.BytesIO()
|
|
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
|
|
for root, dirs, files in os.walk(project):
|
|
dirs[:] = [d for d in dirs if d not in excluded_dirs]
|
|
for fname in files:
|
|
if fname in excluded_files or fname.endswith(".pyc"):
|
|
continue
|
|
fpath = Path(root) / fname
|
|
arcname = fpath.relative_to(project)
|
|
tar.add(fpath, arcname=str(arcname))
|
|
return buf.getvalue()
|
|
|
|
|
|
def _wait_for_url(url: str, timeout: int = 180) -> bool:
|
|
import time
|
|
|
|
import httpx
|
|
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
with httpx.Client(timeout=3.0) as c:
|
|
if c.get(f"{url}/healthz").status_code == 200:
|
|
return True
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
time.sleep(4)
|
|
return False
|
|
|
|
|
|
@app.command()
|
|
def deploy(
|
|
project: Path = typer.Option(Path("."), "--project", "-p"),
|
|
public: bool | None = typer.Option(None, "--public/--private"),
|
|
wait: bool = typer.Option(True, "--wait/--no-wait", help="Poll until URL is live"),
|
|
api: str | None = typer.Option(None, "--api", help="Override control plane URL"),
|
|
) -> None:
|
|
"""Ship the agent.
|
|
|
|
Tarballs your source, uploads to the control plane, and prints the
|
|
URL when it's live. No local docker. No git. No knowledge of how the
|
|
platform builds or deploys.
|
|
"""
|
|
creds = credentials.load()
|
|
if creds is None:
|
|
_fail("not logged in (run `a2a signup` or `a2a login`)")
|
|
|
|
cfg = _read_yaml(project / "a2a.yaml")
|
|
cls = load_agent_class(cfg["entrypoint"], project_dir=project)
|
|
is_public = (
|
|
public if public is not None else bool(cfg.get("expose", {}).get("public", True))
|
|
)
|
|
description = cfg.get("description", cls.description or "")
|
|
|
|
console.print("[dim]packaging source...[/]")
|
|
tarball = _make_tarball(project)
|
|
size_kb = len(tarball) / 1024
|
|
console.print(f"[dim]uploading {size_kb:.1f}KB to {credentials.resolve_api_url(api)}...[/]")
|
|
|
|
client = _client(api)
|
|
try:
|
|
out = client.from_tarball(
|
|
name=cls.name,
|
|
version=cfg["version"],
|
|
entrypoint=cfg["entrypoint"],
|
|
description=description,
|
|
public=is_public,
|
|
tarball=tarball,
|
|
)
|
|
except ApiError as exc:
|
|
_fail(str(exc))
|
|
|
|
summary = {
|
|
"agent": out["name"],
|
|
"version": out["version"],
|
|
"status": out["status"],
|
|
"url": out.get("url"),
|
|
}
|
|
console.print(
|
|
Panel.fit(
|
|
json.dumps(summary, indent=2),
|
|
title="[bold green]shipped[/]",
|
|
)
|
|
)
|
|
|
|
url = out.get("url")
|
|
if wait and url:
|
|
console.print(f"[dim]waiting for {url} ...[/]")
|
|
ready = _wait_for_url(url)
|
|
if ready:
|
|
console.print(f"[green]live[/]: {url}")
|
|
else:
|
|
console.print(
|
|
f"[yellow]still building[/]; check `a2a agents` or curl {url} in a bit"
|
|
)
|
|
|
|
|
|
# Used as `python -m a2a_pack.cli.main`
|
|
if __name__ == "__main__": # pragma: no cover
|
|
app()
|