Skill Tracer
Skills › BMAD Method › bmad › roster.py

roster.py

225 lines · skills/bmad/scripts/roster.py at bd70c64

1#!/usr/bin/env python3
2# /// script
3# requires-python = ">=3.11"
4# ///
5"""Report the people and groups the installed modules offer.
6
7A module's roster is `roster.toml` beside its `bmod.toml`, in the module
8record's folder. It lists members and the groups they form. Nothing is
9recorded under `_bmad`: the roster is whatever the installed modules offer
10right now, so adding or removing a skill changes it with no setup step.
12A member with `skill` is an agent, present only while that skill is installed;
13its name, title and icon follow the skill's customization. A member without
14`skill` is a guest, available to groups and never part of the default room.
15`[agents.<code>]` tables in the central config still apply on top, so a user's
16own agents and overrides keep working.
18Usage:
19 uv run roster.py --skill <any installed skill> [--project-root P] [--root R ...]
20"""
22from __future__ import annotations
24import argparse
25import json
26import sys
27import tomllib
28from pathlib import Path
30sys.dont_write_bytecode = True
32from config_utils import ConfigError, load_central_config, load_customization # noqa: E402
33from knowledge import ROSTER_NAME, Module, install_command, read_document, scan # noqa: E402
35MEMBER_FIELDS = ("name", "icon", "title", "persona", "capabilities", "model")
36AGENT_FIELDS = ("name", "icon", "title")
39def main(argv: list[str] | None = None) -> int:
40 parser = argparse.ArgumentParser(description="Report the members and groups the installed skills offer.")
41 parser.add_argument("--skill", type=Path, help="an installed skill; the skills beside it are scanned")
42 parser.add_argument("--root", type=Path, action="append", default=[], help="a further skills root to scan")
43 parser.add_argument("--project-root", type=Path, help="project root holding _bmad/, for customization")
44 args = parser.parse_args(argv)
45 roots = ([args.skill.resolve().parent] if args.skill else []) + args.root
46 if not roots:
47 parser.error("give --skill or --root")
48 report = collect(roots, args.project_root.resolve() if args.project_root else None)
49 reconfigure = getattr(sys.stdout, "reconfigure", None)
50 if reconfigure is not None:
51 reconfigure(encoding="utf-8")
52 sys.stdout.write(json.dumps(report, indent=2, ensure_ascii=False) + "\n")
53 return 0
56def collect(roots: list[Path], project_root: Path | None = None) -> dict[str, object]:
57 found = scan(roots)
58 problems = found.problems
59 skills = found.folders
60 files: dict[tuple[str, str], dict[str, object]] = {}
62 for module in found.modules:
63 if (module.folder / ROSTER_NAME).exists():
64 record_file(files, problems, module)
66 members: dict[str, dict[str, object]] = {}
67 groups: dict[str, dict[str, object]] = {}
68 for (code, path), file in sorted(files.items()):
69 source = file["module"].table.get("update_source")
70 for member in listed(file["data"], "members", problems, code, path):
71 add_member(members, problems, member, code, path, source, skills, project_root)
72 for group in listed(file["data"], "groups", problems, code, path):
73 add_group(groups, problems, group, code, path)
75 agents = {code: member for code, member in members.items() if member.get("installed")}
76 apply_central_agents(agents, members, problems, project_root)
78 return {
79 "agents": agents,
80 "members": members,
81 "groups": list(groups.values()),
82 "rosters": [
83 {"module": code, "path": path, "skills": [name for name in file["module"].skills if name in skills]}
84 for (code, path), file in sorted(files.items())
85 ],
86 "problems": problems,
87 }
90def listed(data: dict[str, object], key: str, problems: list[dict[str, object]], module: str, path: str) -> list:
91 found = data.get(key, [])
92 if isinstance(found, list):
93 return found
94 problems.append({"kind": "roster", "problem": f"{module} {path}: '{key}' is not a list"})
95 return []
98def record_file(
99 files: dict[tuple[str, str], dict[str, object]], problems: list[dict[str, object]], module: Module
100) -> None:
101 folder = module.folder
102 path = folder / ROSTER_NAME
103 try:
104 data = tomllib.loads(read_document(path, folder).decode("utf-8"))
105 except (OSError, ValueError, UnicodeError, tomllib.TOMLDecodeError) as error:
106 problems.append({"kind": "roster", "skill": folder.name, "problem": f"{path}: {error}"})
107 return
108 files.setdefault((module.code, ROSTER_NAME), {"module": module, "data": data})
111def add_member(
112 members: dict[str, dict[str, object]],
113 problems: list[dict[str, object]],
114 member: object,
115 module: str,
116 path: str,
117 source: object,
118 skills: dict[str, Path],
119 project_root: Path | None,
120) -> None:
121 code = member.get("code") if isinstance(member, dict) else None
122 if not isinstance(code, str) or not code:
123 problems.append({"kind": "member", "problem": f"{module} {path}: a member has no code"})
124 return
125 if code in members:
126 problems.append(
127 {
128 "kind": "member",
129 "problem": f"{module} {path}: member {code!r} is already defined by {members[code]['module']}",
130 }
131 )
132 return
133 entry: dict[str, object] = {"code": code, "module": module, "source": "roster"}
134 for field in MEMBER_FIELDS:
135 if isinstance(member.get(field), str):
136 entry[field] = member[field]
137 skill = member.get("skill")
138 if isinstance(skill, str) and skill:
139 entry["skill"] = skill
140 entry["installed"] = skill in skills
141 if entry["installed"]:
142 entry.update(agent_identity(skills[skill], project_root))
143 else:
144 command = install_command(source, skill)
145 if command:
146 entry["install"] = command
147 entry.setdefault("name", code)
148 members[code] = entry
151def agent_identity(skill_dir: Path, project_root: Path | None) -> dict[str, str]:
152 """The name, title and icon the agent actually answers to, after any customization."""
153 try:
154 agent = load_customization(project_root, skill_dir).get("agent", {})
155 except (ConfigError, OSError):
156 return {}
157 if not isinstance(agent, dict):
158 return {}
159 return {field: agent[field] for field in AGENT_FIELDS if isinstance(agent.get(field), str) and agent[field]}
162def add_group(
163 groups: dict[str, dict[str, object]], problems: list[dict[str, object]], group: object, module: str, path: str
164) -> None:
165 group_id = group.get("id") if isinstance(group, dict) else None
166 if not isinstance(group_id, str) or not group_id:
167 problems.append({"kind": "group", "problem": f"{module} {path}: a group has no id"})
168 return
169 if group_id in groups:
170 problems.append(
171 {
172 "kind": "group",
173 "problem": f"{module} {path}: group {group_id!r} is already defined by {groups[group_id]['module']}",
174 }
175 )
176 return
177 groups[group_id] = {**group, "module": module}
180def apply_central_agents(
181 agents: dict[str, dict[str, object]],
182 members: dict[str, dict[str, object]],
183 problems: list[dict[str, object]],
184 project_root: Path | None,
185) -> None:
186 """Lay the central config's [agents.<code>] tables over the scan.
188 This is how a user adds an agent of their own or describes one further, and
189 how an install made before rosters existed keeps the agents it recorded.
190 An entry for a roster agent whose skill is gone is skipped: the old
191 installer recorded it and nothing removed it when the skill went. For a
192 roster agent the roster and the skill's customization decide name, title,
193 icon and module, so a recorded default never undoes a customized name.
194 """
195 if project_root is None or not (project_root / "_bmad").is_dir():
196 return
197 try:
198 configured = load_central_config(project_root).get("agents", {})
199 except (ConfigError, OSError) as error:
200 problems.append({"kind": "config", "problem": str(error)})
201 return
202 if not isinstance(configured, dict):
203 return
204 for code, info in configured.items():
205 if not isinstance(info, dict) or members.get(code, {}).get("installed") is False:
206 continue
207 entry = agents.setdefault(code, {"code": code, "source": "config"})
208 settled = (
209 {"module", *(field for field in AGENT_FIELDS if field in entry)} if entry["source"] == "roster" else set()
210 )
211 for field, value in info.items():
212 if field in settled:
213 continue
214 # Older installs recorded the persona paragraph as `description`.
215 target = "persona" if field == "description" and "persona" not in info else field
216 entry[target] = value
217 entry.setdefault("name", code)
220if __name__ == "__main__":
221 if sys.platform == "win32":
222 # Piped output on Windows defaults to a legacy code page, not UTF-8.
223 sys.stdout.reconfigure(encoding="utf-8")
224 sys.stderr.reconfigure(encoding="utf-8")
225 sys.exit(main())