Skill Tracer
Skills › BMAD Method › bmad-agent-po › read_store.py

read_store.py

124 lines · skills/bmad-agent-po/scripts/read_store.py at bd70c64

1#!/usr/bin/env python3
2# /// script
3# requires-python = ">=3.11"
4# ///
5"""Read the ticket store's config without loading the rest into context.
6
7The config is two layers. The base is the skill's starter for the store, `config/<store>-ticketing.toml`.
8Over it goes the project's `_bmad/custom/ticketing-store-config.toml`: its `[tickets] store` names the
9store, and any other key it sets replaces the starter's, tables merging key by key. With no project file
10the store is `repo`. A store with no starter is read from the project file alone.
12 read_store.py --project-root <root> [-k <dotted key> ...]
13 read_store.py --starters
15One key prints its value, a string bare and anything else as JSON; several print one JSON object; none
16prints the whole merged config. `--starters` prints each shipped store with its description.
17Exit 1 when a file cannot be read, 2 when a key is missing.
18"""
20import argparse
21import json
22import re
23import sys
24import tomllib
25from pathlib import Path
27sys.dont_write_bytecode = True
29_MISSING = object()
30STARTERS = Path(__file__).resolve().parents[1] / "config"
31PROJECT_FILE = Path("_bmad") / "custom" / "ticketing-store-config.toml"
32STORE_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
35def load(path: Path) -> dict:
36 # utf-8-sig: Windows editors can save a byte-order mark.
37 return tomllib.loads(path.read_text(encoding="utf-8-sig"))
40def merge(base: dict, over: dict) -> dict:
41 out = dict(base)
42 for key, value in over.items():
43 out[key] = merge(out[key], value) if isinstance(value, dict) and isinstance(out.get(key), dict) else value
44 return out
47def store_config(project_root: Path, starters: Path) -> dict:
48 path = project_root / PROJECT_FILE
49 project = load(path) if path.is_file() else {}
50 tickets = project.get("tickets")
51 store = tickets.get("store") if isinstance(tickets, dict) else None
52 if not isinstance(store, str) or not store:
53 store = "repo" # as tickets.py reads it
54 starter = starters / f"{store}-ticketing.toml"
55 config = merge(load(starter), project) if STORE_RE.match(store) and starter.is_file() else project
56 if isinstance(config.get("tickets"), dict):
57 config["tickets"]["store"] = store
58 return config
61def extract(data, dotted: str):
62 current = data
63 for part in dotted.split("."):
64 if isinstance(current, dict) and part in current:
65 current = current[part]
66 else:
67 return _MISSING
68 return current
71def main() -> int:
72 parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
73 parser.add_argument("--project-root", help="project holding _bmad/")
74 parser.add_argument("--key", "-k", action="append", default=[], help="dotted key (repeatable)")
75 parser.add_argument("--starters", action="store_true", help="list the shipped stores and their descriptions")
76 parser.add_argument(
77 "--starters-dir", default=str(STARTERS), help="folder of starters; default: the skill's config/"
78 )
79 args = parser.parse_args()
80 starters = Path(args.starters_dir).expanduser()
82 try:
83 if args.starters:
84 found = {}
85 for path in sorted(starters.glob("*-ticketing.toml")):
86 data = load(path)
87 found[data.get("tickets", {}).get("store", path.name)] = data.get("description", "")
88 print(json.dumps(found, indent=2, ensure_ascii=False))
89 return 0
90 if not args.project_root:
91 parser.error("--project-root is required")
92 data = store_config(Path(args.project_root).expanduser(), starters)
93 except (OSError, ValueError) as error: # ValueError covers a TOML or encoding failure
94 sys.stderr.write(f"error: cannot read the store config: {error}\n")
95 return 1
97 if not args.key:
98 print(json.dumps(data, indent=2, ensure_ascii=False))
99 return 0
101 found = {}
102 missing = []
103 for key in args.key:
104 value = extract(data, key)
105 (missing.append(key) if value is _MISSING else found.__setitem__(key, value))
106 for key in missing:
107 sys.stderr.write(f"missing: {key}\n")
109 if len(args.key) == 1:
110 if missing:
111 return 2
112 value = found[args.key[0]]
113 print(value.rstrip("\n") if isinstance(value, str) else json.dumps(value, indent=2, ensure_ascii=False))
114 else:
115 print(json.dumps(found, indent=2, ensure_ascii=False))
116 return 2 if missing else 0
119if __name__ == "__main__":
120 if sys.platform == "win32":
121 # Piped output on Windows defaults to a legacy code page, not UTF-8.
122 sys.stdout.reconfigure(encoding="utf-8")
123 sys.stderr.reconfigure(encoding="utf-8")
124 raise SystemExit(main())