basic BMAD setup

This commit is contained in:
Ebbe Baß
2026-10-15 08:57:23 +02:00
parent 44029127dc
commit 5e747654b9
438 changed files with 41818 additions and 0 deletions
@@ -0,0 +1,124 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# ///
"""Read the ticket store's config without loading the rest into context.
The config is two layers. The base is the skill's starter for the store, `config/<store>-ticketing.toml`.
Over it goes the project's `_bmad/custom/ticketing-store-config.toml`: its `[tickets] store` names the
store, and any other key it sets replaces the starter's, tables merging key by key. With no project file
the store is `repo`. A store with no starter is read from the project file alone.
read_store.py --project-root <root> [-k <dotted key> ...]
read_store.py --starters
One key prints its value, a string bare and anything else as JSON; several print one JSON object; none
prints the whole merged config. `--starters` prints each shipped store with its description.
Exit 1 when a file cannot be read, 2 when a key is missing.
"""
import argparse
import json
import re
import sys
import tomllib
from pathlib import Path
sys.dont_write_bytecode = True
_MISSING = object()
STARTERS = Path(__file__).resolve().parents[1] / "config"
PROJECT_FILE = Path("_bmad") / "custom" / "ticketing-store-config.toml"
STORE_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
def load(path: Path) -> dict:
# utf-8-sig: Windows editors can save a byte-order mark.
return tomllib.loads(path.read_text(encoding="utf-8-sig"))
def merge(base: dict, over: dict) -> dict:
out = dict(base)
for key, value in over.items():
out[key] = merge(out[key], value) if isinstance(value, dict) and isinstance(out.get(key), dict) else value
return out
def store_config(project_root: Path, starters: Path) -> dict:
path = project_root / PROJECT_FILE
project = load(path) if path.is_file() else {}
tickets = project.get("tickets")
store = tickets.get("store") if isinstance(tickets, dict) else None
if not isinstance(store, str) or not store:
store = "repo" # as tickets.py reads it
starter = starters / f"{store}-ticketing.toml"
config = merge(load(starter), project) if STORE_RE.match(store) and starter.is_file() else project
if isinstance(config.get("tickets"), dict):
config["tickets"]["store"] = store
return config
def extract(data, dotted: str):
current = data
for part in dotted.split("."):
if isinstance(current, dict) and part in current:
current = current[part]
else:
return _MISSING
return current
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--project-root", help="project holding _bmad/")
parser.add_argument("--key", "-k", action="append", default=[], help="dotted key (repeatable)")
parser.add_argument("--starters", action="store_true", help="list the shipped stores and their descriptions")
parser.add_argument(
"--starters-dir", default=str(STARTERS), help="folder of starters; default: the skill's config/"
)
args = parser.parse_args()
starters = Path(args.starters_dir).expanduser()
try:
if args.starters:
found = {}
for path in sorted(starters.glob("*-ticketing.toml")):
data = load(path)
found[data.get("tickets", {}).get("store", path.name)] = data.get("description", "")
print(json.dumps(found, indent=2, ensure_ascii=False))
return 0
if not args.project_root:
parser.error("--project-root is required")
data = store_config(Path(args.project_root).expanduser(), starters)
except (OSError, ValueError) as error: # ValueError covers a TOML or encoding failure
sys.stderr.write(f"error: cannot read the store config: {error}\n")
return 1
if not args.key:
print(json.dumps(data, indent=2, ensure_ascii=False))
return 0
found = {}
missing = []
for key in args.key:
value = extract(data, key)
(missing.append(key) if value is _MISSING else found.__setitem__(key, value))
for key in missing:
sys.stderr.write(f"missing: {key}\n")
if len(args.key) == 1:
if missing:
return 2
value = found[args.key[0]]
print(value.rstrip("\n") if isinstance(value, str) else json.dumps(value, indent=2, ensure_ascii=False))
else:
print(json.dumps(found, indent=2, ensure_ascii=False))
return 2 if missing else 0
if __name__ == "__main__":
if sys.platform == "win32":
# Piped output on Windows defaults to a legacy code page, not UTF-8.
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
raise SystemExit(main())
@@ -0,0 +1,153 @@
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
SCRIPT = Path(__file__).resolve().parents[1] / "read_store.py"
REPO = '''
# comment that must never be printed
description = "Files in the repo."
[tickets]
store = "repo"
access = "Read and write the files."
[verbs]
write = """
Edit the file.
Commit it.
"""
[tickets.status]
backlog = "backlog"
done = "done"
'''
JIRA = """
description = "Jira through acli."
[tickets]
store = "jira"
key = ""
site = ""
[verbs]
write = "acli create"
query = "acli view"
[tickets.status]
backlog = "To Do"
review = "In Review"
done = "Done"
"""
class ReadStoreTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.root = Path(self.tmp.name) / "project"
self.starters = Path(self.tmp.name) / "config"
(self.root / "_bmad" / "custom").mkdir(parents=True)
self.starters.mkdir()
(self.starters / "repo-ticketing.toml").write_text(REPO, encoding="utf-8")
(self.starters / "jira-ticketing.toml").write_text(JIRA, encoding="utf-8")
def tearDown(self):
self.tmp.cleanup()
def project(self, text):
(self.root / "_bmad" / "custom" / "ticketing-store-config.toml").write_text(text, encoding="utf-8")
def run_script(self, *args):
return subprocess.run(
[sys.executable, str(SCRIPT), "--starters-dir", str(self.starters), *args],
encoding="utf-8",
capture_output=True,
check=False,
)
def read(self, *keys):
args = [a for k in keys for a in ("-k", k)]
return self.run_script("--project-root", str(self.root), *args)
def test_no_project_file_reads_the_repo_starter(self):
result = self.read("tickets.store")
self.assertEqual((result.returncode, result.stdout), (0, "repo\n"))
result = self.read("verbs.write")
self.assertEqual(result.stdout, "Edit the file.\nCommit it.\n")
self.assertNotIn("comment", self.read().stdout)
def test_the_project_file_names_the_store_and_its_keys_win_one_by_one(self):
self.project('[tickets]\nstore = "jira"\nkey = "SHOP"\n\n[tickets.status]\nreview = ""\n')
result = self.read("tickets", "verbs.query")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(
json.loads(result.stdout),
{
"tickets": {
"store": "jira",
"key": "SHOP",
"site": "",
"status": {"backlog": "To Do", "review": "", "done": "Done"},
},
"verbs.query": "acli view",
},
)
def test_a_verb_the_project_file_sets_replaces_the_starters(self):
self.project('[tickets]\nstore = "jira"\n\n[verbs]\nwrite = "our own create"\n')
self.assertEqual(self.read("verbs.write").stdout, "our own create\n")
self.assertEqual(self.read("verbs.query").stdout, "acli view\n")
def test_a_store_with_no_starter_is_read_from_the_project_file_alone(self):
self.project('[tickets]\nstore = "azure"\n\n[verbs]\nwrite = "az boards"\n')
self.assertEqual(self.read("verbs.write").stdout, "az boards\n")
result = self.read("verbs.query")
self.assertEqual(result.returncode, 2)
self.assertIn("missing: verbs.query", result.stderr)
def test_a_store_name_that_is_no_file_name_reads_no_starter(self):
self.project('[tickets]\nstore = "../repo"\n')
self.assertEqual(self.read("verbs.write").returncode, 2)
def test_a_project_file_that_names_no_store_reads_the_repo_starter(self):
for text in ("[workflow]\nx = 1\n", "[tickets]\nstore = 5\n", '[tickets]\nstore = ""\nkey = "K"\n'):
self.project(text)
self.assertEqual(self.read("verbs.write").stdout, "Edit the file.\nCommit it.\n", text)
self.assertEqual(self.read("tickets.store").stdout, "repo\n", text)
self.assertEqual(self.read("tickets.key").stdout, "K\n")
def test_several_keys_with_one_missing_print_the_rest_and_exit_2(self):
result = self.read("tickets.store", "tickets.nope")
self.assertEqual(result.returncode, 2)
self.assertEqual(json.loads(result.stdout), {"tickets.store": "repo"})
self.assertIn("missing: tickets.nope", result.stderr)
def test_an_unreadable_project_file_exits_1(self):
self.project("[tickets\nstore = ")
result = self.read("tickets.store")
self.assertEqual(result.returncode, 1)
self.assertIn("cannot read the store config", result.stderr)
def test_starters_lists_each_store_with_its_description(self):
result = self.run_script("--starters")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(json.loads(result.stdout), {"jira": "Jira through acli.", "repo": "Files in the repo."})
def test_every_shipped_starter_parses_and_is_named_for_its_store(self):
shipped = SCRIPT.parents[1] / "config"
result = subprocess.run(
[sys.executable, str(SCRIPT), "--starters"], encoding="utf-8", capture_output=True, check=False
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(
sorted(f"{store}-ticketing.toml" for store in json.loads(result.stdout)),
sorted(p.name for p in shipped.glob("*-ticketing.toml")),
)
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff