#!/usr/bin/env python3 # /// script # requires-python = ">=3.10" # /// """Serve the brainstorming technique library without loading it all into context. The library is a CSV (category, technique_name, description, detail). `description` is a short gist — enough to propose and run most techniques. `detail` is optional: a path (relative to the CSV's directory) to a fuller instruction file for a technique complex enough to warrant one. Only `show` resolves detail files, and only for the technique asked for — so the heavy material never enters context until it is run. Commands: categories list category names + counts (the cheap entry point) list --category C [...] the index (name + gist) for those categories list --all the whole index at once — deliberate; large, avoid interactively show NAME [NAME ...] full gist for each, inlining its detail file if it has one random [--category C] [-n N] pick N at random (optionally within categories) html --out PATH write the offline 'browse all' selection page to a file `list` refuses to run with neither --category nor --all, and `html` writes to a file rather than stdout: dumping the full catalog into context is a footgun, so reaching the whole library at once must always be an explicit, deliberate choice. `--extra PATH` merges a JSON overlay of additional techniques (customize.toml's `additional_techniques`) into every command, so custom techniques and whole new categories are first-class everywhere — including the browse page and category draws. Default output is lean text for an LLM to read; pass --json for structured output. """ import argparse import csv import hashlib import html import json import random import sys from pathlib import Path DEFAULT_FILE = Path(__file__).resolve().parent.parent / "assets" / "brain-methods.csv" FIELDS = ("category", "technique_name", "description", "detail", "provenance", "good_for", "audience") # Optional columns beyond the original four — absent in older CSVs and in --extra # overlays, so always read through .get/setdefault. `provenance` (classic|signature| # playful) drives the "Proven & Professional" lead group; `good_for` (a |-separated # list of goal tags) drives the browse page's goal filter; `audience` (solo|group|either) # is advisory. OPTIONAL_FIELDS = ("detail", "provenance", "good_for", "audience") def load(file: Path) -> list[dict]: with open(file, newline="", encoding="utf-8") as f: rows = list(csv.DictReader(f)) for r in rows: for k in OPTIONAL_FIELDS: r.setdefault(k, "") r[k] = (r.get(k) or "").strip() return rows def load_extra(file: Path) -> list[dict]: """Merge-in techniques from a JSON overlay — a list of {category, technique_name, description[, detail]} objects. This is how customize.toml's `additional_techniques` become first-class across *every* subcommand (categories/list/random/show/html), so the browse page and category draws include them too, not just the in-chat flows.""" data = json.loads(file.read_text(encoding="utf-8")) rows = [] for item in data: rows.append({ "category": str(item.get("category", "")).strip(), "technique_name": str(item.get("technique_name", "")).strip(), "description": str(item.get("description", "")).strip(), "detail": str(item.get("detail") or "").strip(), "provenance": str(item.get("provenance") or "").strip(), "good_for": str(item.get("good_for") or "").strip(), "audience": str(item.get("audience") or "").strip(), }) return rows def categories(rows: list[dict]) -> list[tuple[str, int]]: counts: dict[str, int] = {} for r in rows: counts[r["category"]] = counts.get(r["category"], 0) + 1 return sorted(counts.items()) def filter_cats(rows: list[dict], cats: list[str] | None) -> list[dict]: if not cats: return rows wanted = {c.lower() for c in cats} return [r for r in rows if r["category"].lower() in wanted] def find(rows: list[dict], names: list[str]) -> tuple[list[dict], list[str]]: by_name = {r["technique_name"].lower(): r for r in rows} found, missing = [], [] for n in names: r = by_name.get(n.strip().lower()) (found if r else missing).append(r if r else n) return found, missing def resolve_detail(row: dict, csv_dir: Path) -> str | None: """Return the contents of a row's detail file, or None if there is no detail (or the file is missing — a missing file is reported to stderr, not fatal).""" if not row.get("detail"): return None path = (csv_dir / row["detail"]).resolve() if not path.is_file(): print(f"# detail file not found for {row['technique_name']}: {row['detail']}", file=sys.stderr) return None return path.read_text(encoding="utf-8").strip() def fmt_categories(cats: list[tuple[str, int]], as_json: bool) -> str: if as_json: return json.dumps([{"category": c, "count": n} for c, n in cats]) return "\n".join(f"{c}\t{n}" for c, n in cats) def fmt_list(rows: list[dict], as_json: bool) -> str: if as_json: return json.dumps([{k: r[k] for k in ("category", "technique_name", "description")} for r in rows]) return "\n".join(f"{r['category']}\t{r['technique_name']}\t{r['description']}" for r in rows) def fmt_show(rows: list[dict], csv_dir: Path, as_json: bool) -> str: if as_json: out = [] for r in rows: d = resolve_detail(r, csv_dir) entry = {k: r[k] for k in ("category", "technique_name", "description")} if d: entry["detail"] = d out.append(entry) return json.dumps(out) blocks = [] for r in rows: block = f"## {r['technique_name']} [{r['category']}]\n{r['description']}" d = resolve_detail(r, csv_dir) if d: block += f"\n\n{d}" blocks.append(block) return "\n\n".join(blocks) def pretty(cat: str) -> str: """Turn a category slug (e.g. 'speculative_future') into a display name.""" return cat.replace("_", " ").replace("-", " ").title() # --- card visuals: a crafted duotone icon + hue per category, plus a per-technique icon --- # The hues and SVG glyphs are *data*, not logic: they live in the icon sidecar # (assets/brain-icons.json) so the catalog's visuals can be edited without touching code. # It maps category slug -> {hue, glyph} and technique name -> svg (inner markup, drawn in # `currentColor` which the CSS sets to the category hue; the shared CHIP frame is added by # the renderer). Anything missing falls back here — an unknown category gets a hash-derived # hue + generic glyph, an unknown/not-yet-iconed technique a neutral mark — so custom # catalogs always render. ICON_FILE = DEFAULT_FILE.parent / "brain-icons.json" CHIP = '' _FALLBACK_GLYPH = ( '' '' '' ) _FALLBACK_TECH = ( '' ) def _load_icons(file: Path = ICON_FILE) -> tuple[dict, dict]: """Read the icon sidecar: (category slug -> {hue, glyph}, technique name -> svg). A missing or malformed file is non-fatal — everything then uses the fallbacks below.""" try: data = json.loads(file.read_text(encoding="utf-8")) except (OSError, ValueError): return {}, {} return (data.get("categories") or {}), (data.get("techniques") or {}) _CATEGORY_STYLES, _TECH_ICONS = _load_icons() def _hsl_hex(deg: int, s: float, lt: float) -> str: import colorsys r, g, b = colorsys.hls_to_rgb((deg % 360) / 360, lt, s) return "#%02x%02x%02x" % (round(r * 255), round(g * 255), round(b * 255)) def category_style(cat: str) -> tuple[str, str]: """(hue, glyph markup) for a category — from the sidecar for the shipped set, derived for extras.""" style = _CATEGORY_STYLES.get(cat) if style and style.get("hue"): return style["hue"], style.get("glyph") or _FALLBACK_GLYPH deg = int(hashlib.md5(cat.encode("utf-8")).hexdigest(), 16) % 360 return _hsl_hex(deg, 0.58, 0.52), _FALLBACK_GLYPH def tech_icon(name: str) -> str: """The hand-picked line-icon for a specific technique (neutral mark if unknown).""" return _TECH_ICONS.get(name, _FALLBACK_TECH) SELECTOR_TEMPLATE = r""" BMad Method Brainstorming Selection

