← back to A2a Lab
cabinet_cards.py
169 lines
"""Generate A2A agent cards from Steve's cabinet.yaml.
Turns ~/Projects/agent-cabinet/cabinet.yaml (the org chart: President → VP →
Director → Skills) into one A2A `AgentCard` per VP, where each director's `owns`
line becomes an `AgentSkill`. Writes cards/<vp>.agent-card.json (proto→JSON).
NOTE: cabinet.yaml is NOT strictly-valid YAML — its `owns: [...]` flow lists carry
unquoted prose with ':' and '/', which PyYAML rejects. So this uses a tolerant,
shallow line parser for exactly the fields we need rather than a YAML load.
Run: . .venv/bin/activate && python cabinet_cards.py
"""
from __future__ import annotations
import json
import re
from pathlib import Path
from a2a.types import AgentCapabilities, AgentCard, AgentInterface, AgentSkill
from google.protobuf.json_format import MessageToDict
CABINET = Path.home() / "Projects" / "agent-cabinet" / "cabinet.yaml"
OUT = Path(__file__).parent / "cards"
# Convention only — these cards describe capabilities; a live directory server would
# assign real ports. Base kept explicit so it's obvious they aren't served yet.
BASE = "http://127.0.0.1:41300"
def slug(s: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", (s or "").lower()).strip("-")[:40]
def parse_cabinet(text: str) -> list[dict]:
"""Shallow, tolerant parse: vp / domain / triggers[] / directors[{name,kind,owns}]."""
vps: list[dict] = []
cur: dict | None = None
section: str | None = None # 'triggers' | 'directors' | None
pending_dir: dict | None = None
for raw in text.splitlines():
if not raw.strip() or raw.lstrip().startswith("#"):
continue
m_vp = re.match(r"^\s{2}-\s+vp:\s*(.+?)\s*$", raw)
if m_vp:
if cur:
if pending_dir:
cur["directors"].append(pending_dir)
pending_dir = None
if not cur["domain"] and cur.get("_domlines"):
cur["domain"] = " ".join(cur["_domlines"])
vps.append(cur)
cur = {"vp": m_vp.group(1).strip(), "domain": "", "triggers": [], "directors": []}
section = None
continue
if cur is None:
continue
m_dom = re.match(r"^\s{4}domain:\s*(.*)$", raw)
if m_dom:
val = m_dom.group(1).strip()
if val in (">", ">-", ">+", "|", "|-", "|+", ""):
section = "domain_block" # YAML folded/literal scalar — accumulate
cur["_domlines"] = []
else:
cur["domain"] = val
section = None
continue
if section == "domain_block":
m_cont = re.match(r"^\s{6,}(\S.*)$", raw)
if m_cont:
cur["_domlines"].append(m_cont.group(1).strip())
continue
cur["domain"] = " ".join(cur["_domlines"]) # block ended
section = None
# fall through — process THIS line as a normal key (triggers:/directors:/…)
if re.match(r"^\s{4}triggers:\s*$", raw):
section = "triggers"
continue
if re.match(r"^\s{4}directors:\s*$", raw):
if pending_dir:
cur["directors"].append(pending_dir)
pending_dir = None
section = "directors"
continue
if section == "triggers":
m = re.match(r"^\s{6}-\s+(.+?)\s*$", raw)
if m:
cur["triggers"].append(m.group(1).strip())
continue
if section == "directors":
m_d = re.match(r"^\s{6}-\s+(skill|subagent):\s*(.+?)\s*$", raw)
if m_d:
if pending_dir:
cur["directors"].append(pending_dir)
pending_dir = {"kind": m_d.group(1), "name": m_d.group(2).strip(), "owns": ""}
continue
m_o = re.match(r"^\s{8}owns:\s*\[?(.*?)\]?\s*$", raw)
if m_o and pending_dir is not None:
pending_dir["owns"] = m_o.group(1).strip()
continue
# any other key at vp-field indent ends the current section
if re.match(r"^\s{4}\w", raw):
if pending_dir:
cur["directors"].append(pending_dir)
pending_dir = None
section = None
if cur:
if pending_dir:
cur["directors"].append(pending_dir)
if not cur["domain"] and cur.get("_domlines"):
cur["domain"] = " ".join(cur["_domlines"])
vps.append(cur)
return vps
def build_card(vp: dict) -> AgentCard:
trig = " · ".join(vp["triggers"][:4])
desc = vp["domain"] or vp["vp"]
if trig:
desc = f"{desc} (triggers: {trig})"
skills = []
for d in vp["directors"]:
owns = d["owns"] or d["name"]
skills.append(
AgentSkill(
id=slug(d["name"]),
name=d["name"],
description=owns[:300],
tags=[d["kind"], "cabinet"],
)
)
return AgentCard(
name=vp["vp"],
description=desc[:500],
version="0.1.0",
supported_interfaces=[
AgentInterface(
url=f"{BASE}/{vp['vp']}/",
protocol_binding="JSONRPC",
protocol_version="1.0",
)
],
capabilities=AgentCapabilities(streaming=False, push_notifications=False),
default_input_modes=["text/plain"],
default_output_modes=["text/plain"],
skills=skills,
)
def main() -> int:
vps = parse_cabinet(CABINET.read_text(encoding="utf-8"))
OUT.mkdir(exist_ok=True)
total_skills = 0
for vp in vps:
card = build_card(vp)
total_skills += len(card.skills)
path = OUT / f"{vp['vp']}.agent-card.json"
path.write_text(
json.dumps(MessageToDict(card, preserving_proto_field_name=False), indent=2),
encoding="utf-8",
)
print(f" {vp['vp']:24} {len(card.skills):2} skills → {path.name}")
print(f"\nGenerated {len(vps)} cabinet agent cards / {total_skills} skills → {OUT}/")
return 0
if __name__ == "__main__":
raise SystemExit(main())