"""``a2a`` CLI: scaffold, validate, build, deploy.""" from __future__ import annotations import json 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 = "localhost:30500" 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"), "Dockerfile": _render_template( "Dockerfile.tmpl", entrypoint=f"agent:{class_name}" ), ".dockerignore": _render_template("dockerignore.tmpl"), ".gitea/workflows/build.yml": _render_template( "workflow.yml.tmpl", name=name ), "deploy/20-deployment.yaml": _render_template( "deployment.yaml.tmpl", name=name ), } 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 _git_push_source(project: Path, push_url: str) -> None: """Initialize the project as a git repo (if needed) and push to ``push_url``. Idempotent: re-running on an existing repo just commits any changes and pushes. """ git_dir = project / ".git" if not git_dir.exists(): _run(["git", "-C", str(project), "init", "-q", "-b", "main"]) _run(["git", "-C", str(project), "config", "user.email", "agent@a2a.local"]) _run(["git", "-C", str(project), "config", "user.name", "agent"]) # Ensure remote have_remote = subprocess.run( ["git", "-C", str(project), "remote", "get-url", "origin"], capture_output=True, text=True, check=False, ).returncode == 0 if have_remote: _run(["git", "-C", str(project), "remote", "set-url", "origin", push_url]) else: _run(["git", "-C", str(project), "remote", "add", "origin", push_url]) _run(["git", "-C", str(project), "add", "-A"]) status = subprocess.run( ["git", "-C", str(project), "status", "--porcelain"], capture_output=True, text=True, check=True, ).stdout.strip() if status: _run(["git", "-C", str(project), "commit", "-q", "-m", "deploy"]) # Pull --rebase first to integrate any auto-bump commits the runner pushed back. subprocess.run( ["git", "-C", str(project), "pull", "--rebase", "origin", "main"], capture_output=True, text=True, check=False, ) _run(["git", "-C", str(project), "push", "-u", "origin", "main"]) @app.command() def deploy( project: Path = typer.Option(Path("."), "--project", "-p"), public: bool | None = typer.Option(None, "--public/--private"), api: str | None = typer.Option(None, "--api", help="Override control plane URL"), ) -> None: """Push source to gitea; the platform builds + deploys. No local docker. The CLI: 1. asks the control plane to provision a gitea repo + ArgoCD app 2. ``git push``es the project source to that repo The Gitea Actions runner builds the image and ArgoCD reconciles ``deploy/`` onto the cluster. Returns the public URL once registered. """ 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 "") if not (project / "deploy").exists() or not (project / ".gitea/workflows/build.yml").exists(): _fail( "project missing deploy/ or .gitea/workflows/. " "Re-run `a2a init` or upgrade the scaffold." ) client = _client(api) console.print(f"[dim]asking control plane to provision repo + argo app...[/]") try: prov = client.from_source( name=cls.name, description=description, version=cfg["version"], public=is_public, ) except ApiError as exc: _fail(str(exc)) console.print(f"[dim]pushing source → {prov['repo_url']}[/]") _git_push_source(project, prov["push_url"]) summary = { "agent": prov["name"], "repo": prov["repo_url"], "url": prov.get("expected_url"), "card": f"{prov['expected_url']}/.well-known/agent-card" if prov.get("expected_url") else None, "next": "the runner is building. `a2a agents` to check status, or curl the url in ~30s", } console.print( Panel.fit( json.dumps(summary, indent=2), title="[bold green]shipped[/]", ) ) # Used as `python -m a2a_pack.cli.main` if __name__ == "__main__": # pragma: no cover app()