BMad Method Brainstorming Selection

Compose your session, hit Copy prompt, and paste it back into the chat to begin. {{TOTAL}}

Facilitation
Techniques Picked 0 Random 0 Invent 0 AI picks 0 Total 0 · 3–4 is the sweet spot
{{GOALBAR}}
Jump to
{{CHIPS}}
{{BODY}}
BMad Method · Brainstorming
""" # --- browse-page layout: a "Proven & Professional" lead group, then super-groups ---------- CLASSIC_GROUP = "Proven & Professional" LEAD_HUE = "#3d4f73" # a dignified slate for the professional lead group # Super-group order for the shipped categories. Categories not listed (e.g. user-added # via --extra) render last under "More", alphabetically — so custom catalogs always show. CATEGORY_GROUPS = ( ("Structured & Analytical", ("structured", "deep")), ("Creative & Generative", ("creative", "biomimetic", "cultural", "speculative_future", "quantum")), ("Wild & Playful", ("wild", "absurdist", "theatrical", "constraint")), ("Introspective & Personal", ("introspective_delight", "collaborative")), ) # Human labels for the `good_for` goal tags; this dict's order is the filter-bar order. GOAL_LABELS = { "feature": "Build a feature", "novel": "Novel concept", "strategy": "Strategy", "planning": "Planning", "diagnosis": "Diagnose", "personal": "Personal / life", "unstuck": "Get unstuck", } def _good_for_label(good: str) -> str: parts = [GOAL_LABELS.get(g, g) for g in good.split("|") if g] return ("Great for: " + " · ".join(parts)) if parts else "" def _svg(inner: str) -> str: return f'{CHIP}{inner}' def _card(r: dict, lead: bool = False) -> str: """One technique card. `lead=True` cards live in the cross-cutting professional group; they carry their own category hue (inline --c) and data-lead so selection can de-dupe.""" name = html.escape(r["technique_name"]) desc = html.escape(r["description"]) hue, glyph = category_style(r["category"]) disp_cat = html.escape(pretty(r["category"])) good = html.escape(r.get("good_for", "")) prov = html.escape(r.get("provenance", "")) style = f' style="--c:{hue}"' if lead else "" lead_attr = ' data-lead="1"' if lead else "" gf = _good_for_label(r.get("good_for", "")) gf_html = f'{html.escape(gf)}' if gf else "" return ( f'' ) def _invent_card(disp_cat: str, glyph: str) -> str: """A dashed 'invent on the fly, in this category's spirit' card appended to each section.""" return ( f'' ) def html_doc(rows: list[dict]) -> str: """Render the self-contained 'browse all techniques' selection page from the catalog. Deterministic ordering so the shipped asset can be snapshot-tested against the CSV: a cross-cutting "Proven & Professional" lead group (every `classic`-tagged row), then the categories in fixed super-group order, then any unlisted/custom categories under "More" alphabetically. Techniques render in file order within a category. A `classic` row appears both in the lead group and its home category; the page de-dupes on select. """ groups: dict[str, list[dict]] = {} for r in rows: groups.setdefault(r["category"], []).append(r) body: list[str] = [] chips: list[str] = [] def add_section(cat: str) -> None: hue, glyph = category_style(cat) disp = html.escape(pretty(cat)) cards = [_card(r) for r in groups[cat]] cards.append(_invent_card(disp, glyph)) chips.append(f'') body.append( f'

