Skills › BMAD Method › bmad-toolsmith › init_skill.py
init_skill.py
307 lines · skills/bmad-toolsmith/scripts/init_skill.py at bd70c64
1#!/usr/bin/env python3
2# /// script
3# requires-python = ">=3.11"
4# ///
5"""init_skill: scaffold a new skill folder, or check an existing one.
7Create:
8 init_skill.py --name <kebab> --dest <folder> --shape <shape> [--dirs references,scripts,assets]
9 [--bmod <record-folder-name>] [--source <update_source>] [--description "..."]
11 Shapes: plain-skill, script-utility, rendered-skill, rendered-agent, agent, memory-agent,
12 single-skill-module, multi-skill-module. Writes <dest>/<name>/SKILL.md (frontmatter `name` and `description`, the given
13 description or a [TODO: ...] placeholder, and an empty body heading), the requested dirs, and bmod.toml
14 when --bmod is given or the shape is a module:
15 - with --bmod: a [skill] table carrying `bmod` and `source`
16 - single-skill-module: [bmod] with code (the name without its bmad- prefix), version = "0.1.0" and
17 update_source, plus an empty [skill]
18 - multi-skill-module: the module record, a bmod- folder with [bmod] only (code, version, update_source,
19 skills = []) and the fixed record description
20 Never writes module.yaml or a setup skill. Refuses a target folder that already exists.
22Check:
23 init_skill.py --check <skill-folder> [--any-name]
25 Verifies SKILL.md exists, the frontmatter has only `name` and `description`, the name matches the folder
26 and the bmad regex (bmod- for a module record; any kebab name with --any-name), the description is at
27 most 1024 characters and contains "Use when" or "Use if" (a record carries the fixed text instead), the
28 body is not empty, no `[TODO:` is left in any text file, and bmod.toml, when present, parses and carries
29 [skill] or [bmod]. "bmod" in the output is absent, ok or invalid.
31Output: one JSON object on stdout. Create: {"ok", "skill", "dir", "shape", "created": [...]}.
32Check: {"ok", "skill", "bmod", "findings": [{"path", "line", "rule", "text", "fix"}, ...]}.
33Exit 0 on success or a clean check, 1 on a failed check or a refused create, 2 on a usage error.
34"""
36from __future__ import annotations
38import argparse
39import json
40import re
41import sys
42import tomllib
43from pathlib import Path
45SHAPES = (
46 "plain-skill",
47 "script-utility",
48 "rendered-skill",
49 "rendered-agent",
50 "agent",
51 "memory-agent",
52 "single-skill-module",
53 "multi-skill-module",
54)
55KNOWN_DIRS = ("references", "scripts", "assets", "help", "evals")
56KEBAB_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
57BMAD_NAME_RE = re.compile(r"^(?:bmad|bmad-[a-z0-9]+(?:-[a-z0-9]+)*)$")
58RECORD_NAME_RE = re.compile(r"^bmod-[a-z0-9]+(?:-[a-z0-9]+)*$")
59RECORD_DESCRIPTION = "Required bmod metadata. Never invoke this skill."
60USE_WHEN_RE = re.compile(r"\buse\s+(?:when|if)\b", re.IGNORECASE)
61TODO_RE = re.compile(r"\[TODO:")
62TEXT_SUFFIXES = {".md", ".toml", ".py", ".json", ".yaml", ".yml", ".txt", ".html", ".csv"}
63DESCRIPTION_PLACEHOLDER = "[TODO: five-to-eight-word summary. Use when the user says '<phrase>' or '<phrase>'.]"
66def yaml_single_quoted(value: str) -> str:
67 return "'" + value.replace("'", "''") + "'"
70def toml_string(value: str) -> str:
71 return json.dumps(value)
74# --- frontmatter ---------------------------------------------------------------
77def frontmatter_block(content: str) -> tuple[str | None, str]:
78 """Return (frontmatter text or None, body)."""
79 text = content.lstrip()
80 if not text.startswith("---"):
81 return None, content
82 end = text.find("\n---", 3)
83 if end == -1:
84 return None, content
85 after = text[end + 4 :]
86 if after and not after.startswith(("\n", "\r")):
87 return None, content
88 return text[3:end].strip("\r\n"), after
91def parse_frontmatter(content: str) -> tuple[dict[str, str] | None, str]:
92 """Parse `key: value` frontmatter, folding indented continuation lines; return (dict or None, body)."""
93 block, body = frontmatter_block(content)
94 if block is None:
95 return None, body
96 result: dict[str, str] = {}
97 key: str | None = None
98 value = ""
99 for line in block.split("\n"):
100 colon = line.find(":")
101 if colon > 0 and line[:1] not in (" ", "\t"):
102 if key is not None:
103 result[key] = strip_quotes(value.strip())
104 key = line[:colon].strip()
105 value = line[colon + 1 :]
106 elif key is not None and not line.lstrip().startswith("#"):
107 value += "\n" + line
108 if key is not None:
109 result[key] = strip_quotes(value.strip())
110 return result, body
113def strip_quotes(value: str) -> str:
114 if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"":
115 inner = value[1:-1]
116 return inner.replace("''", "'") if value[0] == "'" else inner
117 return value
120# --- create --------------------------------------------------------------------
123def skill_md(name: str, description: str) -> str:
124 return f"---\nname: {name}\ndescription: {yaml_single_quoted(description)}\n---\n\n# {name}\n"
127def bmod_toml(shape: str, name: str, bmod: str | None, source: str | None) -> str:
128 source_value = toml_string(source) if source else toml_string("[TODO: update_source, e.g. github:org/repo/skills]")
129 if shape == "multi-skill-module":
130 code = name.removeprefix("bmod-")
131 return f'[bmod]\ncode = {toml_string(code)}\nversion = "0.1.0"\nupdate_source = {source_value}\nskills = []\n'
132 if shape == "single-skill-module":
133 code = name.removeprefix("bmad-")
134 return (
135 f'[bmod]\ncode = {toml_string(code)}\nversion = "0.1.0"\nupdate_source = {source_value}\n\n'
136 "# The record is in this same file, so the skill table needs no bmod or source.\n[skill]\n"
137 )
138 lines = ["[skill]"]
139 if bmod:
140 lines.append(f"bmod = {toml_string(bmod)}")
141 if source:
142 lines.append(f"source = {toml_string(source)}")
143 return "\n".join(lines) + "\n"
146def create(args) -> dict:
147 name = args.name
148 if not KEBAB_RE.match(name) or len(name) > 64:
149 raise ValueError(f"name {name!r} must be kebab-case (lowercase, digits, single hyphens), at most 64 characters")
150 if args.shape == "multi-skill-module" and not RECORD_NAME_RE.match(name):
151 raise ValueError("a multi-skill-module record is named bmod-<code>")
152 dirs = [d.strip() for d in (args.dirs or "").split(",") if d.strip()]
153 unknown = [d for d in dirs if d not in KNOWN_DIRS]
154 if unknown:
155 raise ValueError(f"unknown dirs {', '.join(unknown)}; known: {', '.join(KNOWN_DIRS)}")
156 skill_dir = Path(args.dest) / name
157 if skill_dir.exists():
158 raise FileExistsError(f"{skill_dir} already exists")
160 description = args.description or DESCRIPTION_PLACEHOLDER
161 if args.shape == "multi-skill-module":
162 description = RECORD_DESCRIPTION
164 skill_dir.mkdir(parents=True)
165 created = []
166 emitted = [("SKILL.md", skill_md(name, description))]
167 if args.bmod or args.shape in ("single-skill-module", "multi-skill-module"):
168 emitted.append(("bmod.toml", bmod_toml(args.shape, name, args.bmod, args.source)))
169 for rel, text in emitted:
170 (skill_dir / rel).write_text(text, encoding="utf-8")
171 created.append(rel)
172 for d in dirs:
173 (skill_dir / d).mkdir()
174 created.append(d + "/")
175 return {"ok": True, "skill": name, "dir": str(skill_dir), "shape": args.shape, "created": created}
178# --- check ---------------------------------------------------------------------
181def finding(path: str, line: int, rule: str, text: str, fix: str) -> dict:
182 return {"path": path, "line": line, "rule": rule, "text": text[:200], "fix": fix}
185def read_bmod(skill_dir: Path, findings: list[dict]) -> tuple[dict | None, str]:
186 """Return (parsed bmod.toml or None, status): status is absent, ok or invalid."""
187 path = skill_dir / "bmod.toml"
188 if not path.is_file():
189 return None, "absent"
190 try:
191 data = tomllib.loads(path.read_text(encoding="utf-8"))
192 except (tomllib.TOMLDecodeError, UnicodeDecodeError) as err:
193 findings.append(finding("bmod.toml", 1, "bmod-invalid", str(err), "fix the TOML"))
194 return None, "invalid"
195 if "skill" not in data and "bmod" not in data:
196 findings.append(finding("bmod.toml", 1, "bmod-tables", "neither [skill] nor [bmod]", "add a [skill] table"))
197 return data, "invalid"
198 return data, "ok"
201def check(skill_dir: Path, any_name: bool) -> dict:
202 findings: list[dict] = []
203 bmod, bmod_status = read_bmod(skill_dir, findings)
204 is_record = bool(bmod) and "bmod" in bmod and "skill" not in bmod
206 skill_path = skill_dir / "SKILL.md"
207 if not skill_path.is_file():
208 findings.append(finding("SKILL.md", 1, "skill-md-missing", "no SKILL.md", "create SKILL.md"))
209 else:
210 content = skill_path.read_text(encoding="utf-8", errors="replace")
211 meta, body = parse_frontmatter(content)
212 if meta is None:
213 findings.append(
214 finding("SKILL.md", 1, "frontmatter-missing", content[:80], "open with --- name/description ---")
215 )
216 else:
217 extra = sorted(set(meta) - {"name", "description"})
218 if extra:
219 findings.append(
220 finding("SKILL.md", 1, "frontmatter-keys", ", ".join(extra), "keep only name and description")
221 )
222 name = meta.get("name", "")
223 if not name:
224 findings.append(finding("SKILL.md", 1, "name-missing", "", "add name: <folder name>"))
225 else:
226 if name != skill_dir.name:
227 findings.append(
228 finding("SKILL.md", 2, "name-folder-mismatch", name, f"name must equal {skill_dir.name}")
229 )
230 regex = KEBAB_RE if any_name else (RECORD_NAME_RE if is_record else BMAD_NAME_RE)
231 if not regex.match(name):
232 findings.append(finding("SKILL.md", 2, "name-format", name, f"name must match {regex.pattern}"))
233 desc = meta.get("description", "")
234 if not desc:
235 findings.append(
236 finding("SKILL.md", 1, "description-missing", "", "add a description with a Use when clause")
237 )
238 else:
239 if len(desc) > 1024:
240 findings.append(
241 finding("SKILL.md", 3, "description-length", f"{len(desc)} chars", "cut to 1024 or fewer")
242 )
243 if is_record:
244 if desc != RECORD_DESCRIPTION:
245 findings.append(
246 finding(
247 "SKILL.md",
248 3,
249 "description-trigger",
250 desc,
251 f"a record's description is {RECORD_DESCRIPTION!r}",
252 )
253 )
254 elif not USE_WHEN_RE.search(desc):
255 findings.append(finding("SKILL.md", 3, "description-trigger", desc, 'add a "Use when ..." clause'))
256 if not body.strip():
257 findings.append(finding("SKILL.md", 1, "body-empty", "", "write the skill body after the frontmatter"))
259 for path in sorted(skill_dir.rglob("*")):
260 rel = path.relative_to(skill_dir)
261 if not path.is_file() or path.suffix not in TEXT_SUFFIXES or any(p.startswith(".") for p in rel.parts):
262 continue
263 for number, line in enumerate(path.read_text(encoding="utf-8", errors="replace").splitlines(), 1):
264 if TODO_RE.search(line):
265 findings.append(
266 finding(rel.as_posix(), number, "todo-left", line.strip(), "fill in or remove the placeholder")
267 )
269 return {"ok": not findings, "skill": skill_dir.name, "bmod": bmod_status, "findings": findings}
272# --- cli -----------------------------------------------------------------------
275def main(argv: list[str] | None = None) -> int:
276 p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
277 p.add_argument("--check", type=Path, metavar="SKILL", help="check this skill folder instead of creating one")
278 p.add_argument("--any-name", action="store_true", help="with --check: accept any kebab-case name")
279 p.add_argument("--name", help="skill name, kebab-case")
280 p.add_argument("--dest", type=Path, help="parent folder the skill folder is created under")
281 p.add_argument("--shape", choices=SHAPES, help="skill shape")
282 p.add_argument("--dirs", default="", help=f"comma-separated folders to create: {', '.join(KNOWN_DIRS)}")
283 p.add_argument("--bmod", help="record folder name for the [skill] table, e.g. bmod-method")
284 p.add_argument("--source", help="update_source for the [skill] or [bmod] table")
285 p.add_argument("--description", help="frontmatter description; a [TODO: ...] placeholder when omitted")
286 args = p.parse_args(argv)
288 if args.check:
289 if not args.check.is_dir():
290 p.error(f"not a directory: {args.check}")
291 result = check(args.check, args.any_name)
292 print(json.dumps(result, indent=2))
293 return 0 if result["ok"] else 1
295 if not (args.name and args.dest and args.shape):
296 p.error("--name, --dest and --shape are required to create a skill (or use --check)")
297 try:
298 result = create(args)
299 except (ValueError, FileExistsError) as err:
300 print(json.dumps({"ok": False, "error": str(err)}))
301 return 1
302 print(json.dumps(result, indent=2))
303 return 0
306if __name__ == "__main__":
307 sys.exit(main())