"""``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 Annotated, 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 .chat_harness import normalize_local_llm_env, write_chat_compose
from .loader import load_agent_class
from .oauth_login import (
    DEFAULT_OAUTH_CLIENT_ID,
    DEFAULT_OAUTH_ISSUER,
    DEFAULT_OAUTH_SCOPE,
    DEFAULT_REDIRECT_PORT,
    login_with_access_token,
    login_with_browser,
    refresh_credentials_if_needed,
)
from ..frontend import (
    export_frontend_env,
    load_frontend_config,
    packed_frontend_from_env,
    resolve_packed_frontend,
)
from ..dsl import AgentDsl, compile_agent_to_dsl
from ..runtime import LLMProvisioning, apply_project_manifest
from .local import (
    ensure_local_workspace,
    load_env_file,
    load_local_project,
    run_preflight_sync,
)
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.",
)
frontend_app = typer.Typer(
    add_completion=False,
    no_args_is_help=True,
    help="Build and inspect packed frontend apps.",
)
app.add_typer(frontend_app, name="frontend")
auth_app = typer.Typer(
    add_completion=False,
    no_args_is_help=True,
    help="Manage imported agent auth.",
)
app.add_typer(auth_app, name="auth")
openapi_app = typer.Typer(
    add_completion=False,
    no_args_is_help=True,
    help="Generate editable agents from OpenAPI specs.",
)
app.add_typer(openapi_app, name="openapi")
console = Console()

DEFAULT_REGISTRY_HOST = "registry.a2acloud.io"
DEFAULT_AGENT_BASE_IMAGE = "python:3.11-slim"
SDK_PYPI_PACKAGE = "a2a_pack"
PETSTORE_OPENAPI_URL = "https://petstore3.swagger.io/api/v3/openapi.json"


# --------------------------------------------------------------------------- #
# helpers                                                                     #
# --------------------------------------------------------------------------- #


def _fail(msg: str, code: int = 1) -> None:
    console.print(f"[bold red]error:[/] {msg}")
    raise typer.Exit(code)


def _api_error_message(exc: ApiError) -> str:
    detail = exc.detail
    if isinstance(detail, dict) and detail.get("error") == "plan_limit":
        message = str(detail.get("message") or exc.message)
        feature = str(detail.get("feature") or "").replace("_", " ")
        upgrade_url = str(detail.get("upgrade_url") or "").strip()
        prefix = f"plan limit reached for {feature}: " if feature else "plan limit reached: "
        if upgrade_url:
            return f"{prefix}{message}\nUpgrade: {upgrade_url}"
        return f"{prefix}{message}"
    return str(exc)


def _read_yaml(path: Path) -> dict[str, Any]:
    if not path.exists():
        _fail(f"missing {path.name} (run `a2a init NAME` first)")
    return yaml.safe_load(path.read_text()) or {}


def _read_yaml_optional(path: Path) -> dict[str, Any]:
    if not path.exists():
        return {}
    data = yaml.safe_load(path.read_text()) or {}
    return data if isinstance(data, dict) else {}


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)}[/]")
    if cmd and cmd[0] == "docker" and "env" not in kwargs:
        docker_desktop_bin = Path("/Applications/Docker.app/Contents/Resources/bin")
        if docker_desktop_bin.is_dir():
            env = os.environ.copy()
            env["PATH"] = f"{docker_desktop_bin}:{env.get('PATH', '')}"
            kwargs["env"] = env
    return subprocess.run(cmd, check=True, text=True, **kwargs)


def _clean_init_auth(raw: Any) -> str:
    value = str(raw or "inherit").strip().lower()
    if value not in {"inherit", "platform", "public"}:
        _fail("--auth must be one of: inherit, platform, public")
    return value


def _auth_template_type(auth: str) -> str:
    return "PlatformUserAuth" if auth == "platform" else "NoAuth"


def _clean_init_language(raw: Any) -> str:
    value = str(raw or "python").strip().lower()
    if value in {"ts", "node"}:
        value = "typescript"
    if value in {"js", "nodejs"}:
        value = "javascript"
    if value not in {"python", "typescript", "javascript", "go", "rust"}:
        _fail("--language must be one of: python, typescript, javascript, go, rust")
    return value


def _frontend_block(kind: str | None, *, auth: str = "inherit") -> str:
    if not kind:
        return ""
    normalized = kind.strip().lower()
    if normalized == "static":
        return (
            "\nfrontend:\n"
            "  path: frontend\n"
            "  dist: dist\n"
            "  mount: /app\n"
            f"  auth: {auth}\n"
        )
    if normalized == "react":
        return (
            "\nfrontend:\n"
            "  path: frontend\n"
            "  build: npm run build\n"
            "  dist: dist\n"
            "  mount: /app\n"
            f"  auth: {auth}\n"
        )
    if normalized == "nextjs":
        return (
            "\nfrontend:\n"
            "  type: static-spa\n"
            "  path: frontend\n"
            "  build: npm run build\n"
            "  dist: out\n"
            "  mount: /app\n"
            f"  auth: {auth}\n"
        )
    _fail("--frontend must be 'static', 'react', or 'nextjs'")
    return ""


def _copy_resource_tree(package: str, source: str, target: Path) -> None:
    root = resources.files(package).joinpath(source)
    if not root.is_dir():
        _fail(f"missing packaged template resource: {package}/{source}")
    target.mkdir(parents=True, exist_ok=True)
    for item in root.iterdir():
        dest = target / item.name
        if item.is_dir():
            _copy_resource_tree(package, f"{source}/{item.name}", dest)
        else:
            dest.write_bytes(item.read_bytes())


def _frontend_files(kind: str | None, name: str) -> dict[str, str]:
    if not kind:
        return {}
    normalized = kind.strip().lower()
    if normalized == "static":
        return {
            "frontend/dist/index.html": _render_template(
                "frontend/static-index.html.tmpl",
                name=name,
            ),
        }
    if normalized == "react":
        return {
            "frontend/package.json": _render_template(
                "frontend/react-package.json.tmpl",
                name=name,
            ),
            "frontend/index.html": _render_template(
                "frontend/react-index.html.tmpl",
                name=name,
            ),
            "frontend/vite.config.js": _render_template(
                "frontend/react-vite.config.js.tmpl",
            ),
            "frontend/src/main.jsx": _render_template(
                "frontend/react-main.jsx.tmpl",
                name=name,
            ),
            "frontend/src/App.jsx": _render_template(
                "frontend/react-app.jsx.tmpl",
                name=name,
            ),
            "frontend/src/a2a.js": _render_template(
                "frontend/react-a2a.js.tmpl",
            ),
            "frontend/src/style.css": _render_template(
                "frontend/react-style.css.tmpl",
                name=name,
            ),
        }
    if normalized == "nextjs":
        return {
            "frontend/package.json": _render_template(
                "frontend/next-package.json.tmpl",
                name=name,
            ),
            "frontend/next.config.mjs": _render_template(
                "frontend/next-config.mjs.tmpl",
            ),
            "frontend/jsconfig.json": _render_template(
                "frontend/next-jsconfig.json.tmpl",
            ),
            "frontend/app/layout.jsx": _render_template(
                "frontend/next-layout.jsx.tmpl",
                name=name,
            ),
            "frontend/app/page.jsx": _render_template(
                "frontend/next-page.jsx.tmpl",
                name=name,
            ),
            "frontend/app/globals.css": _render_template(
                "frontend/next-globals.css.tmpl",
                name=name,
            ),
            "frontend/public/.gitkeep": "",
        }
    _fail("--frontend must be 'static', 'react', or 'nextjs'")
    return {}


def _frontend_dockerfile_blocks(cfg: dict[str, Any]) -> tuple[str, str]:
    """Return ``(stage, runtime)`` Dockerfile snippets for local builds."""

    try:
        frontend = load_frontend_config(Path("."), cfg)
    except ValueError:
        return "", ""
    if frontend is None:
        return "", ""
    dist_env = "/app/.a2a/frontend"
    runtime = "\n".join(
        [
            f"ENV A2A_FRONTEND_DIST={dist_env}",
            f"ENV A2A_FRONTEND_MOUNT={frontend.mount}",
            f"ENV A2A_FRONTEND_AUTH={frontend.auth}",
            f"ENV A2A_FRONTEND_DOCS_URL={frontend.docs_url}",
        ]
    )
    if frontend.build:
        stage = (
            "FROM node:20-bookworm-slim AS frontend-build\n"
            "WORKDIR /frontend\n"
            f"COPY {frontend.path}/ ./\n"
            "RUN if [ -f package-lock.json ]; then npm ci; "
            "elif [ -f yarn.lock ]; then corepack enable && yarn install --frozen-lockfile; "
            "elif [ -f pnpm-lock.yaml ]; then corepack enable && pnpm install --frozen-lockfile; "
            "else npm install; fi\n"
            f"RUN {frontend.build}\n\n"
        )
        source_dist = f"{frontend.path}/{frontend.dist}".strip("/")
        runtime = (
            f"COPY --from=frontend-build /frontend/{frontend.dist} {source_dist}\n"
            f"COPY --from=frontend-build /frontend/{frontend.dist} {dist_env}\n"
            f"{runtime}"
        )
        return stage, f"\n{runtime}\n"
    source_dist = f"{frontend.path}/{frontend.dist}".strip("/")
    runtime = f"COPY {source_dist}/ {dist_env}/\n{runtime}"
    return "", f"\n{runtime}\n"


# --------------------------------------------------------------------------- #
# auth                                                                        #
# --------------------------------------------------------------------------- #


def _client(api: str | None = None) -> ControlPlaneClient:
    api_url = credentials.resolve_api_url(api)
    creds = credentials.load()
    if creds is not None:
        try:
            creds = refresh_credentials_if_needed(creds)
        except RuntimeError as exc:
            _fail(str(exc))
    token = creds.token if creds is not None else None

    def refresh_token() -> str | None:
        latest = credentials.load()
        if latest is None:
            return None
        try:
            refreshed = refresh_credentials_if_needed(latest, force=True)
        except RuntimeError as exc:
            _fail(str(exc))
        return refreshed.token

    return ControlPlaneClient(api_url, token=token, refresh_token=refresh_token)


@app.command()
def signup(
    api: str = typer.Option(credentials.DEFAULT_API_URL, "--api"),
    issuer: str = typer.Option(DEFAULT_OAUTH_ISSUER, "--issuer"),
    client_id: str = typer.Option(DEFAULT_OAUTH_CLIENT_ID, "--client-id"),
    scope: str = typer.Option(DEFAULT_OAUTH_SCOPE, "--scope"),
    port: int = typer.Option(DEFAULT_REDIRECT_PORT, "--port"),
    token: str | None = typer.Option(None, "--token", help="Use an existing Keycloak access token"),
    open_browser: bool = typer.Option(True, "--open/--no-open", help="Open the login URL"),
) -> None:
    """Create or sign into a Keycloak account and cache OAuth tokens."""
    _keycloak_login(
        api=api,
        issuer=issuer,
        client_id=client_id,
        scope=scope,
        port=port,
        token=token,
        open_browser=open_browser,
        verb="signed in",
    )


@app.command()
def login(
    api: str = typer.Option(credentials.DEFAULT_API_URL, "--api"),
    issuer: str = typer.Option(DEFAULT_OAUTH_ISSUER, "--issuer"),
    client_id: str = typer.Option(DEFAULT_OAUTH_CLIENT_ID, "--client-id"),
    scope: str = typer.Option(DEFAULT_OAUTH_SCOPE, "--scope"),
    port: int = typer.Option(DEFAULT_REDIRECT_PORT, "--port"),
    token: str | None = typer.Option(None, "--token", help="Use an existing Keycloak access token"),
    open_browser: bool = typer.Option(True, "--open/--no-open", help="Open the login URL"),
) -> None:
    """Authenticate with Keycloak and cache OAuth tokens."""
    _keycloak_login(
        api=api,
        issuer=issuer,
        client_id=client_id,
        scope=scope,
        port=port,
        token=token,
        open_browser=open_browser,
        verb="logged in",
    )


def _keycloak_login(
    *,
    api: str,
    issuer: str,
    client_id: str,
    scope: str,
    port: int,
    token: str | None,
    open_browser: bool,
    verb: str,
) -> None:
    api_url = credentials.resolve_api_url(api)
    try:
        if token:
            creds = login_with_access_token(
                token,
                api_url=api_url,
                issuer=issuer,
                client_id=client_id,
                scope=scope,
            )
        else:
            creds = login_with_browser(
                api_url=api_url,
                issuer=issuer,
                client_id=client_id,
                scope=scope,
                port=port,
                open_browser=open_browser,
                on_authorization_url=lambda url: console.print(
                    f"Open this URL to sign in with Keycloak:\n{url}"
                ),
            )
    except (ApiError, RuntimeError, TimeoutError) as exc:
        _fail(str(exc))
    console.print(f"[green]{verb}[/] as [cyan]{creds.email}[/] @ {api_url}")


@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}"
        )


@app.command(name="import")
def import_agent(
    url: str = typer.Argument(..., help="Base URL of an existing A2A agent"),
    name: str | None = typer.Option(None, "--name", "-n", help="Registry slug to use"),
    public: bool = typer.Option(False, "--public/--private"),
    auth_bearer: str | None = typer.Option(
        None,
        "--auth-bearer",
        help="Bearer token for the imported agent",
    ),
    auth_api_key: str | None = typer.Option(
        None,
        "--auth-api-key",
        help="API key value for the imported agent",
    ),
    auth_name: str | None = typer.Option(
        None,
        "--auth-name",
        help="API-key header or query parameter name",
    ),
    auth_location: str = typer.Option(
        "header",
        "--auth-location",
        help="API-key location: header or query",
    ),
    auth_scheme: str = typer.Option(
        "Bearer",
        "--auth-scheme",
        help="HTTP Authorization scheme",
    ),
    api: str | None = typer.Option(None, "--api"),
) -> None:
    """Import an already-running A2A agent into the registry."""
    auth = _import_auth_payload(
        auth_bearer=auth_bearer,
        auth_api_key=auth_api_key,
        auth_name=auth_name,
        auth_location=auth_location,
        auth_scheme=auth_scheme,
    )
    try:
        out = _client(api).import_agent(url=url, name=name, public=public, auth=auth)
    except ApiError as exc:
        _fail(str(exc))
    summary = {
        "agent": out["name"],
        "status": out["status"],
        "url": out.get("url"),
        "mcp": out.get("mcp"),
        "card_hash": out.get("card_hash"),
    }
    console.print(
        Panel.fit(
            json.dumps(summary, indent=2),
            title="[bold green]imported[/]",
        )
    )


def _import_auth_payload(
    *,
    auth_bearer: str | None,
    auth_api_key: str | None,
    auth_name: str | None,
    auth_location: str,
    auth_scheme: str,
) -> dict[str, str] | None:
    if auth_bearer and auth_api_key:
        _fail("use either --auth-bearer or --auth-api-key, not both")
    if auth_bearer:
        scheme = auth_scheme.strip() or "Bearer"
        return {"type": "bearer", "value": auth_bearer, "scheme": scheme}
    if auth_api_key:
        location = auth_location.strip().lower()
        if location not in {"header", "query"}:
            _fail("--auth-location must be 'header' or 'query'")
        name = (auth_name or "").strip()
        if not name:
            _fail("--auth-name is required with --auth-api-key")
        return {
            "type": "api_key",
            "value": auth_api_key,
            "location": location,
            "name": name,
        }
    if auth_name or auth_location.strip().lower() != "header" or auth_scheme.strip() != "Bearer":
        _fail("auth options require --auth-bearer or --auth-api-key")
    return None


@openapi_app.command(name="preview")
def preview_openapi(
    url: str = typer.Argument(PETSTORE_OPENAPI_URL, help="OpenAPI JSON/YAML URL"),
    name: str | None = typer.Option(None, "--name", "-n", help="Registry slug to generate"),
    description: str | None = typer.Option(None, "--description", "-d"),
    base_url: str | None = typer.Option(None, "--base-url", help="Override the API server URL"),
    public: bool = typer.Option(True, "--public/--private"),
    api: str | None = typer.Option(None, "--api"),
) -> None:
    """Preview generated skills, setup fields, and source files."""
    try:
        out = _client(api).preview_openapi_agent(
            url=url,
            name=name,
            description=description,
            public=public,
            base_url=base_url,
        )
    except ApiError as exc:
        _fail(str(exc))
    console.print_json(json.dumps(out, indent=2))


@openapi_app.command(name="generate")
def generate_openapi(
    url: str = typer.Argument(PETSTORE_OPENAPI_URL, help="OpenAPI JSON/YAML URL"),
    name: str | None = typer.Option(None, "--name", "-n", help="Registry slug to generate"),
    description: str | None = typer.Option(None, "--description", "-d"),
    base_url: str | None = typer.Option(None, "--base-url", help="Override the API server URL"),
    public: bool = typer.Option(True, "--public/--private"),
    api: str | None = typer.Option(None, "--api"),
) -> None:
    """Generate an editable A2APack source repo and start deployment."""
    try:
        out = _client(api).from_openapi(
            url=url,
            name=name,
            description=description,
            public=public,
            base_url=base_url,
        )
    except ApiError as exc:
        _fail(str(exc))
    summary = {
        "agent": out["name"],
        "version": out["version"],
        "status": out["status"],
        "repo_url": out.get("repo_url"),
        "url": out.get("expected_url"),
        "deployment_id": out.get("deployment_id"),
        "operations": out.get("preview", {}).get("operation_count"),
    }
    console.print(
        Panel.fit(
            json.dumps(summary, indent=2),
            title="[bold green]generated[/]",
        )
    )


@auth_app.command(name="status")
def auth_status(
    agent: str = typer.Argument(..., help="Imported agent name"),
    api: str | None = typer.Option(None, "--api"),
) -> None:
    """Show detected auth requirements and configured connections."""
    try:
        out = _client(api).get_agent_auth(agent)
    except ApiError as exc:
        _fail(str(exc))
    console.print(Panel.fit(json.dumps(out, indent=2, default=str), title="[cyan]auth[/]"))


@auth_app.command(name="bearer")
def auth_bearer(
    agent: str = typer.Argument(..., help="Imported agent name"),
    token: str = typer.Option(..., "--token", prompt=True, hide_input=True),
    scheme_name: str | None = typer.Option(None, "--scheme-name"),
    scheme: str = typer.Option("Bearer", "--scheme"),
    api: str | None = typer.Option(None, "--api"),
) -> None:
    """Connect HTTP/Bearer auth for an imported agent."""
    body = {
        "scheme_type": "http",
        "scheme_name": scheme_name,
        "token": token,
        "scheme": scheme,
    }
    _print_auth_connection(agent, body, api)


@auth_app.command(name="api-key")
def auth_api_key(
    agent: str = typer.Argument(..., help="Imported agent name"),
    value: str = typer.Option(..., "--value", prompt=True, hide_input=True),
    name: str = typer.Option(..., "--name", help="Header or query parameter name"),
    location: str = typer.Option("header", "--location", help="header or query"),
    scheme_name: str | None = typer.Option(None, "--scheme-name"),
    api: str | None = typer.Option(None, "--api"),
) -> None:
    """Connect API-key auth for an imported agent."""
    body = {
        "scheme_type": "api_key",
        "scheme_name": scheme_name,
        "value": value,
        "name": name,
        "location": location,
    }
    _print_auth_connection(agent, body, api)


@auth_app.command(name="oauth-token")
def auth_oauth_token(
    agent: str = typer.Argument(..., help="Imported agent name"),
    access_token: str = typer.Option(..., "--access-token", prompt=True, hide_input=True),
    scheme_type: str = typer.Option("oauth2", "--scheme-type", help="oauth2 or oidc"),
    scheme_name: str | None = typer.Option(None, "--scheme-name"),
    refresh_token: str | None = typer.Option(None, "--refresh-token"),
    token_url: str | None = typer.Option(None, "--token-url"),
    client_id: str | None = typer.Option(None, "--client-id"),
    client_secret: str | None = typer.Option(None, "--client-secret"),
    expires_in: int | None = typer.Option(None, "--expires-in"),
    api: str | None = typer.Option(None, "--api"),
) -> None:
    """Connect OAuth/OIDC with an existing access token and optional refresh config."""
    body = {
        "scheme_type": scheme_type,
        "scheme_name": scheme_name,
        "access_token": access_token,
        "refresh_token": refresh_token,
        "token_url": token_url,
        "client_id": client_id,
        "client_secret": client_secret,
        "expires_in": expires_in,
    }
    _print_auth_connection(agent, body, api)


@auth_app.command(name="mtls")
def auth_mtls(
    agent: str = typer.Argument(..., help="Imported agent name"),
    cert: Path = typer.Option(..., "--cert", exists=True, readable=True),
    key: Path = typer.Option(..., "--key", exists=True, readable=True),
    ca: Path | None = typer.Option(None, "--ca", exists=True, readable=True),
    scheme_name: str | None = typer.Option(None, "--scheme-name"),
    api: str | None = typer.Option(None, "--api"),
) -> None:
    """Connect mTLS client certificate auth for an imported agent."""
    body = {
        "scheme_type": "mtls",
        "scheme_name": scheme_name,
        "cert_pem": cert.read_text(encoding="utf-8"),
        "key_pem": key.read_text(encoding="utf-8"),
        "ca_pem": ca.read_text(encoding="utf-8") if ca else None,
    }
    _print_auth_connection(agent, body, api)


@auth_app.command(name="delete")
def auth_delete(
    agent: str = typer.Argument(..., help="Imported agent name"),
    connection_id: int = typer.Argument(..., help="Auth connection id"),
    api: str | None = typer.Option(None, "--api"),
) -> None:
    """Remove an imported-agent auth connection."""
    try:
        _client(api).delete_agent_auth(agent, connection_id)
    except ApiError as exc:
        _fail(str(exc))
    console.print("[green]deleted[/]")


def _print_auth_connection(agent: str, body: dict[str, Any], api: str | None) -> None:
    clean = {k: v for k, v in body.items() if v is not None}
    try:
        out = _client(api).connect_agent_auth(agent, clean)
    except ApiError as exc:
        _fail(str(exc))
    console.print(Panel.fit(json.dumps(out, indent=2, default=str), title="[green]connected[/]"))


# --------------------------------------------------------------------------- #
# 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"),
    language: str = typer.Option(
        "python",
        "--language",
        "-l",
        help="Template language: python, typescript, javascript, go, or rust",
    ),
    frontend: str | None = typer.Option(
        None,
        "--frontend",
        help="Add a packed frontend scaffold: static, react, or nextjs",
    ),
    auth: str = typer.Option(
        "inherit",
        "--auth",
        help="App auth mode for scaffolded agents/frontends: inherit, platform, or public",
    ),
) -> 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)
    frontend_kind = frontend if isinstance(frontend, str) else None
    auth_mode = _clean_init_auth(auth if isinstance(auth, str) else "inherit")
    language_mode = _clean_init_language(language if isinstance(language, str) else "python")
    auth_type = _auth_template_type(auth_mode)

    if language_mode == "python":
        files = {
            "README.md": _render_template(
                "README.md.tmpl",
                name=name,
                class_name=class_name,
                description=description,
            ),
            "agent.py": _render_template(
                "agent.py.tmpl",
                name=name,
                class_name=class_name,
                description=description,
                auth_type=auth_type,
            ),
            "a2a.yaml": _render_template(
                "a2a.yaml.tmpl",
                name=name,
                class_name=class_name,
                frontend_block=_frontend_block(frontend_kind, auth=auth_mode),
            ),
            "requirements.txt": _render_template("requirements.txt.tmpl"),
        }
        next_steps = (
            "  python -m pip install -r requirements.txt\n"
            "  a2a dev         # iterate locally\n"
            "  a2a test        # run preflight checks\n"
            "  a2a deploy      # ship it"
        )
    elif language_mode in {"typescript", "javascript"}:
        if frontend_kind is not None and frontend_kind.strip().lower() != "nextjs":
            _fail(f"--language {language_mode} currently supports --frontend nextjs")
        if auth_mode != "inherit":
            _fail("--auth is only supported for the python template today")
        is_typescript = language_mode == "typescript"
        template_dir = "typescript" if is_typescript else "javascript"
        entrypoint = "node dist/worker.js" if is_typescript else "node src/worker.js"
        entrypoint_command = (
            '["node", "dist/worker.js"]'
            if is_typescript
            else '["node", "src/worker.js"]'
        )
        files = {
            "README.md": _render_template(
                f"{template_dir}/README.md.tmpl",
                name=name,
                class_name=class_name,
                description=description,
            ),
            "a2a.yaml": _render_template(
                f"{template_dir}/a2a.yaml.tmpl",
                name=name,
                frontend_block=_frontend_block(frontend_kind, auth="public"),
                entrypoint=entrypoint,
            ),
            "package.json": _render_template(
                f"{template_dir}/package.json.tmpl",
                name=name,
            ),
            f"src/agent.{'ts' if is_typescript else 'js'}": _render_template(
                f"{template_dir}/agent.{'ts' if is_typescript else 'js'}.tmpl",
                name=name,
                class_name=class_name,
                description=description,
                entrypoint_command=entrypoint_command,
            ),
            f"src/worker.{'ts' if is_typescript else 'js'}": _render_template(
                f"{template_dir}/worker.{'ts' if is_typescript else 'js'}.tmpl",
                class_name=class_name,
            ),
        }
        if is_typescript:
            files["tsconfig.json"] = _render_template("typescript/tsconfig.json.tmpl")
        next_steps = (
            "  npm install\n"
            "  npm run compile  # writes .a2a/agent.dsl.json\n"
            "  npm run worker   # run native worker locally\n"
            "  a2a deploy       # ship it"
        )
    elif language_mode in {"go", "rust"}:
        if frontend_kind is not None and frontend_kind.strip().lower() != "nextjs":
            _fail(f"--language {language_mode} currently supports --frontend nextjs")
        if auth_mode != "inherit":
            _fail("--auth is only supported for the python template today")
        files = {
            "README.md": _render_template(
                f"{language_mode}/README.md.tmpl",
                name=name,
                class_name=class_name,
                description=description,
            ),
            "a2a.yaml": _render_template(
                f"{language_mode}/a2a.yaml.tmpl",
                name=name,
                frontend_block=_frontend_block(frontend_kind, auth="public"),
            ),
        }
        if language_mode == "go":
            files["go.mod"] = _render_template("go/go.mod.tmpl", name=name)
            files["main.go"] = _render_template(
                "go/main.go.tmpl",
                name=name,
                class_name=class_name,
                description=description,
            )
            next_steps = (
                "  go run . compile  # writes .a2a/agent.dsl.json\n"
                "  go run . worker   # run native worker locally\n"
                "  a2a deploy        # ship it"
            )
        else:
            files["Cargo.toml"] = _render_template("rust/Cargo.toml.tmpl", name=name)
            files["src/main.rs"] = _render_template(
                "rust/main.rs.tmpl",
                name=name,
                class_name=class_name,
                description=description,
            )
            next_steps = (
                "  cargo run -- compile  # writes .a2a/agent.dsl.json\n"
                "  cargo run -- worker   # run native worker locally\n"
                "  a2a deploy            # ship it"
            )
    else:
        raise AssertionError(f"unsupported language mode: {language_mode}")
    files.update(_frontend_files(frontend_kind, 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}")
    if language_mode in {"typescript", "javascript"}:
        vendor_target = project / "vendor" / "a2a-pack-ts"
        _copy_resource_tree("a2a_pack", "typescript", vendor_target)
        console.print(f"  [green]+[/] {vendor_target}")
    if language_mode == "go":
        vendor_target = project / "third_party" / "a2a-pack-go"
        _copy_resource_tree("a2a_pack", "go", vendor_target)
        console.print(f"  [green]+[/] {vendor_target}")
    if language_mode == "rust":
        vendor_target = project / "third_party" / "a2a-pack-rs"
        _copy_resource_tree("a2a_pack", "rust", vendor_target)
        console.print(f"  [green]+[/] {vendor_target}")

    console.print(
        Panel.fit(
            f"[bold]{name}[/] scaffolded at [cyan]{project}[/]\n\n"
            "next:\n"
            f"  cd {project}\n"
            f"{next_steps}",
            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)
    apply_project_manifest(cls, cfg)
    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)
    apply_project_manifest(cls, cfg)
    console.print_json(cls().card().model_dump_json())


def _compile_project_dsl(project: Path) -> tuple[dict[str, Any], AgentDsl]:
    cfg = _read_yaml(project / "a2a.yaml")
    language = str(cfg.get("language") or "python").strip().lower()
    if language in {"ts", "node"}:
        language = "typescript"
    if language in {"js", "nodejs"}:
        language = "javascript"
    if language in {"typescript", "javascript", "go", "rust"}:
        return cfg, _compile_external_project_dsl(project, language=language)
    if language != "python":
        _fail(f"unsupported a2a.yaml language: {language}")
    entrypoint = cfg["entrypoint"]
    cls = load_agent_class(entrypoint, project_dir=project)
    apply_project_manifest(cls, cfg)
    dsl = compile_agent_to_dsl(
        cls,
        language="python",
        entrypoint=entrypoint,
        metadata={
            "source": "python-a2a-pack",
            "project_manifest": {
                key: value
                for key, value in cfg.items()
                if key in {"name", "version", "entrypoint", "frontend"}
            },
        },
    )
    return cfg, dsl


def _compile_external_project_dsl(project: Path, *, language: str) -> AgentDsl:
    if language in {"typescript", "javascript"}:
        command = ["npm", "run", "compile"]
        missing_tool = "npm"
    elif language == "go":
        command = ["go", "run", ".", "compile"]
        missing_tool = "go"
    elif language == "rust":
        command = ["cargo", "run", "--quiet", "--", "compile"]
        missing_tool = "cargo"
    else:
        _fail(f"unsupported external Agent DSL language: {language}")
    try:
        subprocess.run(
            command,
            cwd=project,
            check=True,
            text=True,
        )
    except FileNotFoundError as exc:
        _fail(f"{missing_tool} is required to compile {language} Agent DSL")
        raise AssertionError("unreachable") from exc
    except subprocess.CalledProcessError as exc:
        _fail(f"{language} DSL compile failed with exit code {exc.returncode}")
    dsl_path = project / ".a2a" / "agent.dsl.json"
    if not dsl_path.exists():
        _fail(f"{language} compile did not write {dsl_path}")
    try:
        dsl = AgentDsl.model_validate_json(dsl_path.read_text(encoding="utf-8"))
    except Exception as exc:  # noqa: BLE001
        _fail(f"invalid compiled Agent DSL: {exc}")
    if dsl.language != language:
        _fail(f"compiled Agent DSL language {dsl.language!r} does not match {language!r}")
    return dsl


@app.command(name="compile")
def compile_dsl(
    project: Path = typer.Option(Path("."), "--project", "-p"),
    out: Path | None = typer.Option(
        None,
        "--out",
        "-o",
        help="Write DSL JSON here (defaults to PROJECT/.a2a/agent.dsl.json)",
    ),
    stdout: bool = typer.Option(
        False,
        "--stdout",
        help="Print DSL JSON instead of writing a file",
    ),
) -> None:
    """Compile the project declaration to the sidecar Agent DSL."""
    _, dsl = _compile_project_dsl(project)
    payload = dsl.model_dump_json(indent=2)
    if stdout:
        console.print_json(payload)
        return
    out_path = out or (project / ".a2a" / "agent.dsl.json")
    if not out_path.is_absolute():
        out_path = project / out_path if out is not None else out_path
    out_path.parent.mkdir(parents=True, exist_ok=True)
    out_path.write_text(payload + "\n", encoding="utf-8")
    console.print(f"[green]compiled[/] sidecar DSL -> [cyan]{out_path}[/]")


_LOCAL_LLM_TOOL_HINTS = {"deepagents", "langchain", "openapi", "llm"}


def _local_dev_warnings(agent_cls: type[Any]) -> list[str]:
    warnings: list[str] = []

    missing_runtime = [
        key
        for key in (*agent_cls.required_env, *agent_cls.required_secrets)
        if not os.environ.get(key)
    ]
    if missing_runtime:
        warnings.append(
            "runtime env missing "
            + ", ".join(dict.fromkeys(missing_runtime))
            + " (add to .env.local)"
        )

    setup_fields = tuple(getattr(agent_cls.consumer_setup, "fields", ()) or ())
    missing_setup = [
        field.name
        for field in setup_fields
        if field.required and not os.environ.get(field.name)
    ]
    if missing_setup:
        warnings.append(
            "setup missing "
            + ", ".join(missing_setup)
        )
        warnings.append("setup fix: open dev ui or add to .env.local")

    if _local_dev_likely_needs_llm(agent_cls) and not (
        os.environ.get("AGENT_LLM_KEY") or os.environ.get("A2A_LITELLM_KEY")
    ):
        warnings.append("llm missing AGENT_LLM_KEY or A2A_LITELLM_KEY")
        warnings.append("llm fix: open dev ui or set local ctx.llm env")

    return warnings


def _local_dev_likely_needs_llm(agent_cls: type[Any]) -> bool:
    provisioning = getattr(agent_cls, "llm_provisioning", None)
    if provisioning not in {
        LLMProvisioning.PLATFORM,
        LLMProvisioning.PLATFORM_OR_CALLER_PROVIDED,
        LLMProvisioning.CALLER_PROVIDED,
        LLMProvisioning.AGENT_BYOK,
    }:
        return False
    tools = {str(tool).lower() for tool in getattr(agent_cls, "tools_used", ())}
    return bool(tools.intersection(_LOCAL_LLM_TOOL_HINTS))


def _dev_image_ref(name: str) -> str:
    safe = re.sub(r"[^a-z0-9_.-]+", "-", name.lower()).strip(".-") or "agent"
    return f"a2a-dev-{safe}:local"


def _stage_default_dockerfile(
    ctx: Path,
    cfg: dict[str, Any],
    *,
    base_image: str = DEFAULT_AGENT_BASE_IMAGE,
) -> None:
    if (ctx / "Dockerfile").exists():
        return
    frontend_stage, frontend_runtime = _frontend_dockerfile_blocks(cfg)
    (ctx / "Dockerfile").write_text(
        _render_template(
            "Dockerfile.tmpl",
            entrypoint=cfg["entrypoint"],
            base_image=base_image,
            frontend_stage=frontend_stage,
            frontend_runtime=frontend_runtime,
        )
    )


def _docker_passthrough_env(agent_cls: type[Any], loaded: dict[str, str]) -> list[str]:
    names = set(loaded)
    names.update(agent_cls.required_env)
    names.update(agent_cls.required_secrets)
    names.update(field.name for field in agent_cls.consumer_setup.fields)
    names.update(
        {
            "A2A_LITELLM_URL",
            "A2A_LITELLM_KEY",
            "A2A_LITELLM_MODEL",
            "AGENT_LLM_URL",
            "AGENT_LLM_KEY",
            "AGENT_LLM_MODEL",
        }
    )
    return sorted(name for name in names if os.environ.get(name) is not None)


def _run_docker_dev(
    *,
    local: Any,
    project: Path,
    env_path: Path,
    workspace_root: Path,
    loaded: dict[str, str],
    host: str,
    port: int,
    reload: bool,
) -> None:
    image = _dev_image_ref(local.agent_cls.name)
    with tempfile.TemporaryDirectory(prefix="a2a-dev-build-") as tmp:
        ctx = Path(tmp)
        _stage_build_context(project, ctx)
        _stage_default_dockerfile(ctx, local.config)
        _run(["docker", "build", "-t", image, str(ctx)])

    try:
        container_env_path = f"/app/{env_path.resolve().relative_to(project.resolve())}"
    except ValueError:
        container_env_path = "/app/.env.local"

    cmd = [
        "docker",
        "run",
        "--rm",
        "-p",
        f"{host}:{port}:8000",
        "-v",
        f"{project.resolve()}:/app",
        "-v",
        f"{workspace_root}:/workspace",
        "-e",
        "A2A_PROJECT_DIR=/app",
        "-e",
        f"A2A_ENTRYPOINT={local.entrypoint}",
        "-e",
        f"A2A_ENV_FILE={container_env_path}",
        "-e",
        "A2A_LOCAL_DEV=1",
        "-e",
        "A2A_LOCAL_WORKSPACE_DIR=/workspace",
    ]
    if env_path.exists():
        cmd.extend(["--env-file", str(env_path.resolve())])
    for name in _docker_passthrough_env(local.agent_cls, loaded):
        cmd.extend(["-e", name])
    cmd.extend(
        [
            image,
            "python",
            "-m",
            "uvicorn",
            "a2a_pack.cli.dev_server:create_app",
            "--factory",
            "--host",
            "0.0.0.0",
            "--port",
            "8000",
        ]
    )
    if reload:
        cmd.extend(["--reload", "--reload-dir", "/app"])
    _run(cmd)


@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

    cfg = _read_yaml_optional(project / "a2a.yaml")
    cls = load_agent_class(entrypoint, project_dir=project)
    apply_project_manifest(cls, cfg)
    frontend = resolve_packed_frontend(project, cfg) or packed_frontend_from_env()
    export_frontend_env(frontend)
    serve(cls(), host=host, port=port, frontend=frontend)


@app.command(name="sidecar")
def sidecar(
    dsl: Path = typer.Option(
        Path(".a2a/agent.dsl.json"),
        "--dsl",
        help="Compiled Agent DSL JSON path",
    ),
    worker_url: str = typer.Option(
        "http://127.0.0.1:9001",
        "--worker-url",
        help="Native language worker base URL",
    ),
    host: str = typer.Option("0.0.0.0", "--host"),
    port: int = typer.Option(8000, "--port"),
) -> None:
    """Run the common sidecar runtime for a compiled Agent DSL."""
    from ..sidecar import serve_sidecar

    serve_sidecar(dsl_path=dsl, worker_url=worker_url, host=host, port=port)


@app.command()
def dev(
    project: Path = typer.Option(Path("."), "--project", "-p"),
    host: str = typer.Option("127.0.0.1", "--host"),
    port: int = typer.Option(8000, "--port"),
    reload: bool = typer.Option(True, "--reload/--no-reload"),
    env_file: Path = typer.Option(Path(".env.local"), "--env-file"),
    workspace: Path | None = typer.Option(None, "--workspace"),
    docker: bool = typer.Option(
        True,
        "--docker/--host-runtime",
        help="Run dev mode in the agent container image instead of this Python process.",
    ),
) -> None:
    """Run the agent locally with .env.local, workspace, and hot reload."""
    local = load_local_project(project)
    env_path = env_file if env_file.is_absolute() else project / env_file
    workspace_root = ensure_local_workspace(project, workspace=workspace)
    loaded = load_env_file(env_path)
    credentials.load_local_llm_into_env()
    credentials.load_agent_setup_into_env(
        local.agent_cls.name,
        set(local.agent_cls.required_env)
        | set(local.agent_cls.required_secrets)
        | {field.name for field in local.agent_cls.consumer_setup.fields},
    )
    frontend_cfg = load_frontend_config(project, local.config)
    frontend_runtime = resolve_packed_frontend(project, local.config)
    export_frontend_env(frontend_runtime)
    os.environ["A2A_PROJECT_DIR"] = str(project.resolve())
    os.environ["A2A_ENTRYPOINT"] = local.entrypoint
    os.environ["A2A_ENV_FILE"] = str(env_path.resolve())
    os.environ["A2A_LOCAL_DEV"] = "1"
    os.environ["A2A_LOCAL_WORKSPACE_DIR"] = str(workspace_root)
    warnings = _local_dev_warnings(local.agent_cls)
    runtime_label = "docker" if docker else "host"

    console.print(
        Panel.fit(
            "\n".join(
                [
                    f"agent: {local.agent_cls.name} v{local.agent_cls.version}",
                    f"runtime: {runtime_label}",
                    f"url: http://{host}:{port}",
                    f"dev ui: http://{host}:{port}/_dev",
                    f"card: http://{host}:{port}/.well-known/agent-card",
                    f"workspace: {workspace_root}",
                    f"env: {env_path} ({len(loaded)} loaded)",
                    (
                        f"frontend: http://{host}:{port}{frontend_runtime.mount}"
                        if frontend_runtime is not None
                        else (
                            "frontend: declared, build dist first with `a2a frontend build`"
                            if frontend_cfg is not None
                            else "frontend: none"
                        )
                    ),
                    *(
                        ["", *[f"warning: {warning}" for warning in warnings]]
                        if warnings
                        else []
                    ),
                ]
            ),
            title="[bold green]local dev[/]",
        )
    )

    if docker:
        _run_docker_dev(
            local=local,
            project=project,
            env_path=env_path,
            workspace_root=workspace_root,
            loaded=loaded,
            host=host,
            port=port,
            reload=reload,
        )
        return

    import uvicorn

    uvicorn.run(
        "a2a_pack.cli.dev_server:create_app",
        factory=True,
        host=host,
        port=port,
        reload=reload,
        reload_dirs=[str(project.resolve())] if reload else None,
        log_level="info",
    )


@app.command(name="chat")
def chat(
    project: Path = typer.Option(Path("."), "--project", "-p"),
    host: str = typer.Option("127.0.0.1", "--host"),
    port: int = typer.Option(8000, "--port"),
    reload: bool = typer.Option(True, "--reload/--no-reload"),
    env_file: Path = typer.Option(Path(".env.local"), "--env-file"),
    workspace: Path | None = typer.Option(None, "--workspace"),
    build_image: bool = typer.Option(
        True,
        "--build/--no-build",
        help="Build the local agent image before starting compose.",
    ),
    up: bool = typer.Option(
        True,
        "--up/--no-up",
        help="Start the compose harness after writing it.",
    ),
    detach: bool = typer.Option(
        False,
        "--detach/--foreground",
        help="Run compose in the background.",
    ),
    down: bool = typer.Option(
        False,
        "--down",
        help="Stop the existing chat harness for this project.",
    ),
    volumes: bool = typer.Option(
        False,
        "--volumes",
        help="With --down, also remove the chat harness data volumes.",
    ),
    print_compose: bool = typer.Option(
        False,
        "--print-compose",
        help="Print the generated compose file.",
    ),
    base_image: str = typer.Option(
        DEFAULT_AGENT_BASE_IMAGE,
        "--base-image",
        help="Base image for the generated local Dockerfile.",
    ),
) -> None:
    """Run one agent plus declared local Qdrant/Postgres resources."""
    local = load_local_project(project)
    project_name = (
        f"a2a-chat-{re.sub(r'[^a-z0-9]+', '-', local.agent_cls.name.lower()).strip('-') or 'agent'}"
    )[:63].strip("-")
    compose_path = project / ".a2a" / "chat" / "docker-compose.yml"
    if down:
        if not compose_path.exists():
            _fail(f"missing {compose_path}; run `a2a chat` first")
        cmd = ["docker", "compose", "-p", project_name, "-f", str(compose_path), "down"]
        if volumes:
            cmd.append("--volumes")
        _run(cmd)
        console.print(f"[green]stopped[/] {project_name}")
        return

    env_path = env_file if env_file.is_absolute() else project / env_file
    workspace_root = ensure_local_workspace(project, workspace=workspace)
    loaded = load_env_file(env_path)
    credentials.load_local_llm_into_env()
    normalize_local_llm_env()
    credentials.load_agent_setup_into_env(
        local.agent_cls.name,
        set(local.agent_cls.required_env)
        | set(local.agent_cls.required_secrets)
        | {field.name for field in local.agent_cls.consumer_setup.fields},
    )
    image = _dev_image_ref(local.agent_cls.name)
    if build_image:
        with tempfile.TemporaryDirectory(prefix="a2a-chat-build-") as tmp:
            ctx = Path(tmp)
            _stage_build_context(project, ctx)
            _stage_default_dockerfile(ctx, local.config, base_image=base_image)
            _run(["docker", "build", "-t", image, str(ctx)])

    plan = write_chat_compose(
        project=project,
        local=local,
        image=image,
        env_path=env_path,
        workspace_root=workspace_root,
        host=host,
        port=port,
        reload=reload,
        passthrough_env=_docker_passthrough_env(local.agent_cls, loaded),
    )
    warnings = _local_dev_warnings(local.agent_cls)
    console.print(
        Panel.fit(
            "\n".join(
                [
                    f"agent: {local.agent_cls.name} v{local.agent_cls.version}",
                    f"url: http://{host}:{port}",
                    f"chat ui: http://{host}:{port}/_dev",
                    f"compose: {plan.compose_path}",
                    f"workspace: {workspace_root}",
                    f"resources: {', '.join(plan.resource_summary)}",
                    *(
                        ["", *[f"warning: {warning}" for warning in warnings]]
                        if warnings
                        else []
                    ),
                ]
            ),
            title="[bold green]docker chat[/]",
        )
    )
    if print_compose:
        console.print(plan.compose_path.read_text(encoding="utf-8"))
    if not up:
        return
    cmd = [
        "docker",
        "compose",
        "-p",
        plan.project_name,
        "-f",
        str(plan.compose_path),
        "up",
        "--remove-orphans",
    ]
    if detach:
        cmd.append("-d")
    _run(cmd)


@app.command(name="test")
def test_agent(
    project: Path = typer.Option(Path("."), "--project", "-p"),
    env_file: Path = typer.Option(Path(".env.local"), "--env-file"),
    workspace: Path | None = typer.Option(None, "--workspace"),
    invoke: bool = typer.Option(False, "--invoke/--no-invoke", help="Run a local skill call"),
    skill: str | None = typer.Option(None, "--skill", help="Skill to invoke"),
    args_json: str | None = typer.Option(None, "--args-json", help="JSON object args for --invoke"),
) -> None:
    """Run local preflight checks before deploying."""
    env_path = env_file if env_file.is_absolute() else project / env_file
    try:
        checks, result = run_preflight_sync(
            project,
            env_file=env_path,
            workspace=workspace,
            invoke=invoke,
            skill_name=skill,
            args_json=args_json,
        )
    except Exception as exc:  # noqa: BLE001
        _fail(f"local test failed to start: {exc}")

    failed = [check for check in checks if not check.ok]
    for check in checks:
        icon = "[green]✓[/]" if check.ok else "[red]✗[/]"
        console.print(f"{icon} [bold]{check.name}[/] {check.message}")
    if result is not None:
        console.print_json(json.dumps(result, default=str))
    if failed:
        raise typer.Exit(1)
    console.print("[green]local checks passed[/]")


@app.command(name="mcp-url")
def mcp_url(
    name: str | None = typer.Argument(
        None, help="Agent name (defaults to a2a.yaml in --project)"
    ),
    project: Path = typer.Option(Path("."), "--project", "-p"),
    api: str | None = typer.Option(None, "--api"),
) -> None:
    """Print the MCP connect config for a deployed agent.

    Every shipped agent auto-exposes ``POST /mcp`` (Streamable HTTP).
    This command prints the JSON snippet you paste into Claude Code,
    Cursor, or any other MCP client to wire up the agent.
    """
    if name is None:
        cfg = _read_yaml(project / "a2a.yaml")
        name = cfg["name"]
    try:
        rows = _client(api).list_agents()
    except ApiError as exc:
        _fail(str(exc))
    row = next((r for r in rows if r["name"] == name), None)
    if row is None:
        _fail(f"agent {name!r} not found (run `a2a deploy` first)")
    url = row.get("url")
    if not url:
        _fail(f"agent {name!r} has no public URL yet")

    snippet = {
        "mcpServers": {
            name: {
                "type": "http",
                "url": f"{url.rstrip('/')}/mcp",
            }
        }
    }
    console.print_json(json.dumps(snippet))


# --------------------------------------------------------------------------- #
# frontend                                                                    #
# --------------------------------------------------------------------------- #


@frontend_app.command(name="build")
def build_frontend(
    project: Path = typer.Option(Path("."), "--project", "-p"),
) -> None:
    """Build the packed frontend declared in a2a.yaml."""
    cfg = _read_yaml(project / "a2a.yaml")
    try:
        frontend = load_frontend_config(project, cfg)
    except ValueError as exc:
        _fail(str(exc))
    if frontend is None:
        _fail("a2a.yaml has no frontend section")

    source_dir = frontend.source_dir(project)
    if not source_dir.is_dir():
        _fail(f"frontend.path does not exist: {source_dir}")
    if frontend.build:
        _run(["/bin/sh", "-lc", frontend.build], cwd=str(source_dir))

    dist_dir = frontend.dist_dir(project)
    if not (dist_dir / "index.html").is_file():
        _fail(f"frontend dist is missing index.html: {dist_dir}")
    console.print(f"[green]frontend ready[/] {dist_dir} -> {frontend.mount}")


@frontend_app.command(name="info")
def frontend_info(
    project: Path = typer.Option(Path("."), "--project", "-p"),
) -> None:
    """Print the packed frontend config resolved from a2a.yaml."""
    cfg = _read_yaml(project / "a2a.yaml")
    try:
        frontend = load_frontend_config(project, cfg)
    except ValueError as exc:
        _fail(str(exc))
    if frontend is None:
        _fail("a2a.yaml has no frontend section")
    payload = {
        "path": str(frontend.source_dir(project)),
        "dist": str(frontend.dist_dir(project)),
        "mount": frontend.mount,
        "build": frontend.build,
        "auth": frontend.auth,
        "docs_url": frontend.docs_url,
        "ready": (frontend.dist_dir(project) / "index.html").is_file(),
    }
    console.print_json(json.dumps(payload))


# --------------------------------------------------------------------------- #
# 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 {".a2a", ".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(
            ".git",
            ".mypy_cache",
            ".pytest_cache",
            ".venv",
            "__pycache__",
            "*.egg-info",
            "build",
            "dist",
            "node_modules",
        ),
    )


@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)
        if not (ctx / "Dockerfile").exists():
            frontend_stage, frontend_runtime = _frontend_dockerfile_blocks(cfg)
            (ctx / "Dockerfile").write_text(
                _render_template(
                    "Dockerfile.tmpl",
                    entrypoint=cfg["entrypoint"],
                    base_image=DEFAULT_AGENT_BASE_IMAGE,
                    frontend_stage=frontend_stage,
                    frontend_runtime=frontend_runtime,
                )
            )
        _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, *, agent_dsl: AgentDsl | None = None) -> 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

    cfg = _read_yaml_optional(project / "a2a.yaml")
    frontend = load_frontend_config(project, cfg)
    prebuilt_frontend_dist: Path | None = None
    build_managed_frontend_dist: Path | None = None
    if frontend is not None and not frontend.build:
        try:
            prebuilt_frontend_dist = frontend.dist_dir(project).resolve().relative_to(project.resolve())
        except ValueError:
            prebuilt_frontend_dist = None
    if frontend is not None and frontend.build:
        try:
            build_managed_frontend_dist = (
                frontend.dist_dir(project).resolve().relative_to(project.resolve())
            )
        except ValueError:
            build_managed_frontend_dist = None
    package_includes = _package_include_paths(cfg)

    excluded_dirs = {
        "__pycache__",
        ".venv",
        ".git",
        ".pytest_cache",
        ".mypy_cache",
        "node_modules",
        "dist",
        "build",
        ".gitea",
        "deploy",
        ".a2a",
    }
    excluded_files = {"Dockerfile", ".dockerignore", ".env", ".env.local"}

    buf = io.BytesIO()
    with tarfile.open(fileobj=buf, mode="w:gz") as tar:
        for root, dirs, files in os.walk(project):
            root_path = Path(root)
            kept_dirs: list[str] = []
            for dirname in dirs:
                rel_dir = (root_path / dirname).resolve().relative_to(project.resolve())
                if dirname in excluded_dirs and not _is_frontend_dist_path(
                    rel_dir,
                    prebuilt_frontend_dist,
                ) and not _is_package_include_path(
                    rel_dir,
                    package_includes,
                ):
                    continue
                if _is_frontend_dist_path(
                    rel_dir,
                    build_managed_frontend_dist,
                ) and not _is_package_include_path(
                    rel_dir,
                    package_includes,
                ):
                    continue
                kept_dirs.append(dirname)
            dirs[:] = kept_dirs
            for fname in files:
                fpath = Path(root) / fname
                arcname = fpath.relative_to(project)
                inside_excluded_dir = any(
                    part in excluded_dirs for part in arcname.parts[:-1]
                )
                if (
                    inside_excluded_dir
                    and not _is_frontend_dist_path(arcname, prebuilt_frontend_dist)
                    and not _is_package_include_path(arcname, package_includes)
                ):
                    continue
                if (
                    _is_frontend_dist_path(arcname, build_managed_frontend_dist)
                    and not _is_package_include_path(arcname, package_includes)
                ):
                    continue
                if (
                    (fname in excluded_files or fname.endswith(".pyc"))
                    and not _is_package_include_path(arcname, package_includes)
                ):
                    continue
                tar.add(fpath, arcname=str(arcname))
        if agent_dsl is not None:
            body = (agent_dsl.model_dump_json(indent=2) + "\n").encode("utf-8")
            info = tarfile.TarInfo(".a2a/agent.dsl.json")
            info.size = len(body)
            info.mode = 0o644
            tar.addfile(info, io.BytesIO(body))
    return buf.getvalue()


def _is_frontend_dist_path(path: Path, frontend_dist: Path | None) -> bool:
    if frontend_dist is None:
        return False
    return path == frontend_dist or frontend_dist in path.parents


def _package_include_paths(cfg: dict[str, Any]) -> tuple[Path, ...]:
    package = cfg.get("package")
    raw: Any = None
    if isinstance(package, dict):
        raw = package.get("include")
    if raw is None:
        return ()
    if isinstance(raw, str):
        raw = [raw]
    if not isinstance(raw, list):
        _fail("package.include must be a string or list of relative paths")
    out: list[Path] = []
    seen: set[str] = set()
    for item in raw:
        if not isinstance(item, str):
            _fail("package.include entries must be strings")
        value = item.strip().replace("\\", "/").strip("/")
        parts = Path(value).parts
        if not value or value == "." or Path(value).is_absolute() or ".." in parts:
            _fail(f"invalid package.include path: {item!r}")
        key = Path(value).as_posix()
        if key not in seen:
            seen.add(key)
            out.append(Path(value))
    return tuple(out)


def _is_package_include_path(path: Path, includes: tuple[Path, ...]) -> bool:
    return any(
        path == include or include in path.parents or path in include.parents
        for include in includes
    )


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: Annotated[
        Path | None,
        typer.Argument(
            metavar="PROJECT",
            help="Project directory containing a2a.yaml",
        ),
    ] = None,
    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.
    """
    if project_path is not None and project != Path("."):
        _fail("pass either PROJECT or --project, not both")
    project_dir = project_path or project

    creds = credentials.load()
    if creds is None:
        _fail("not logged in (run `a2a signup` or `a2a login`)")

    cfg, dsl = _compile_project_dsl(project_dir)
    is_public = (
        public if public is not None else bool(cfg.get("expose", {}).get("public", True))
    )
    description = cfg.get("description", dsl.description or "")

    console.print("[dim]packaging source...[/]")
    tarball = _make_tarball(project_dir, agent_dsl=dsl)
    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=dsl.name,
            version=dsl.version,
            entrypoint=cfg["entrypoint"],
            description=description,
            public=is_public,
            tarball=tarball,
            agent_dsl=dsl.model_dump(mode="json"),
        )
    except ApiError as exc:
        _fail(_api_error_message(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"
            )


@app.command(name="local-deploy")
def local_deploy(
    project_path: Annotated[
        Path | None,
        typer.Argument(
            metavar="PROJECT",
            help="Project directory containing a2a.yaml",
        ),
    ] = None,
    project: Path = typer.Option(Path("."), "--project", "-p"),
    public: bool | None = typer.Option(None, "--public/--private"),
    api: str | None = typer.Option(None, "--api", help="Local control plane URL"),
    token: str | None = typer.Option(None, "--token", help="Bearer token override"),
    token_email: str = typer.Option(
        "local@example.com",
        "--token-email",
        help="Local user email used when minting a local token with docker exec",
    ),
    docker_token: bool = typer.Option(
        True,
        "--docker-token/--no-docker-token",
        help="Mint a local CP token from the microcash-control-plane container when none is supplied",
    ),
    wait_control_plane: bool = typer.Option(
        True,
        "--wait-control-plane/--no-wait-control-plane",
        help="Wait for the local control plane /healthz before uploading",
    ),
    wait_agent: bool = typer.Option(
        False,
        "--wait-agent/--no-wait-agent",
        help="Wait for the deployed agent /healthz URL after upload",
    ),
    json_output: bool = typer.Option(False, "--json", help="Print machine-readable JSON"),
) -> None:
    """Deploy an agent to the local devcontainer control plane.

    This is the non-interactive local harness for tests and generated agents:
    it mints an e2e bearer token from the local control-plane container when
    no token env var is present, then uses the same tarball upload path as
    `a2a deploy`.
    """
    if project_path is not None and project != Path("."):
        _fail("pass either PROJECT or --project, not both")
    project_dir = project_path or project
    try:
        from .local_harness import LocalHarnessError, deploy_local_agent

        result = deploy_local_agent(
            project_dir,
            api_url=api,
            token=token,
            public=public,
            token_email=token_email,
            allow_docker_token=docker_token,
            wait_control_plane_ready=wait_control_plane,
            wait_agent_ready=wait_agent,
        )
    except (ApiError, LocalHarnessError) as exc:
        _fail(str(exc))

    if json_output:
        console.print_json(json.dumps(result.as_dict()))
        return
    console.print(
        Panel.fit(
            json.dumps(result.as_dict(), indent=2),
            title="[bold green]local deploy[/]",
        )
    )


@app.command(name="local-cleanup")
def local_cleanup(
    name: str = typer.Argument(..., help="Agent name to delete from the local control plane"),
    api: str | None = typer.Option(None, "--api", help="Local control plane URL"),
    token: str | None = typer.Option(None, "--token", help="Bearer token override"),
    token_email: str = typer.Option(
        "local@example.com",
        "--token-email",
        help="Local user email used when minting a local token with docker exec",
    ),
    docker_token: bool = typer.Option(
        True,
        "--docker-token/--no-docker-token",
        help="Mint a local CP token from the microcash-control-plane container when none is supplied",
    ),
    wait_control_plane: bool = typer.Option(
        True,
        "--wait-control-plane/--no-wait-control-plane",
        help="Wait for the local control plane /healthz before deleting",
    ),
    ignore_missing: bool = typer.Option(
        False,
        "--ignore-missing",
        help="Return success when the agent is already absent",
    ),
    json_output: bool = typer.Option(False, "--json", help="Print machine-readable JSON"),
) -> None:
    """Delete a local devcontainer agent and managed local resources."""
    try:
        from .local_harness import LocalHarnessError, cleanup_local_agent

        result = cleanup_local_agent(
            name,
            api_url=api,
            token=token,
            token_email=token_email,
            allow_docker_token=docker_token,
            wait_control_plane_ready=wait_control_plane,
            ignore_missing=ignore_missing,
        )
    except (ApiError, LocalHarnessError) as exc:
        _fail(str(exc))

    if json_output:
        console.print_json(json.dumps(result.as_dict()))
        return
    console.print(
        Panel.fit(
            json.dumps(result.as_dict(), indent=2),
            title="[bold green]local cleanup[/]",
        )
    )


# Used as `python -m a2a_pack.cli.main`
if __name__ == "__main__":  # pragma: no cover
    app()
