initial a2a-pack
This commit is contained in:
430
a2a_pack/cli/main.py
Normal file
430
a2a_pack/cli/main.py
Normal file
@@ -0,0 +1,430 @@
|
||||
"""``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()
|
||||
Reference in New Issue
Block a user