{disp}{len(groups[cat])}

' f'
{"".join(cards)}
' ) # 1) lead group — every classic-tagged technique, cross-category (no invent card here) classics = [r for r in rows if r.get("provenance", "").lower() == "classic"] if classics: disp = html.escape(CLASSIC_GROUP) lead_cards = "".join(_card(r, lead=True) for r in classics) chips.append(f'') body.append( f'

{disp}{len(classics)}

' f'
{lead_cards}
' ) # 2) shipped categories, in super-group order placed = set() for group_title, cats in CATEGORY_GROUPS: present = [c for c in cats if c in groups] if not present: continue hue, _ = category_style(present[0]) body.append(f'

{html.escape(group_title)}

') for c in present: add_section(c) placed.add(c) # 3) leftover (custom / --extra) categories, alphabetically leftover = sorted(c for c in groups if c not in placed) if leftover: body.append('

More

') for c in leftover: add_section(c) # goal-affinity filter bar — only if the catalog actually carries good_for tags present_goals: set[str] = set() for r in rows: for g in (r.get("good_for", "") or "").split("|"): if g: present_goals.add(g) goalbar = "" if present_goals: ordered = [g for g in GOAL_LABELS if g in present_goals] + sorted(present_goals - set(GOAL_LABELS)) gchips = "".join( f'' for g in ordered ) goalbar = f'
Great for
{gchips}
' total = html.escape(f"{len(rows)} techniques across {len(groups)} categories.") return ( SELECTOR_TEMPLATE.replace("{{BODY}}", "\n".join(body)) .replace("{{CHIPS}}", "".join(chips)) .replace("{{GOALBAR}}", goalbar) .replace("{{TOTAL}}", total) ) def main(argv: list[str] | None = None) -> int: p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) p.add_argument("--file", type=Path, default=DEFAULT_FILE, help="technique CSV (default: sibling assets/brain-methods.csv)") p.add_argument("--extra", type=Path, help="JSON overlay of additional techniques (customize.toml additional_techniques), merged into every command") p.add_argument("--json", action="store_true", help="emit structured JSON instead of lean text") sub = p.add_subparsers(dest="cmd", required=True) sub.add_parser("categories", help="list category names + counts") pl = sub.add_parser("list", help="the index: category/name/gist (needs --category or --all)") pl.add_argument("--category", action="append", help="filter to a category (repeatable)") pl.add_argument("--all", action="store_true", help="dump the entire catalog (deliberate; large)") ps = sub.add_parser("show", help="full gist + detail file for named techniques") ps.add_argument("names", nargs="+") pr = sub.add_parser("random", help="pick techniques at random") pr.add_argument("--category", action="append", help="restrict to a category (repeatable)") pr.add_argument("-n", type=int, default=1, help="how many (default 1)") ph = sub.add_parser("html", help="write the offline 'browse all' selection page") ph.add_argument("--out", help="file to write the page to (required; never prints the catalog)") args = p.parse_args(argv) if not args.file.is_file(): print(f"error: technique file not found: {args.file}", file=sys.stderr) return 2 rows = load(args.file) if args.extra: if not args.extra.is_file(): print(f"error: --extra file not found: {args.extra}", file=sys.stderr) return 2 rows += load_extra(args.extra) csv_dir = args.file.resolve().parent if args.cmd == "categories": print(fmt_categories(categories(rows), args.json)) elif args.cmd == "list": if not args.category and not args.all: print( "error: `list` needs --category (one or more) — or --all to dump the whole " "catalog on purpose. Use `categories` for the cheap map, or `random` to draw blind.", file=sys.stderr, ) return 2 print(fmt_list(filter_cats(rows, args.category), args.json)) elif args.cmd == "show": found, missing = find(rows, args.names) for m in missing: print(f"# not found: {m}", file=sys.stderr) if not found: return 1 print(fmt_show(found, csv_dir, args.json)) elif args.cmd == "random": pool = filter_cats(rows, args.category) if not pool: print("# no techniques match", file=sys.stderr) return 1 n = max(0, min(args.n, len(pool))) # clamp: never crash on a negative or oversized -n print(fmt_list(random.sample(pool, n), args.json)) elif args.cmd == "html": if not args.out: print( "error: `html` needs --out PATH — it writes the selection page to a file and " "never prints the catalog to stdout (which would defeat the point).", file=sys.stderr, ) return 2 out = Path(args.out) out.parent.mkdir(parents=True, exist_ok=True) out.write_text(html_doc(rows), encoding="utf-8") print(f"wrote {out} ({len(rows)} techniques, {len(categories(rows))} categories)") return 0 if __name__ == "__main__": sys.exit(main())