Skill Tracer
Skills › BMAD Method › bmad-toolsmith › prepass.py

prepass.py

162 lines · skills/bmad-toolsmith/scripts/prepass.py at bd70c64

1#!/usr/bin/env python3
2# /// script
3# requires-python = ">=3.11"
4# dependencies = ["tiktoken"]
5# ///
6"""prepass: the deterministic pre-pass the review lenses read before opening any file.
7
8One skill folder in, one JSON object out. Token counts come from count_tokens, path findings from
9scan_paths and script findings from scan_scripts, all imported from this folder, so the numbers match
10what each script prints on its own.
12shape_hint is inferred, first match wins:
13 memory-agent `_bmad/memory` in SKILL.md, or scripts/wake.py or scripts/init-sanctum.py present
14 rendered-agent SKILL.md calls render_skill.py and customize.toml has an [agent] table
15 rendered-skill SKILL.md calls render_skill.py
16 agent customize.toml has an [agent] table
17 single-skill-module bmod.toml has both [bmod] and [skill]
18 multi-skill-module bmod.toml has [bmod] only (a module record)
19 script-utility scripts/ exists and SKILL.md is under 60 lines
20 plain-skill everything else; "unknown" when there is no SKILL.md
22Usage:
23 prepass.py <skill-folder>
25Output, one JSON object on stdout:
26 {"skill", "shape_hint", "files": [{"path", "tokens", "kind"}], "skill_md_tokens", "total_tokens",
27 "has_customize", "has_scripts", "scripts": [{"path", "has_pep723", "has_test"}],
28 "description_chars", "has_use_when", "frontmatter_ok", "bmod_kind", "path_findings": [...], "script_findings": [...]}
29 kind is one of entry, prompt, script, test, asset, config, other.
30 bmod_kind is none (no bmod.toml), skill (a module member), record (a record folder), record+skill (a
31 single-skill module) or invalid (a file with neither table, or one that does not parse).
32"""
34from __future__ import annotations
36import argparse
37import json
38import re
39import sys
40import tomllib
41from pathlib import Path
43sys.path.insert(0, str(Path(__file__).resolve().parent))
45from count_tokens import count_tokens, iter_text_files, read_text # noqa: E402
46from init_skill import USE_WHEN_RE, parse_frontmatter # noqa: E402
47from scan_paths import scan_skill # noqa: E402
48from scan_scripts import scan_scripts # noqa: E402
50MEMORY_RE = re.compile(r"_bmad/memory")
51SCRIPT_UTILITY_MAX_LINES = 60
54def kind_of(rel: Path) -> str:
55 parts = rel.parts
56 if "assets" in parts[:-1]:
57 return "asset"
58 if parts[0] == "scripts":
59 return "test" if "tests" in parts[1:-1] else "script"
60 if rel.suffix == ".md":
61 return "entry" if rel.name == "SKILL.md" and len(parts) == 1 else "prompt"
62 if rel.suffix == ".toml":
63 return "config"
64 return "other"
67def load_toml(path: Path) -> dict:
68 try:
69 return tomllib.loads(path.read_text(encoding="utf-8"))
70 except (OSError, tomllib.TOMLDecodeError, UnicodeDecodeError):
71 return {}
74def shape_hint(root: Path, skill_text: str | None) -> str:
75 if skill_text is None:
76 return "unknown"
77 scripts = root / "scripts"
78 if MEMORY_RE.search(skill_text) or (scripts / "wake.py").is_file() or (scripts / "init-sanctum.py").is_file():
79 return "memory-agent"
80 is_agent = "agent" in load_toml(root / "customize.toml")
81 if "render_skill.py" in skill_text:
82 return "rendered-agent" if is_agent else "rendered-skill"
83 if is_agent:
84 return "agent"
85 bmod = load_toml(root / "bmod.toml")
86 if "bmod" in bmod:
87 return "single-skill-module" if "skill" in bmod else "multi-skill-module"
88 if scripts.is_dir() and len(skill_text.splitlines()) < SCRIPT_UTILITY_MAX_LINES:
89 return "script-utility"
90 return "plain-skill"
93def bmod_kind(root: Path) -> str:
94 manifest = root / "bmod.toml"
95 if not manifest.is_file():
96 return "none"
97 data = load_toml(manifest)
98 has_record = isinstance(data.get("bmod"), dict)
99 has_skill = isinstance(data.get("skill"), dict)
100 if has_record and has_skill:
101 return "record+skill"
102 if has_record:
103 return "record"
104 if has_skill:
105 return "skill"
106 return "invalid"
109def build(root: Path) -> dict:
110 skill_path = root / "SKILL.md"
111 skill_text = read_text(skill_path) if skill_path.is_file() else None
113 files = []
114 skill_md_tokens = 0
115 for path in iter_text_files(root):
116 rel = path.relative_to(root)
117 tokens, _ = count_tokens(read_text(path))
118 files.append({"path": rel.as_posix(), "tokens": tokens, "kind": kind_of(rel)})
119 if path == skill_path:
120 skill_md_tokens = tokens
122 meta = parse_frontmatter(skill_text)[0] if skill_text is not None else None
123 description = (meta or {}).get("description", "")
124 frontmatter_ok = bool(
125 meta and meta.get("name") == root.name and description and set(meta) <= {"name", "description"}
126 )
128 scripts_result = scan_scripts(root)
129 return {
130 "skill": root.name,
131 "shape_hint": shape_hint(root, skill_text),
132 "files": files,
133 "skill_md_tokens": skill_md_tokens,
134 "total_tokens": sum(f["tokens"] for f in files),
135 "has_customize": (root / "customize.toml").is_file(),
136 "has_scripts": (root / "scripts").is_dir(),
137 "scripts": [
138 {"path": s["path"], "has_pep723": s["has_pep723"], "has_test": s["has_test"]}
139 for s in scripts_result["scripts"]
140 ],
141 "description_chars": len(description),
142 "has_use_when": bool(USE_WHEN_RE.search(description)),
143 "frontmatter_ok": frontmatter_ok,
144 "bmod_kind": bmod_kind(root),
145 "path_findings": scan_skill(root)["findings"],
146 "script_findings": scripts_result["findings"],
147 }
150def main(argv: list[str] | None = None) -> int:
151 p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
152 p.add_argument("skill", type=Path, help="skill folder to analyze")
153 args = p.parse_args(argv)
154 root = args.skill.expanduser().resolve()
155 if not root.is_dir():
156 p.error(f"not a directory: {args.skill}")
157 print(json.dumps(build(root), indent=2))
158 return 0
161if __name__ == "__main__":
162 sys.exit(main())