Compare commits
7 Commits
ab9bb5b080
...
810a8756a1
| Author | SHA1 | Date |
|---|---|---|
|
|
810a8756a1 | |
|
|
560a2e3a6f | |
|
|
fbb48ed711 | |
|
|
b9431d6d99 | |
|
|
9799d10123 | |
|
|
579c78d2aa | |
|
|
bec2c04a6d |
|
|
@ -43,15 +43,27 @@ Inside the spec folder:
|
||||||
|
|
||||||
```
|
```
|
||||||
<spec-folder>/
|
<spec-folder>/
|
||||||
SPEC.md ← uppercase, the kernel
|
SPEC.md ← uppercase, the kernel — DERIVED from .memlog.md, never hand-edited
|
||||||
<companion-1>.md ← optional, content-typed (e.g. glossary.md)
|
<companion-1>.md ← optional, content-typed (e.g. glossary.md); spec-authored ones are derived too
|
||||||
<companion-2>.md
|
<companion-2>.md
|
||||||
.decision-log.md ← canonical memory for this spec
|
.memlog.md ← canonical, append-only memory; what SPEC.md is distilled from
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Memory and derivation
|
||||||
|
|
||||||
|
`.memlog.md` is canonical — an append-only, chronological record of every decision, constraint, capability (with its stable `CAP-N`), assumption, open question, and bit of user direction, one line each in the order it happened, never edited or reordered. `SPEC.md` and every spec-authored companion are **derived on each run** from the memlog (the decision-of-record) plus the sources it cites for raw content — never hand-patched.
|
||||||
|
|
||||||
|
Deriving the contract from a living log instead of editing the contract in place is what lets the steps around the spec (PRD, UX, architecture, epics) run in any order and feed the same spec without merge drift: the log only accumulates, the artifact is re-rendered. So the spec is updated *only* by re-deriving it here — bmad-spec is its single writer; a hand-edit to `SPEC.md` from outside is unsupported and is overwritten on the next derive.
|
||||||
|
|
||||||
|
Writes go through the shared script — `{project-root}/_bmad/scripts/memlog.py`, the same location as `resolve_customization.py` (atomic; never read it back except to resume):
|
||||||
|
|
||||||
|
- `python3 {project-root}/_bmad/scripts/memlog.py init --workspace {spec-folder} --field topic="<what is being specced>"` — once, at create.
|
||||||
|
- `python3 {project-root}/_bmad/scripts/memlog.py append --workspace {spec-folder} --type <decision|constraint|capability|assumption|question|direction|note|event> --text "<one-line gist, reason included>"` — as each lands.
|
||||||
|
- Terminal moments (a validation verdict, "spec finalized") are `--type event` entries; the memlog carries no status field.
|
||||||
|
|
||||||
## The Operation
|
## The Operation
|
||||||
|
|
||||||
Read the input and its ancillary linked materials. If there is no input, follow the no-input branch in **Workspace** (ask or block). If a prior `SPEC.md` exists at the target folder, read it too — the operation becomes an update. Preserve capability IDs; new capabilities get the next unused `CAP-N`; never reuse retired IDs. Otherwise this is a create.
|
Read the input and its ancillary linked materials. If there is no input, follow the no-input branch in **Workspace** (ask or block). If a prior `.memlog.md` exists at the target folder, read it — the operation becomes an update, and the memlog (not the rendered `SPEC.md`) is the authority on what was decided and on capability IDs. Preserve those IDs; new capabilities get the next unused `CAP-N`; never reuse retired IDs. Otherwise this is a create, and the first move is `memlog.py init`.
|
||||||
|
|
||||||
When the input is structured and pre-sorted (a PRD with an addendum, a GDD, a brief produced by an upstream BMad skill), trust the authored separation: lift kernel-fitting content into SPEC.md, lift overflow into appropriately-named companions. When the input is mixed (a brain dump, a transcript, an RFC, a customer email), do the sorting yourself: walk each claim, apply the three-lens load-bearing test (Spec Law rule 7), and route to the kernel field or a companion.
|
When the input is structured and pre-sorted (a PRD with an addendum, a GDD, a brief produced by an upstream BMad skill), trust the authored separation: lift kernel-fitting content into SPEC.md, lift overflow into appropriately-named companions. When the input is mixed (a brain dump, a transcript, an RFC, a customer email), do the sorting yourself: walk each claim, apply the three-lens load-bearing test (Spec Law rule 7), and route to the kernel field or a companion.
|
||||||
|
|
||||||
|
|
@ -59,6 +71,8 @@ Distill the input into the five-field kernel using `{workflow.spec_template}` as
|
||||||
|
|
||||||
Write lean from the first pass: every sentence must earn its place. Decoration costs tokens and dilutes downstream readers.
|
Write lean from the first pass: every sentence must earn its place. Decoration costs tokens and dilutes downstream readers.
|
||||||
|
|
||||||
|
Log each decision, capability, constraint, and accepted change to `.memlog.md` as it is made — that running record is what the render reads. Because the log is append-only, a later entry supersedes an earlier one on the same point while the history stays intact. When two currently-live sources or companions disagree on the same field, or an either/or never got resolved, surface it to the user rather than silently choosing — the resolution is itself a new memlog entry.
|
||||||
|
|
||||||
If the input is genuinely too thin to distill (e.g. "an app for hikers" with no surrounding context), stop and suggest `bmad-prd` (or sibling ceremony skill). This skill distills; it does not coach.
|
If the input is genuinely too thin to distill (e.g. "an app for hikers" with no surrounding context), stop and suggest `bmad-prd` (or sibling ceremony skill). This skill distills; it does not coach.
|
||||||
|
|
||||||
## Load-bearing
|
## Load-bearing
|
||||||
|
|
@ -94,7 +108,7 @@ Every spec must satisfy these eight rules. The operation aims for them; the self
|
||||||
5. **Success signal is concrete enough to test or demonstrate against.** "Users love it" doesn't qualify.
|
5. **Success signal is concrete enough to test or demonstrate against.** "Users love it" doesn't qualify.
|
||||||
6. **Capability IDs are stable and unique.** Never reused, never renumbered.
|
6. **Capability IDs are stable and unique.** Never reused, never renumbered.
|
||||||
7. **Preservation.** Every load-bearing source claim lands in SPEC.md or a companion. Wrapper ceremony does not.
|
7. **Preservation.** Every load-bearing source claim lands in SPEC.md or a companion. Wrapper ceremony does not.
|
||||||
8. **Lean prose.** Every sentence carries load-bearing content. Cut decoration, hedges, backstory, throat-clearing. Applies to SPEC.md, companions, and `.decision-log.md`.
|
8. **Lean prose.** Every sentence carries load-bearing content. Cut decoration, hedges, backstory, throat-clearing. Applies to SPEC.md, companions, and `.memlog.md`.
|
||||||
|
|
||||||
## Self-Validate
|
## Self-Validate
|
||||||
|
|
||||||
|
|
@ -104,7 +118,7 @@ After every create or update, sweep the resulting artifact in **two passes** bef
|
||||||
|
|
||||||
**Pass 2 — Preservation.** Walk the source claim by claim. Confirm each load-bearing claim landed in SPEC.md or a companion. Wrapper-ceremony drops are logged under "Wrapper-only content" so the drop is on the record, not silent.
|
**Pass 2 — Preservation.** Walk the source claim by claim. Confirm each load-bearing claim landed in SPEC.md or a companion. Wrapper-ceremony drops are logged under "Wrapper-only content" so the drop is on the record, not silent.
|
||||||
|
|
||||||
Append a one-paragraph verdict to `.decision-log.md` covering both passes. In interactive mode, review the verdict with the user. In headless mode, `.decision-log.md` is one of the files returned, so the caller (or its downstream LLM) reads the verdict there.
|
Record the verdict for each pass to `.memlog.md` (`append --type event`). In interactive mode, review it with the user. In headless mode, `.memlog.md` is one of the files returned, so the caller (or its downstream LLM) reads the verdict there.
|
||||||
|
|
||||||
## Spec with no change signal
|
## Spec with no change signal
|
||||||
|
|
||||||
|
|
@ -120,10 +134,10 @@ Run `{workflow.on_complete}` if set.
|
||||||
|
|
||||||
## After Spec is Output
|
## After Spec is Output
|
||||||
|
|
||||||
Any update to spec regarding assumptions, open questions, or other changes should be appended to that source's decision log also and offer to update the source.
|
Any update to the spec — resolved assumptions, answered open questions, other changes — is appended to `.memlog.md` as it happens. When a change overrides something that came from a source input, offer to update that source too, so upstream and the spec don't silently diverge.
|
||||||
|
|
||||||
## Frontmatter conventions
|
## Frontmatter conventions
|
||||||
|
|
||||||
- `companions:` array of `.md` files downstream MUST read alongside SPEC.md to have the full contract. Paths may point inside the spec folder (spec-authored companions like `glossary.md`) or outside it (adopted companions like `../planning-artifacts/ux-designs/ux-foo-bar-2026-05-23/DESIGN.md`). The split between spec-authored and adopted is implicit by path; downstream treats both the same.
|
- `companions:` array of `.md` files downstream MUST read alongside SPEC.md to have the full contract. Paths may point inside the spec folder (spec-authored companions like `glossary.md`) or outside it (adopted companions like `../planning-artifacts/ux-designs/ux-foo-bar-2026-05-23/DESIGN.md`). The split between spec-authored and adopted is implicit by path; downstream treats both the same.
|
||||||
- `sources:` array of paths to files that were **fully absorbed** into the SPEC, with no remaining downstream value (e.g., a PRD whose every load-bearing claim is now in the kernel). Listed for audit and for bmad-spec to re-read on update. Downstream does NOT read these. Files that downstream still needs to read belong in `companions:`, not here.
|
- `sources:` array of paths to files that were **fully absorbed** into the SPEC, with no remaining downstream value (e.g., a PRD whose every load-bearing claim is now in the kernel). Listed for audit and for bmad-spec to re-read on update. Downstream does NOT read these. Files that downstream still needs to read belong in `companions:`, not here.
|
||||||
- **Do not list** decision logs, README files, organizational artifacts, or any operational record of how upstream skills produced their artifacts. Those are not source content; they are process metadata that downstream consumers don't need.
|
- **Do not list** the memlog, README files, organizational artifacts, or any operational record of how upstream skills produced their artifacts. Those are not source content; they are process metadata that downstream consumers don't need.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Headless JSON Response
|
# Headless JSON Response
|
||||||
|
|
||||||
The default invocation is headless: input goes in, JSON comes out. The contract is intentionally tiny — return the outcome and the files touched. Anything else a caller needs is inside those files (SPEC.md, companions, `.decision-log.md`).
|
The default invocation is headless: input goes in, JSON comes out. The contract is intentionally tiny — return the outcome and the files touched. Anything else a caller needs is inside those files (SPEC.md, companions, `.memlog.md`).
|
||||||
|
|
||||||
## Success
|
## Success
|
||||||
|
|
||||||
|
|
@ -10,12 +10,12 @@ The default invocation is headless: input goes in, JSON comes out. The contract
|
||||||
"files": [
|
"files": [
|
||||||
"_bmad-output/specs/spec-quarter-drop/SPEC.md",
|
"_bmad-output/specs/spec-quarter-drop/SPEC.md",
|
||||||
"_bmad-output/specs/spec-quarter-drop/glossary.md",
|
"_bmad-output/specs/spec-quarter-drop/glossary.md",
|
||||||
"_bmad-output/specs/spec-quarter-drop/.decision-log.md"
|
"_bmad-output/specs/spec-quarter-drop/.memlog.md"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`files` lists every file written or modified in this run, in any order. The spec folder, kernel filename, decision log location, capabilities, companions, and verdict are all readable from those files; no need to re-encode them in the response.
|
`files` lists every file written or modified in this run, in any order. The spec folder, kernel filename, memlog location, capabilities, companions, and verdict are all readable from those files; no need to re-encode them in the response.
|
||||||
|
|
||||||
## Blocked
|
## Blocked
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
---
|
---
|
||||||
id: SPEC-{slug}
|
id: SPEC-{slug}
|
||||||
companions: [] # files downstream MUST read alongside SPEC.md. Paths may point inside the spec folder (spec-authored) or outside it (adopted from an upstream skill).
|
companions: [] # files downstream MUST read alongside SPEC.md. Paths may point inside the spec folder (spec-authored) or outside it (adopted from an upstream skill).
|
||||||
sources: [] # files fully absorbed into the SPEC (audit only; downstream does NOT read these). Never decision logs.
|
sources: [] # files fully absorbed into the SPEC (audit only; downstream does NOT read these). Never the memlog.
|
||||||
---
|
---
|
||||||
|
|
||||||
> **Canonical contract.** This SPEC and the files in `companions:` are the complete, preservation-validated contract for what to build, test, and validate. Source documents listed in frontmatter are for traceability only — consult them only if you need narrative rationale or prose color this contract intentionally omits.
|
> **Canonical contract.** This SPEC and the files in `companions:` are the complete, preservation-validated contract for what to build, test, and validate. Source documents listed in frontmatter are for traceability only — consult them only if you need narrative rationale or prose color this contract intentionally omits.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,224 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# /// script
|
||||||
|
# requires-python = ">=3.8"
|
||||||
|
# ///
|
||||||
|
"""memlog — an append-only memory log: LLM-optimal working memory for a skill.
|
||||||
|
|
||||||
|
A memlog is the dense, chronological record of everything that mattered in a piece of
|
||||||
|
work — every item the user generated or accepted — kept minimal like human memory: only
|
||||||
|
what's important, never bloated. It persists ACROSS sessions, so a fresh session can
|
||||||
|
load it and continue. It is NOT a deliverable; downstream artifacts (a brief, a PRD, a
|
||||||
|
deck, a report) are *derived* from it on demand. The host skill supplies the vocabulary
|
||||||
|
by how it calls `append` — the tool stays neutral.
|
||||||
|
|
||||||
|
It is a FLAT log: there are no sections or grouping. Every entry is one line, recorded
|
||||||
|
at the END in the order it happened. The chronology itself is the structure — an event
|
||||||
|
like "started technique X" is just another entry, same as an idea or an insight.
|
||||||
|
|
||||||
|
Three invariants make it trustworthy:
|
||||||
|
|
||||||
|
1. Append-only, chronological. Entries land at the end, in the order they happen.
|
||||||
|
Nothing is ever inserted backward, reordered, edited, or removed. There is no
|
||||||
|
edit or delete subcommand by design; history is never rewritten.
|
||||||
|
2. Write-only / blind. Every command is an atomic, context-free write and echoes the
|
||||||
|
new state as one line of JSON, so the caller never re-reads the file mid-session.
|
||||||
|
The one time the file is read is on resume — and the caller reads it itself, not
|
||||||
|
via this script.
|
||||||
|
3. No lifecycle status. A memory log has no "complete" flag. Whether the work is done,
|
||||||
|
blocked, or paused is itself a fact that happened, so it is recorded as an entry
|
||||||
|
(e.g. `append --type event --text "session complete"`), never as frontmatter the
|
||||||
|
log would have to mutate. The chronology stays the single source of truth, and a
|
||||||
|
resume learns the state by reading the last entries — the same way it learns
|
||||||
|
everything else.
|
||||||
|
|
||||||
|
Atomicity: every write goes to a temp file, is flushed and fsync'd, then atomically
|
||||||
|
renamed over the target, so a crash never leaves a half-written entry.
|
||||||
|
|
||||||
|
The file shape (.memlog.md):
|
||||||
|
|
||||||
|
---
|
||||||
|
topic: Onboarding flow for a budgeting app
|
||||||
|
goal: lift week-1 retention
|
||||||
|
updated: 2026-06-07T14:22
|
||||||
|
---
|
||||||
|
|
||||||
|
- (note) user picked techniques: SCAMPER, then Six Thinking Hats
|
||||||
|
- (technique) started SCAMPER
|
||||||
|
- (idea) skip the signup wall: let people try with sample data first
|
||||||
|
- (idea) auto-import one bank account so the first screen shows real numbers
|
||||||
|
- (question) is open-banking consent too heavy for step one?
|
||||||
|
- (insight) the "scary numbers" risk and the "real numbers" idea are one lever: show real data, pre-categorized
|
||||||
|
- (direction) optimize for the anxious first-timer, not the power user
|
||||||
|
- (decision) lead with one pre-categorized account; defer multi-account import
|
||||||
|
- (event) session complete
|
||||||
|
|
||||||
|
Each entry may carry an optional `--type` — what KIND it is (idea, insight, question,
|
||||||
|
decision, direction, assumption, gap, note, event, …) — and an optional `--by` naming
|
||||||
|
who it came from (e.g. `user`, `coach`), for sessions where authorship matters. Both
|
||||||
|
render into one short inline tag: `(idea)`, `(idea by user)`, `(by coach)`. Omit them
|
||||||
|
for a plain note. The host skill names the vocabulary; the script does not enforce one.
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
init (--workspace DIR | --path FILE) [--field k=v ...] create the memlog (errors if it exists)
|
||||||
|
append (--workspace DIR | --path FILE) --text STR [--type T] [--by W] append one entry at the end
|
||||||
|
set (--workspace DIR | --path FILE) --key K --value V set/replace a descriptive frontmatter field
|
||||||
|
|
||||||
|
Addressing: `--workspace` is the run folder, and the memlog is always {workspace}/.memlog.md.
|
||||||
|
`--path` points straight at the memlog file instead, for callers that already hold the path.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations # keep type-hint syntax lazy so the script runs on 3.8+
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
MEMLOG = ".memlog.md"
|
||||||
|
|
||||||
|
|
||||||
|
def now() -> str:
|
||||||
|
return datetime.now().strftime("%Y-%m-%dT%H:%M")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve(args) -> Path:
|
||||||
|
"""The memlog file, from either addressing mode: {workspace}/.memlog.md or an explicit --path."""
|
||||||
|
return Path(args.path) if args.path else Path(args.workspace) / MEMLOG
|
||||||
|
|
||||||
|
|
||||||
|
def split(text: str) -> tuple[dict, str]:
|
||||||
|
"""Return (frontmatter dict in source order, body str). Frontmatter is plain key: value.
|
||||||
|
|
||||||
|
The closing fence is the first line that is *exactly* `---`, so a `---` inside a
|
||||||
|
field value (topic/goal are free user text) never truncates the frontmatter.
|
||||||
|
"""
|
||||||
|
lines = text.splitlines()
|
||||||
|
if not lines or lines[0] != "---":
|
||||||
|
raise ValueError(".memlog.md has no frontmatter")
|
||||||
|
end = next((i for i in range(1, len(lines)) if lines[i] == "---"), None)
|
||||||
|
if end is None:
|
||||||
|
raise ValueError(".memlog.md frontmatter is not terminated")
|
||||||
|
meta: dict[str, str] = {}
|
||||||
|
for line in lines[1:end]:
|
||||||
|
if ":" in line:
|
||||||
|
k, v = line.split(":", 1)
|
||||||
|
meta[k.strip()] = v.strip()
|
||||||
|
return meta, "\n".join(lines[end + 1:]).lstrip("\n")
|
||||||
|
|
||||||
|
|
||||||
|
def render(meta: dict, body: str) -> str:
|
||||||
|
# Neutralize newlines in values so a multi-line field can't break the fence on re-read.
|
||||||
|
fm = "\n".join(f"{k}: {' '.join(str(v).splitlines())}" for k, v in meta.items())
|
||||||
|
return "---\n" + fm + "\n---\n\n" + body.rstrip("\n") + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def touch(meta: dict) -> None:
|
||||||
|
"""Stamp `updated` and keep it last so the field order stays predictable."""
|
||||||
|
meta.pop("updated", None)
|
||||||
|
meta["updated"] = now()
|
||||||
|
|
||||||
|
|
||||||
|
def write_atomic(path: Path, text: str) -> None:
|
||||||
|
"""Temp + flush + fsync + atomic rename, so a crash never half-writes an entry."""
|
||||||
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
|
f.write(text)
|
||||||
|
f.flush()
|
||||||
|
os.fsync(f.fileno())
|
||||||
|
os.replace(tmp, path)
|
||||||
|
|
||||||
|
|
||||||
|
def entry_count(body: str) -> int:
|
||||||
|
return sum(1 for ln in body.splitlines() if ln.startswith("- "))
|
||||||
|
|
||||||
|
|
||||||
|
def ack(path: Path, body: str) -> None:
|
||||||
|
"""Echo new state so the caller never re-reads the file to know where it stands."""
|
||||||
|
print(json.dumps({
|
||||||
|
"ok": True,
|
||||||
|
"memlog": str(path),
|
||||||
|
"entries": entry_count(body),
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_init(args) -> int:
|
||||||
|
path = resolve(args)
|
||||||
|
if path.exists():
|
||||||
|
print(f"error: {path} already exists; use append/set to update it", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
meta: dict[str, str] = {}
|
||||||
|
for pair in args.field or []:
|
||||||
|
if "=" not in pair:
|
||||||
|
print(f"error: --field expects key=value, got {pair!r}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
k, v = pair.split("=", 1)
|
||||||
|
meta[k.strip()] = v.strip()
|
||||||
|
touch(meta)
|
||||||
|
write_atomic(path, render(meta, ""))
|
||||||
|
ack(path, "")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_append(args) -> int:
|
||||||
|
path = resolve(args)
|
||||||
|
meta, body = split(path.read_text(encoding="utf-8"))
|
||||||
|
text = " ".join(args.text.split()) # collapse newlines/runs → one-line entry, no prose bloat
|
||||||
|
label = args.type or ""
|
||||||
|
if args.by:
|
||||||
|
label = f"{label} by {args.by}".strip() # attribution: "(idea by user)" / "(by coach)"
|
||||||
|
tag = f"({label}) " if label else ""
|
||||||
|
entry = f"- {tag}{text}"
|
||||||
|
body = (body.rstrip("\n") + "\n" + entry) if body.strip() else entry # always at the end
|
||||||
|
touch(meta)
|
||||||
|
write_atomic(path, render(meta, body))
|
||||||
|
ack(path, body)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_set(args) -> int:
|
||||||
|
path = resolve(args)
|
||||||
|
meta, body = split(path.read_text(encoding="utf-8"))
|
||||||
|
meta[args.key] = args.value
|
||||||
|
touch(meta)
|
||||||
|
write_atomic(path, render(meta, body))
|
||||||
|
ack(path, body)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def add_target(sp) -> None:
|
||||||
|
"""Every command addresses the memlog the same way: a run folder or an explicit path."""
|
||||||
|
g = sp.add_mutually_exclusive_group(required=True)
|
||||||
|
g.add_argument("--workspace", help="run folder; the memlog is {workspace}/.memlog.md")
|
||||||
|
g.add_argument("--path", help="explicit memlog file path (alternative to --workspace)")
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
sub = p.add_subparsers(dest="cmd", required=True)
|
||||||
|
|
||||||
|
pi = sub.add_parser("init", help="create the memlog")
|
||||||
|
add_target(pi)
|
||||||
|
pi.add_argument("--field", action="append", metavar="KEY=VALUE", help="frontmatter field (repeatable)")
|
||||||
|
pi.set_defaults(func=cmd_init)
|
||||||
|
|
||||||
|
pa = sub.add_parser("append", help="append one entry at the end")
|
||||||
|
add_target(pa)
|
||||||
|
pa.add_argument("--text", required=True)
|
||||||
|
pa.add_argument("--type", help="entry kind, rendered as an inline tag")
|
||||||
|
pa.add_argument("--by", help="who the entry came from (e.g. user, coach); rendered into the tag")
|
||||||
|
pa.set_defaults(func=cmd_append)
|
||||||
|
|
||||||
|
pset = sub.add_parser("set", help="set a descriptive frontmatter field")
|
||||||
|
add_target(pset)
|
||||||
|
pset.add_argument("--key", required=True)
|
||||||
|
pset.add_argument("--value", required=True)
|
||||||
|
pset.set_defaults(func=cmd_set)
|
||||||
|
|
||||||
|
args = p.parse_args(argv)
|
||||||
|
return args.func(args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
|
|
@ -1,12 +1,30 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Resolve BMad's central config using four-layer TOML merge.
|
Resolve BMad's central config using a layered TOML merge.
|
||||||
|
|
||||||
Reads from four layers (highest priority last):
|
Reads from up to seven tiers (highest priority last):
|
||||||
1. {project-root}/_bmad/config.toml (installer-owned team)
|
0. {project-root}/_bmad/{module}/module.toml (shipped module defaults — floor)
|
||||||
2. {project-root}/_bmad/config.user.toml (installer-owned user)
|
1. {global-dir}/config.toml (global team / machine defaults)
|
||||||
3. {project-root}/_bmad/custom/config.toml (human-authored team, committed)
|
2. {global-dir}/config.user.toml (global personal defaults)
|
||||||
4. {project-root}/_bmad/custom/config.user.toml (human-authored user, gitignored)
|
3. {project-root}/_bmad/config.toml (installer-owned team)
|
||||||
|
4. {project-root}/_bmad/config.user.toml (installer-owned user)
|
||||||
|
5. {project-root}/_bmad/custom/config.toml (human-authored team, committed)
|
||||||
|
6. {project-root}/_bmad/custom/config.user.toml (human-authored user, gitignored)
|
||||||
|
|
||||||
|
Tier 0 ("module floor") carries each installed module's shipped defaults:
|
||||||
|
its [modules.X] paths and [agents.X] roster. Authors write module.yaml at
|
||||||
|
source; the installer converts to module.toml at install-time, giving the
|
||||||
|
resolver a TOML-only read path (no PyYAML dependency). Discovery is by
|
||||||
|
glob: any subdirectory of _bmad/ containing a module.toml counts.
|
||||||
|
|
||||||
|
All layers are optional. If a file is missing it is silently skipped.
|
||||||
|
If no file is found anywhere, an empty object is emitted.
|
||||||
|
|
||||||
|
{global-dir} resolves to $BMAD_HOME if set, otherwise ~/.bmad. Path.home()
|
||||||
|
gives the right answer on macOS, Linux, WSL, and Windows.
|
||||||
|
|
||||||
|
--project-root is optional. With no project root, only the global layers
|
||||||
|
are consulted (useful for standalone skill invocations).
|
||||||
|
|
||||||
Outputs merged JSON to stdout. Errors go to stderr.
|
Outputs merged JSON to stdout. Errors go to stderr.
|
||||||
|
|
||||||
|
|
@ -16,6 +34,7 @@ no virtualenv — plain `python3` is sufficient.
|
||||||
python3 resolve_config.py --project-root /abs/path/to/project
|
python3 resolve_config.py --project-root /abs/path/to/project
|
||||||
python3 resolve_config.py --project-root ... --key core
|
python3 resolve_config.py --project-root ... --key core
|
||||||
python3 resolve_config.py --project-root ... --key agents
|
python3 resolve_config.py --project-root ... --key agents
|
||||||
|
python3 resolve_config.py # global only
|
||||||
|
|
||||||
Merge rules (same as resolve_customization.py):
|
Merge rules (same as resolve_customization.py):
|
||||||
- Scalars: override wins
|
- Scalars: override wins
|
||||||
|
|
@ -26,6 +45,7 @@ Merge rules (same as resolve_customization.py):
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
@ -123,6 +143,61 @@ def deep_merge(base, override):
|
||||||
return override
|
return override
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_global_dir() -> Path:
|
||||||
|
"""Locate the cross-platform global BMad config directory.
|
||||||
|
|
||||||
|
Honors $BMAD_HOME (useful for CI, multi-account setups, or relocating
|
||||||
|
config off a slow network home). Otherwise ~/.bmad — Path.home() does
|
||||||
|
the right thing on macOS, Linux, WSL, and Windows.
|
||||||
|
"""
|
||||||
|
override = os.environ.get("BMAD_HOME")
|
||||||
|
if override:
|
||||||
|
return Path(override).expanduser().resolve()
|
||||||
|
return Path.home() / ".bmad"
|
||||||
|
|
||||||
|
|
||||||
|
def collect_module_layers(project_root: Path | None) -> list[Path]:
|
||||||
|
"""Return per-module module.toml paths discovered in this project.
|
||||||
|
|
||||||
|
Floor of the resolver chain — these are the shipped module defaults
|
||||||
|
that the installer realizes from source module.yaml on install. Authors
|
||||||
|
don't edit module.toml directly; it's a build artifact.
|
||||||
|
|
||||||
|
Discovery is purely file-system based: any direct subdirectory of
|
||||||
|
_bmad/ that contains a module.toml is treated as an installed module.
|
||||||
|
Returned in sorted order for deterministic merge order (irrelevant
|
||||||
|
in practice because modules don't share keys — each writes its own
|
||||||
|
[modules.{code}] and own [agents.{agent-code}] entries — but
|
||||||
|
determinism is cheap).
|
||||||
|
"""
|
||||||
|
if project_root is None:
|
||||||
|
return []
|
||||||
|
bmad_dir = project_root / "_bmad"
|
||||||
|
if not bmad_dir.is_dir():
|
||||||
|
return []
|
||||||
|
return sorted(bmad_dir.glob("*/module.toml"))
|
||||||
|
|
||||||
|
|
||||||
|
def collect_config_layers(project_root: Path | None, global_dir: Path) -> list[tuple[str, Path]]:
|
||||||
|
"""Return (label, path) pairs in lowest→highest priority order.
|
||||||
|
|
||||||
|
All layers are optional; load_toml returns {} for any missing file.
|
||||||
|
"""
|
||||||
|
layers: list[tuple[str, Path]] = [
|
||||||
|
("global team", global_dir / "config.toml"),
|
||||||
|
("global user", global_dir / "config.user.toml"),
|
||||||
|
]
|
||||||
|
if project_root is not None:
|
||||||
|
bmad_dir = project_root / "_bmad"
|
||||||
|
layers.extend([
|
||||||
|
("project team", bmad_dir / "config.toml"),
|
||||||
|
("project user", bmad_dir / "config.user.toml"),
|
||||||
|
("project custom team", bmad_dir / "custom" / "config.toml"),
|
||||||
|
("project custom user", bmad_dir / "custom" / "config.user.toml"),
|
||||||
|
])
|
||||||
|
return layers
|
||||||
|
|
||||||
|
|
||||||
def extract_key(data, dotted_key: str):
|
def extract_key(data, dotted_key: str):
|
||||||
parts = dotted_key.split(".")
|
parts = dotted_key.split(".")
|
||||||
current = data
|
current = data
|
||||||
|
|
@ -136,11 +211,12 @@ def extract_key(data, dotted_key: str):
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Resolve BMad central config using four-layer TOML merge.",
|
description="Resolve BMad central config using a layered TOML merge.",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--project-root", "-p", required=True,
|
"--project-root", "-p", required=False, default=None,
|
||||||
help="Absolute path to the project root (contains _bmad/)",
|
help="Absolute path to the project root (contains _bmad/). Optional — "
|
||||||
|
"if omitted, only global layers ($BMAD_HOME or ~/.bmad) are read.",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--key", "-k", action="append", default=[],
|
"--key", "-k", action="append", default=[],
|
||||||
|
|
@ -148,17 +224,27 @@ def main():
|
||||||
)
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
project_root = Path(args.project_root).resolve()
|
project_root = Path(args.project_root).resolve() if args.project_root else None
|
||||||
bmad_dir = project_root / "_bmad"
|
global_dir = resolve_global_dir()
|
||||||
|
|
||||||
base_team = load_toml(bmad_dir / "config.toml", required=True)
|
# If the caller explicitly named a project root, that's a promise it exists
|
||||||
base_user = load_toml(bmad_dir / "config.user.toml")
|
# and has been installed. Fail loudly on a missing _bmad/ rather than
|
||||||
custom_team = load_toml(bmad_dir / "custom" / "config.toml")
|
# silently returning {} — that masked broken installs in the old required=
|
||||||
custom_user = load_toml(bmad_dir / "custom" / "config.user.toml")
|
# True behavior. Global-only mode (no --project-root) stays permissive.
|
||||||
|
if project_root is not None and not (project_root / "_bmad").is_dir():
|
||||||
|
sys.stderr.write(
|
||||||
|
f"error: --project-root {project_root} has no _bmad/ directory "
|
||||||
|
f"(install not present, or wrong path)\n"
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
merged = deep_merge(base_team, base_user)
|
merged: dict = {}
|
||||||
merged = deep_merge(merged, custom_team)
|
# Floor: per-module shipped defaults (lowest priority).
|
||||||
merged = deep_merge(merged, custom_user)
|
for module_toml in collect_module_layers(project_root):
|
||||||
|
merged = deep_merge(merged, load_toml(module_toml))
|
||||||
|
# Then global → project → custom config layers on top.
|
||||||
|
for _label, path in collect_config_layers(project_root, global_dir):
|
||||||
|
merged = deep_merge(merged, load_toml(path))
|
||||||
|
|
||||||
if args.key:
|
if args.key:
|
||||||
output = {}
|
output = {}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,41 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Resolve customization for a BMad skill using three-layer TOML merge.
|
Resolve customization for a BMad skill using a layered TOML merge.
|
||||||
|
|
||||||
Reads customization from three layers (highest priority first):
|
Reads from (lowest → highest priority):
|
||||||
1. {project-root}/_bmad/custom/{name}.user.toml (personal, gitignored)
|
1. {skill-root}/customize.toml (skill author defaults)
|
||||||
2. {project-root}/_bmad/custom/{name}.toml (team/org, committed)
|
2. [skills.X] sections inside four customize layers (lowest→highest):
|
||||||
3. {skill-root}/customize.toml (skill defaults)
|
a. {global-dir}/customize.toml (global team / machine)
|
||||||
|
b. {global-dir}/customize.user.toml (global personal)
|
||||||
|
c. {project-root}/_bmad/custom/customize.toml (project team, committed)
|
||||||
|
d. {project-root}/_bmad/custom/customize.user.toml (project personal, gitignored)
|
||||||
|
3. {project-root}/_bmad/custom/{name}.toml (per-skill team override)
|
||||||
|
4. {project-root}/_bmad/custom/{name}.user.toml (per-skill personal override)
|
||||||
|
|
||||||
Skill name is derived from the basename of the skill directory.
|
config.toml is NOT consulted by this resolver — identity/agents live there,
|
||||||
|
skill behavior overrides live in customize.toml. Clean split.
|
||||||
|
|
||||||
|
There is no installer-tier customize.toml — the installer manages identity
|
||||||
|
(config.toml), not skill behavior. customize.{,user.}toml is purely human
|
||||||
|
or `bmad-customize`-skill-authored.
|
||||||
|
|
||||||
|
Skill name is derived from the basename of the skill directory. If the
|
||||||
|
skill lives under `{module}-skills/...`, a qualified name `{module}/{skill}`
|
||||||
|
is also computed and used for pattern matching.
|
||||||
|
|
||||||
|
Inside a customize layer, [skills.X] sections cascade by specificity (most
|
||||||
|
specific wins within the layer):
|
||||||
|
[skills."*"] # catchall
|
||||||
|
[skills."bmad-*"] # bare-name glob
|
||||||
|
[skills."bmm/*"] # module-scoped glob
|
||||||
|
[skills.bmad-prd] # bare exact
|
||||||
|
[skills."bmm/bmad-prd"] # qualified exact (most specific)
|
||||||
|
|
||||||
|
Patterns are matched against both the bare skill name and (if available)
|
||||||
|
the qualified `module/skill` name. Specificity scoring: exact > wildcard,
|
||||||
|
longer-pattern > shorter, `*` is the lowest.
|
||||||
|
|
||||||
|
{global-dir} = $BMAD_HOME if set, otherwise ~/.bmad (cross-platform).
|
||||||
|
|
||||||
Outputs merged JSON to stdout. Errors go to stderr.
|
Outputs merged JSON to stdout. Errors go to stderr.
|
||||||
|
|
||||||
|
|
@ -34,7 +62,10 @@ description/prompt.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import fnmatch
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
@ -166,6 +197,93 @@ def deep_merge(base, override):
|
||||||
return override
|
return override
|
||||||
|
|
||||||
|
|
||||||
|
_MODULE_SKILLS_RE = re.compile(r"^(?P<module>[A-Za-z0-9_-]+)-skills$")
|
||||||
|
|
||||||
|
|
||||||
|
def detect_skill_module(skill_dir: Path) -> str | None:
|
||||||
|
"""Walk up from the skill dir looking for an ancestor named `{module}-skills`.
|
||||||
|
|
||||||
|
Returns the module slug (e.g. 'bmm', 'core') or None if the skill isn't
|
||||||
|
inside a recognizable module tree (standalone skill, test fixture, etc.).
|
||||||
|
"""
|
||||||
|
for ancestor in skill_dir.parents:
|
||||||
|
match = _MODULE_SKILLS_RE.match(ancestor.name)
|
||||||
|
if match:
|
||||||
|
return match.group("module")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_global_dir() -> Path:
|
||||||
|
"""Locate the cross-platform global BMad config directory ($BMAD_HOME or ~/.bmad)."""
|
||||||
|
override = os.environ.get("BMAD_HOME")
|
||||||
|
if override:
|
||||||
|
return Path(override).expanduser().resolve()
|
||||||
|
return Path.home() / ".bmad"
|
||||||
|
|
||||||
|
|
||||||
|
def collect_customize_layers(project_root: Path | None, global_dir: Path) -> list[Path]:
|
||||||
|
"""Return customize.toml file paths in lowest→highest priority order.
|
||||||
|
|
||||||
|
Four layers (all optional):
|
||||||
|
1. {global-dir}/customize.toml — global team / machine
|
||||||
|
2. {global-dir}/customize.user.toml — global personal
|
||||||
|
3. _bmad/custom/customize.toml — project team, committed
|
||||||
|
4. _bmad/custom/customize.user.toml — project personal, gitignored
|
||||||
|
|
||||||
|
Installer-managed _bmad/config*.toml is NOT scanned: customize is a
|
||||||
|
separate concern from identity, and the installer does not author it.
|
||||||
|
"""
|
||||||
|
layers: list[Path] = [
|
||||||
|
global_dir / "customize.toml",
|
||||||
|
global_dir / "customize.user.toml",
|
||||||
|
]
|
||||||
|
if project_root is not None:
|
||||||
|
custom_dir = project_root / "_bmad" / "custom"
|
||||||
|
layers.extend([
|
||||||
|
custom_dir / "customize.toml",
|
||||||
|
custom_dir / "customize.user.toml",
|
||||||
|
])
|
||||||
|
return layers
|
||||||
|
|
||||||
|
|
||||||
|
def _pattern_specificity(pattern: str) -> tuple[int, int]:
|
||||||
|
"""Score a [skills.X] pattern for specificity ordering (ascending = less specific).
|
||||||
|
|
||||||
|
Tier 2 (exact, no wildcards): most specific. Tie-break by pattern length.
|
||||||
|
Tier 1 (wildcard, not pure '*'): mid. Longer patterns are more specific.
|
||||||
|
Tier 0 (bare '*'): catchall.
|
||||||
|
"""
|
||||||
|
if pattern == "*":
|
||||||
|
return (0, 0)
|
||||||
|
if "*" in pattern or "?" in pattern or "[" in pattern:
|
||||||
|
return (1, len(pattern))
|
||||||
|
return (2, len(pattern))
|
||||||
|
|
||||||
|
|
||||||
|
def extract_skill_overrides(layer_data: dict, qualified: str | None, bare: str) -> dict:
|
||||||
|
"""Build a single-skill override dict from a layer's [skills.X] table.
|
||||||
|
|
||||||
|
Matches every pattern that fnmatches against the bare or qualified name,
|
||||||
|
then deep-merges them in ascending specificity so the most specific wins.
|
||||||
|
"""
|
||||||
|
skills_table = layer_data.get("skills")
|
||||||
|
if not isinstance(skills_table, dict):
|
||||||
|
return {}
|
||||||
|
matched: list[tuple[tuple[int, int], dict]] = []
|
||||||
|
for pattern, override in skills_table.items():
|
||||||
|
if not isinstance(override, dict):
|
||||||
|
continue
|
||||||
|
if fnmatch.fnmatchcase(bare, pattern) or (
|
||||||
|
qualified is not None and fnmatch.fnmatchcase(qualified, pattern)
|
||||||
|
):
|
||||||
|
matched.append((_pattern_specificity(pattern), override))
|
||||||
|
matched.sort(key=lambda item: item[0])
|
||||||
|
result: dict = {}
|
||||||
|
for _, override in matched:
|
||||||
|
result = deep_merge(result, override)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def extract_key(data, dotted_key: str):
|
def extract_key(data, dotted_key: str):
|
||||||
parts = dotted_key.split(".")
|
parts = dotted_key.split(".")
|
||||||
current = data
|
current = data
|
||||||
|
|
@ -187,7 +305,7 @@ def write_json_stdout(output):
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Resolve customization for a BMad skill using three-layer TOML merge.",
|
description="Resolve customization for a BMad skill using a layered TOML merge with [skills.X] cascade.",
|
||||||
add_help=True,
|
add_help=True,
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
|
|
@ -202,6 +320,8 @@ def main():
|
||||||
|
|
||||||
skill_dir = Path(args.skill).resolve()
|
skill_dir = Path(args.skill).resolve()
|
||||||
skill_name = skill_dir.name
|
skill_name = skill_dir.name
|
||||||
|
module = detect_skill_module(skill_dir)
|
||||||
|
qualified = f"{module}/{skill_name}" if module else None
|
||||||
defaults_path = skill_dir / "customize.toml"
|
defaults_path = skill_dir / "customize.toml"
|
||||||
|
|
||||||
defaults = load_toml(defaults_path, required=True)
|
defaults = load_toml(defaults_path, required=True)
|
||||||
|
|
@ -211,16 +331,27 @@ def main():
|
||||||
# for standalone skills invoked directly). Using cwd first is unsafe when
|
# for standalone skills invoked directly). Using cwd first is unsafe when
|
||||||
# an ancestor of cwd happens to have a stray _bmad/ from another project.
|
# an ancestor of cwd happens to have a stray _bmad/ from another project.
|
||||||
project_root = find_project_root(skill_dir) or find_project_root(Path.cwd())
|
project_root = find_project_root(skill_dir) or find_project_root(Path.cwd())
|
||||||
|
global_dir = resolve_global_dir()
|
||||||
|
|
||||||
team = {}
|
merged = defaults
|
||||||
user = {}
|
|
||||||
|
# Walk the customize layers low→high, applying any [skills.X] sections
|
||||||
|
# that match this skill. This is the cross-cutting override surface:
|
||||||
|
# users can cascade values across all skills, all skills in a module, or
|
||||||
|
# one specific skill without editing per-skill files.
|
||||||
|
for layer_path in collect_customize_layers(project_root, global_dir):
|
||||||
|
layer_data = load_toml(layer_path)
|
||||||
|
skill_override = extract_skill_overrides(layer_data, qualified, skill_name)
|
||||||
|
if skill_override:
|
||||||
|
merged = deep_merge(merged, skill_override)
|
||||||
|
|
||||||
|
# Per-skill override files (highest priority — most explicit). These keep
|
||||||
|
# working for back-compat and remain the right tool when an override is
|
||||||
|
# large or wholly skill-specific.
|
||||||
if project_root:
|
if project_root:
|
||||||
custom_dir = project_root / "_bmad" / "custom"
|
custom_dir = project_root / "_bmad" / "custom"
|
||||||
team = load_toml(custom_dir / f"{skill_name}.toml")
|
merged = deep_merge(merged, load_toml(custom_dir / f"{skill_name}.toml"))
|
||||||
user = load_toml(custom_dir / f"{skill_name}.user.toml")
|
merged = deep_merge(merged, load_toml(custom_dir / f"{skill_name}.user.toml"))
|
||||||
|
|
||||||
merged = deep_merge(defaults, team)
|
|
||||||
merged = deep_merge(merged, user)
|
|
||||||
|
|
||||||
if args.key:
|
if args.key:
|
||||||
output = {}
|
output = {}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,306 @@
|
||||||
|
# /// script
|
||||||
|
# requires-python = ">=3.10"
|
||||||
|
# dependencies = ["pytest>=8.0"]
|
||||||
|
# ///
|
||||||
|
"""Tests for memlog.py. Run: uv run --with pytest pytest scripts/tests/test_memlog.py
|
||||||
|
|
||||||
|
The spine under test is the flat, append-only, chronological invariant: every entry is
|
||||||
|
one line recorded at the end in the order it happened — no sections, no grouping, and no
|
||||||
|
lifecycle status the log would have to mutate.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
import memlog # noqa: E402
|
||||||
|
|
||||||
|
MEMLOG = ".memlog.md"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ws(tmp_path):
|
||||||
|
return str(tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
def read(ws):
|
||||||
|
return (Path(ws) / MEMLOG).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def body_of(ws):
|
||||||
|
return memlog.split(read(ws))[1]
|
||||||
|
|
||||||
|
|
||||||
|
def entries(ws):
|
||||||
|
return [ln for ln in body_of(ws).splitlines() if ln.startswith("- ")]
|
||||||
|
|
||||||
|
|
||||||
|
def init(ws, **fields):
|
||||||
|
fields = fields or {"topic": "Reinvent the lunchbox", "goal": "ideas for a pitch"}
|
||||||
|
argv = ["init", "--workspace", ws]
|
||||||
|
for k, v in fields.items():
|
||||||
|
argv += ["--field", f"{k}={v}"]
|
||||||
|
assert memlog.main(argv) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def append(ws, text, entry_type=None, by=None):
|
||||||
|
argv = ["append", "--workspace", ws, "--text", text]
|
||||||
|
if entry_type:
|
||||||
|
argv += ["--type", entry_type]
|
||||||
|
if by:
|
||||||
|
argv += ["--by", by]
|
||||||
|
assert memlog.main(argv) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# --- init ---------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_init_writes_frontmatter_fields(ws):
|
||||||
|
init(ws)
|
||||||
|
meta, body = memlog.split(read(ws))
|
||||||
|
assert meta["topic"] == "Reinvent the lunchbox"
|
||||||
|
assert meta["goal"] == "ideas for a pitch"
|
||||||
|
assert "updated" in meta
|
||||||
|
assert body.strip() == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_has_no_lifecycle_status(ws):
|
||||||
|
# A memory log carries no "status" flag; completion is an appended entry, not frontmatter.
|
||||||
|
init(ws)
|
||||||
|
meta, _ = memlog.split(read(ws))
|
||||||
|
assert "status" not in meta
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_arbitrary_fields(ws):
|
||||||
|
init(ws, topic="T", audience="board")
|
||||||
|
meta, _ = memlog.split(read(ws))
|
||||||
|
assert meta["audience"] == "board"
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_refuses_overwrite(ws):
|
||||||
|
init(ws)
|
||||||
|
assert memlog.main(["init", "--workspace", ws, "--field", "topic=other"]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_creates_missing_workspace(tmp_path):
|
||||||
|
nested = str(tmp_path / "a" / "b")
|
||||||
|
assert memlog.main(["init", "--workspace", nested, "--field", "topic=T"]) == 0
|
||||||
|
assert (Path(nested) / MEMLOG).is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_rejects_malformed_field(ws):
|
||||||
|
assert memlog.main(["init", "--workspace", ws, "--field", "noequals"]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
# --- addressing: --workspace and --path are interchangeable --------------
|
||||||
|
|
||||||
|
def test_path_addressing_targets_the_file_directly(tmp_path):
|
||||||
|
target = tmp_path / "run" / ".memlog.md"
|
||||||
|
assert memlog.main(["init", "--path", str(target), "--field", "topic=T"]) == 0
|
||||||
|
assert target.is_file()
|
||||||
|
assert memlog.main(["append", "--path", str(target), "--text", "an idea", "--type", "idea"]) == 0
|
||||||
|
body = memlog.split(target.read_text(encoding="utf-8"))[1]
|
||||||
|
assert "- (idea) an idea" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_workspace_and_path_resolve_to_same_file(ws):
|
||||||
|
init(ws)
|
||||||
|
via_path = str(Path(ws) / MEMLOG)
|
||||||
|
assert memlog.main(["append", "--path", via_path, "--text", "from path"]) == 0
|
||||||
|
assert memlog.main(["append", "--workspace", ws, "--text", "from workspace"]) == 0
|
||||||
|
assert entries(ws) == ["- from path", "- from workspace"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_target_is_required(ws):
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
memlog.main(["append", "--text", "orphan"]) # neither --workspace nor --path
|
||||||
|
|
||||||
|
|
||||||
|
# --- append: flat chronological order is the whole point -----------------
|
||||||
|
|
||||||
|
def test_append_lands_at_end_in_order(ws):
|
||||||
|
init(ws)
|
||||||
|
append(ws, "first")
|
||||||
|
append(ws, "second")
|
||||||
|
append(ws, "third")
|
||||||
|
assert entries(ws) == ["- first", "- second", "- third"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_sections_or_headings_ever(ws):
|
||||||
|
init(ws)
|
||||||
|
append(ws, "started foo", entry_type="technique")
|
||||||
|
append(ws, "an idea", entry_type="idea")
|
||||||
|
append(ws, "started bar", entry_type="technique")
|
||||||
|
assert "## " not in body_of(ws) # the flat log never grows headings
|
||||||
|
|
||||||
|
|
||||||
|
def test_type_renders_as_inline_tag(ws):
|
||||||
|
init(ws)
|
||||||
|
append(ws, "the earth revolves around the sun", entry_type="idea")
|
||||||
|
append(ws, "how do we handle stampede?", entry_type="question")
|
||||||
|
body = body_of(ws)
|
||||||
|
assert "- (idea) the earth revolves around the sun" in body
|
||||||
|
assert "- (question) how do we handle stampede?" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_append_without_type_is_plain_note(ws):
|
||||||
|
init(ws)
|
||||||
|
append(ws, "bare entry")
|
||||||
|
assert entries(ws) == ["- bare entry"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_completion_is_an_entry_not_a_status(ws):
|
||||||
|
# The documented way to mark a session done: append it. Frontmatter never gains a status.
|
||||||
|
init(ws)
|
||||||
|
append(ws, "session complete", entry_type="event")
|
||||||
|
meta, _ = memlog.split(read(ws))
|
||||||
|
assert "status" not in meta
|
||||||
|
assert entries(ws)[-1] == "- (event) session complete"
|
||||||
|
|
||||||
|
|
||||||
|
def test_append_collapses_newlines_into_one_line(ws):
|
||||||
|
init(ws)
|
||||||
|
append(ws, "line one\nline two\n spaced out")
|
||||||
|
assert entries(ws) == ["- line one line two spaced out"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_revisited_technique_is_just_a_later_entry(ws):
|
||||||
|
# the user's model: switching techniques is an entry, not a section to return to
|
||||||
|
init(ws)
|
||||||
|
append(ws, "started SCAMPER", entry_type="technique")
|
||||||
|
append(ws, "magnetic latch", entry_type="idea")
|
||||||
|
append(ws, "started Six Hats", entry_type="technique")
|
||||||
|
append(ws, "stale data risk", entry_type="idea")
|
||||||
|
append(ws, "started SCAMPER", entry_type="technique") # back to SCAMPER — just appended again
|
||||||
|
append(ws, "stackable tiers", entry_type="idea")
|
||||||
|
assert entries(ws) == [
|
||||||
|
"- (technique) started SCAMPER",
|
||||||
|
"- (idea) magnetic latch",
|
||||||
|
"- (technique) started Six Hats",
|
||||||
|
"- (idea) stale data risk",
|
||||||
|
"- (technique) started SCAMPER",
|
||||||
|
"- (idea) stackable tiers",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_by_renders_attribution_in_tag(ws):
|
||||||
|
# Creative Partner mode must record whose idea each one was
|
||||||
|
init(ws)
|
||||||
|
append(ws, "magnetic latch lid", entry_type="idea", by="user")
|
||||||
|
append(ws, "lid doubles as a plate", entry_type="idea", by="coach")
|
||||||
|
body = body_of(ws)
|
||||||
|
assert "- (idea by user) magnetic latch lid" in body
|
||||||
|
assert "- (idea by coach) lid doubles as a plate" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_by_without_type_renders_alone(ws):
|
||||||
|
init(ws)
|
||||||
|
append(ws, "off-the-cuff thought", by="coach")
|
||||||
|
assert entries(ws) == ["- (by coach) off-the-cuff thought"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_heterogeneous_entry_types_coexist(ws):
|
||||||
|
init(ws)
|
||||||
|
append(ws, "an idea", entry_type="idea")
|
||||||
|
append(ws, "an open question", entry_type="question")
|
||||||
|
append(ws, "a decision we made", entry_type="decision")
|
||||||
|
append(ws, "user wants mobile-first", entry_type="direction")
|
||||||
|
body = body_of(ws)
|
||||||
|
for tag in ("(idea)", "(question)", "(decision)", "(direction)"):
|
||||||
|
assert tag in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_free_vocabulary_is_not_enforced(ws):
|
||||||
|
# The tool is neutral: any --type the host skill names renders verbatim.
|
||||||
|
init(ws)
|
||||||
|
append(ws, "a custom kind", entry_type="crack")
|
||||||
|
append(ws, "another", entry_type="lock")
|
||||||
|
body = body_of(ws)
|
||||||
|
assert "- (crack) a custom kind" in body
|
||||||
|
assert "- (lock) another" in body
|
||||||
|
|
||||||
|
|
||||||
|
# --- set: generic descriptive frontmatter, no lifecycle semantics --------
|
||||||
|
|
||||||
|
def test_set_adds_field(ws):
|
||||||
|
init(ws)
|
||||||
|
memlog.main(["set", "--workspace", ws, "--key", "mode", "--value", "partner"])
|
||||||
|
assert memlog.split(read(ws))[0]["mode"] == "partner"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_replaces_field(ws):
|
||||||
|
init(ws, topic="T", mode="facilitator")
|
||||||
|
memlog.main(["set", "--workspace", ws, "--key", "mode", "--value", "partner"])
|
||||||
|
assert memlog.split(read(ws))[0]["mode"] == "partner"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_preserves_body(ws):
|
||||||
|
init(ws)
|
||||||
|
append(ws, "keep me", entry_type="idea")
|
||||||
|
memlog.main(["set", "--workspace", ws, "--key", "mode", "--value", "partner"])
|
||||||
|
meta, body = memlog.split(read(ws))
|
||||||
|
assert meta["mode"] == "partner"
|
||||||
|
assert "- (idea) keep me" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_updated_stays_last(ws):
|
||||||
|
init(ws)
|
||||||
|
memlog.main(["set", "--workspace", ws, "--key", "owner", "--value", "BMad"])
|
||||||
|
meta = memlog.split(read(ws))[0]
|
||||||
|
assert list(meta)[-1] == "updated"
|
||||||
|
|
||||||
|
|
||||||
|
# --- robustness ---------------------------------------------------------
|
||||||
|
|
||||||
|
def test_roundtrip_render_is_stable(ws):
|
||||||
|
init(ws)
|
||||||
|
append(ws, "one", entry_type="idea")
|
||||||
|
first = read(ws)
|
||||||
|
meta, body = memlog.split(first)
|
||||||
|
assert memlog.render(meta, body) == first
|
||||||
|
|
||||||
|
|
||||||
|
def test_commas_in_field_survive(ws):
|
||||||
|
init(ws, topic="cars, trains, and planes")
|
||||||
|
append(ws, "z", entry_type="idea")
|
||||||
|
meta, _ = memlog.split(read(ws))
|
||||||
|
assert meta["topic"] == "cars, trains, and planes"
|
||||||
|
|
||||||
|
|
||||||
|
def test_triple_dash_in_field_does_not_corrupt_frontmatter(ws):
|
||||||
|
# A `---` inside a value must NOT be read as the closing fence: topic stays intact
|
||||||
|
# and the body never leaks frontmatter text.
|
||||||
|
init(ws, topic="Pricing --- tiers --- and add-ons")
|
||||||
|
append(ws, "an idea", entry_type="idea")
|
||||||
|
meta, body = memlog.split(read(ws))
|
||||||
|
assert meta["topic"] == "Pricing --- tiers --- and add-ons"
|
||||||
|
assert entries(ws) == ["- (idea) an idea"]
|
||||||
|
assert "topic:" not in body # frontmatter never bled into the body
|
||||||
|
|
||||||
|
|
||||||
|
def test_newline_in_field_is_neutralized(ws):
|
||||||
|
# A value carrying a newline can't break the fence on the next round-trip.
|
||||||
|
memlog.main(["init", "--workspace", ws, "--field", "topic=line one\nline two"])
|
||||||
|
append(ws, "x", entry_type="idea")
|
||||||
|
meta, _ = memlog.split(read(ws))
|
||||||
|
assert "\n" not in meta["topic"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_append_emits_json_ack(ws, capsys):
|
||||||
|
init(ws)
|
||||||
|
append(ws, "x", entry_type="idea")
|
||||||
|
out = json.loads(capsys.readouterr().out.strip().splitlines()[-1])
|
||||||
|
assert out["ok"] is True
|
||||||
|
assert out["entries"] == 1
|
||||||
|
assert out["memlog"].endswith(MEMLOG)
|
||||||
|
assert "status" not in out # no lifecycle status
|
||||||
|
assert "section" not in out # sections are gone
|
||||||
|
|
||||||
|
|
||||||
|
def test_ack_entry_count_climbs(ws, capsys):
|
||||||
|
init(ws)
|
||||||
|
append(ws, "a")
|
||||||
|
append(ws, "b")
|
||||||
|
out = json.loads(capsys.readouterr().out.strip().splitlines()[-1])
|
||||||
|
assert out["entries"] == 2
|
||||||
|
|
@ -0,0 +1,197 @@
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
SCRIPT = Path(__file__).resolve().parents[1] / "resolve_config.py"
|
||||||
|
|
||||||
|
|
||||||
|
def run_raw(args, env_overrides=None):
|
||||||
|
"""Run resolver, return CompletedProcess-like object with decoded streams.
|
||||||
|
Use this when the test expects a non-zero exit (e.g. fail-fast checks)."""
|
||||||
|
env = os.environ.copy()
|
||||||
|
# Force BMAD_HOME to a guaranteed-missing path so any value the developer
|
||||||
|
# has set in their shell can't leak the real ~/.bmad into a test that
|
||||||
|
# expected an empty global. Tests that need a populated global pass it via
|
||||||
|
# env_overrides below.
|
||||||
|
env["BMAD_HOME"] = "/nonexistent-bmad-home-default"
|
||||||
|
if env_overrides:
|
||||||
|
env.update(env_overrides)
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, str(SCRIPT), *args],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
env=env,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
result.stdout = result.stdout.decode("utf-8", errors="replace")
|
||||||
|
result.stderr = result.stderr.decode("utf-8", errors="replace")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def run(args, env_overrides=None):
|
||||||
|
result = run_raw(args, env_overrides=env_overrides)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise AssertionError(f"resolve_config failed ({result.returncode}): {result.stderr}")
|
||||||
|
return json.loads(result.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
class ResolveConfigTests(unittest.TestCase):
|
||||||
|
def test_no_project_root_no_global_returns_empty(self):
|
||||||
|
with tempfile.TemporaryDirectory() as empty_global:
|
||||||
|
data = run([], env_overrides={"BMAD_HOME": empty_global})
|
||||||
|
self.assertEqual(data, {})
|
||||||
|
|
||||||
|
def test_global_only_when_no_project_root(self):
|
||||||
|
with tempfile.TemporaryDirectory() as global_dir:
|
||||||
|
(Path(global_dir) / "config.toml").write_text(
|
||||||
|
'[core]\nuser_name = "Globie"\n', encoding="utf-8"
|
||||||
|
)
|
||||||
|
data = run([], env_overrides={"BMAD_HOME": global_dir})
|
||||||
|
self.assertEqual(data["core"]["user_name"], "Globie")
|
||||||
|
|
||||||
|
def test_project_overrides_global(self):
|
||||||
|
with tempfile.TemporaryDirectory() as global_dir, tempfile.TemporaryDirectory() as proj:
|
||||||
|
(Path(global_dir) / "config.toml").write_text(
|
||||||
|
'[core]\nuser_name = "Globie"\ncommunication_language = "French"\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
bmad = Path(proj) / "_bmad"
|
||||||
|
bmad.mkdir()
|
||||||
|
(bmad / "config.toml").write_text(
|
||||||
|
'[core]\nuser_name = "ProjectUser"\n', encoding="utf-8"
|
||||||
|
)
|
||||||
|
data = run(
|
||||||
|
["--project-root", proj], env_overrides={"BMAD_HOME": global_dir}
|
||||||
|
)
|
||||||
|
# Project wins on user_name; global fills in communication_language
|
||||||
|
self.assertEqual(data["core"]["user_name"], "ProjectUser")
|
||||||
|
self.assertEqual(data["core"]["communication_language"], "French")
|
||||||
|
|
||||||
|
def test_custom_user_beats_everything(self):
|
||||||
|
with tempfile.TemporaryDirectory() as global_dir, tempfile.TemporaryDirectory() as proj:
|
||||||
|
(Path(global_dir) / "config.user.toml").write_text(
|
||||||
|
'[core]\nuser_name = "Globie"\n', encoding="utf-8"
|
||||||
|
)
|
||||||
|
bmad = Path(proj) / "_bmad"
|
||||||
|
(bmad / "custom").mkdir(parents=True)
|
||||||
|
(bmad / "config.toml").write_text(
|
||||||
|
'[core]\nuser_name = "Installer"\n', encoding="utf-8"
|
||||||
|
)
|
||||||
|
(bmad / "custom" / "config.user.toml").write_text(
|
||||||
|
'[core]\nuser_name = "Pinned"\n', encoding="utf-8"
|
||||||
|
)
|
||||||
|
data = run(
|
||||||
|
["--project-root", proj], env_overrides={"BMAD_HOME": global_dir}
|
||||||
|
)
|
||||||
|
self.assertEqual(data["core"]["user_name"], "Pinned")
|
||||||
|
|
||||||
|
def test_project_config_optional(self):
|
||||||
|
# _bmad/ exists (installed project) but no config.toml inside — that's
|
||||||
|
# the lean / global-only case and must not error.
|
||||||
|
with tempfile.TemporaryDirectory() as proj:
|
||||||
|
(Path(proj) / "_bmad").mkdir()
|
||||||
|
data = run(["--project-root", proj])
|
||||||
|
self.assertEqual(data, {})
|
||||||
|
|
||||||
|
def test_project_root_without_bmad_dir_errors(self):
|
||||||
|
# --project-root pointing at a directory with no _bmad/ is treated
|
||||||
|
# as a broken install (typo, wiped install) — resolver exits non-zero
|
||||||
|
# rather than silently returning {}. Global-only mode (no
|
||||||
|
# --project-root) keeps the permissive behavior.
|
||||||
|
with tempfile.TemporaryDirectory() as proj:
|
||||||
|
result = run_raw(["--project-root", proj])
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertIn("no _bmad/ directory", result.stderr)
|
||||||
|
|
||||||
|
def test_module_floor_contributes_when_no_overrides(self):
|
||||||
|
# Module-shipped defaults from _bmad/{module}/module.toml should
|
||||||
|
# appear when nothing else specifies them.
|
||||||
|
with tempfile.TemporaryDirectory() as proj:
|
||||||
|
bmad = Path(proj) / "_bmad" / "bmm"
|
||||||
|
bmad.mkdir(parents=True)
|
||||||
|
(bmad / "module.toml").write_text(
|
||||||
|
'[modules.bmm]\nplanning_artifacts = "/from/module/yaml"\n'
|
||||||
|
'[agents.bmad-agent-analyst]\nname = "Mary"\nmodule = "bmm"\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
data = run(["--project-root", proj])
|
||||||
|
self.assertEqual(data["modules"]["bmm"]["planning_artifacts"], "/from/module/yaml")
|
||||||
|
self.assertEqual(data["agents"]["bmad-agent-analyst"]["name"], "Mary")
|
||||||
|
|
||||||
|
def test_config_toml_overrides_module_floor(self):
|
||||||
|
# Config layers sit above the module floor — explicit overrides win.
|
||||||
|
with tempfile.TemporaryDirectory() as proj:
|
||||||
|
bmad = Path(proj) / "_bmad"
|
||||||
|
mod = bmad / "bmm"
|
||||||
|
mod.mkdir(parents=True)
|
||||||
|
(mod / "module.toml").write_text(
|
||||||
|
'[modules.bmm]\nplanning_artifacts = "/module/default"\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(bmad / "config.toml").write_text(
|
||||||
|
'[modules.bmm]\nplanning_artifacts = "/user/override"\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
data = run(["--project-root", proj])
|
||||||
|
self.assertEqual(
|
||||||
|
data["modules"]["bmm"]["planning_artifacts"], "/user/override"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_multiple_modules_merge_independently(self):
|
||||||
|
# Each module writes its own [modules.X] / [agents.X] subtree;
|
||||||
|
# the merge should not collide across modules.
|
||||||
|
with tempfile.TemporaryDirectory() as proj:
|
||||||
|
bmad = Path(proj) / "_bmad"
|
||||||
|
(bmad / "bmm").mkdir(parents=True)
|
||||||
|
(bmad / "bmb").mkdir(parents=True)
|
||||||
|
(bmad / "bmm" / "module.toml").write_text(
|
||||||
|
'[modules.bmm]\nplanning_artifacts = "/bmm/path"\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(bmad / "bmb" / "module.toml").write_text(
|
||||||
|
'[modules.bmb]\nbmad_builder_output_folder = "/bmb/path"\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
data = run(["--project-root", proj])
|
||||||
|
self.assertEqual(data["modules"]["bmm"]["planning_artifacts"], "/bmm/path")
|
||||||
|
self.assertEqual(
|
||||||
|
data["modules"]["bmb"]["bmad_builder_output_folder"], "/bmb/path"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_module_floor_ignored_when_no_project_root(self):
|
||||||
|
# Without --project-root, no per-project files (including module
|
||||||
|
# floor) should be read. Just global.
|
||||||
|
with tempfile.TemporaryDirectory() as g:
|
||||||
|
(Path(g) / "config.toml").write_text(
|
||||||
|
'[core]\nuser_name = "Globie"\n', encoding="utf-8"
|
||||||
|
)
|
||||||
|
data = run([], env_overrides={"BMAD_HOME": g})
|
||||||
|
self.assertEqual(data, {"core": {"user_name": "Globie"}})
|
||||||
|
|
||||||
|
def test_non_module_dirs_skipped(self):
|
||||||
|
# _bmad/_config, _bmad/custom, _bmad/scripts must NOT be treated as
|
||||||
|
# modules even though they're direct children of _bmad/.
|
||||||
|
with tempfile.TemporaryDirectory() as proj:
|
||||||
|
bmad = Path(proj) / "_bmad"
|
||||||
|
for sub in ("_config", "custom", "scripts"):
|
||||||
|
(bmad / sub).mkdir(parents=True)
|
||||||
|
# No module.toml anywhere — resolver should return {}, not error
|
||||||
|
data = run(["--project-root", proj])
|
||||||
|
self.assertEqual(data, {})
|
||||||
|
|
||||||
|
def test_bmad_home_env_var_honored(self):
|
||||||
|
with tempfile.TemporaryDirectory() as global_dir:
|
||||||
|
(Path(global_dir) / "config.toml").write_text(
|
||||||
|
'[core]\nuser_name = "FromEnv"\n', encoding="utf-8"
|
||||||
|
)
|
||||||
|
data = run([], env_overrides={"BMAD_HOME": global_dir})
|
||||||
|
self.assertEqual(data["core"]["user_name"], "FromEnv")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -0,0 +1,156 @@
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
SCRIPT = Path(__file__).resolve().parents[1] / "resolve_customization.py"
|
||||||
|
|
||||||
|
|
||||||
|
def make_skill(parent: Path, name: str, defaults_toml: str, module: str | None = None) -> Path:
|
||||||
|
"""Create a fake skill dir, optionally under a `{module}-skills/` ancestor."""
|
||||||
|
if module:
|
||||||
|
base = parent / f"{module}-skills"
|
||||||
|
base.mkdir(parents=True, exist_ok=True)
|
||||||
|
else:
|
||||||
|
base = parent
|
||||||
|
skill_dir = base / name
|
||||||
|
skill_dir.mkdir()
|
||||||
|
(skill_dir / "customize.toml").write_text(defaults_toml, encoding="utf-8")
|
||||||
|
return skill_dir
|
||||||
|
|
||||||
|
|
||||||
|
def run(skill_dir: Path, key=None, env_overrides=None):
|
||||||
|
env = os.environ.copy()
|
||||||
|
# Force BMAD_HOME to a guaranteed-missing path so the developer's real
|
||||||
|
# ~/.bmad never leaks into a test expecting an empty global. Tests that
|
||||||
|
# need a populated global override via env_overrides below.
|
||||||
|
env["BMAD_HOME"] = "/nonexistent-bmad-home-default"
|
||||||
|
if env_overrides:
|
||||||
|
env.update(env_overrides)
|
||||||
|
args = [sys.executable, str(SCRIPT), "--skill", str(skill_dir)]
|
||||||
|
if key:
|
||||||
|
args.extend(["--key", key])
|
||||||
|
result = subprocess.run(
|
||||||
|
args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, check=False
|
||||||
|
)
|
||||||
|
stderr = result.stderr.decode("utf-8", errors="replace")
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise AssertionError(f"resolve_customization failed ({result.returncode}): {stderr}")
|
||||||
|
return json.loads(result.stdout.decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
class ResolveCustomizationCascadeTests(unittest.TestCase):
|
||||||
|
def test_defaults_pass_through_when_no_overrides(self):
|
||||||
|
with tempfile.TemporaryDirectory() as t:
|
||||||
|
skill = make_skill(Path(t), "bmad-prd", '[knobs]\ndepth = "low"\n')
|
||||||
|
data = run(skill)
|
||||||
|
self.assertEqual(data["knobs"]["depth"], "low")
|
||||||
|
|
||||||
|
def test_skills_section_in_global_customize_overrides_default(self):
|
||||||
|
with tempfile.TemporaryDirectory() as t, tempfile.TemporaryDirectory() as global_dir:
|
||||||
|
skill = make_skill(Path(t), "bmad-prd", '[knobs]\ndepth = "low"\n')
|
||||||
|
(Path(global_dir) / "customize.toml").write_text(
|
||||||
|
'[skills.bmad-prd.knobs]\ndepth = "high"\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
data = run(skill, env_overrides={"BMAD_HOME": global_dir})
|
||||||
|
self.assertEqual(data["knobs"]["depth"], "high")
|
||||||
|
|
||||||
|
def test_config_toml_is_not_consulted_for_skills_section(self):
|
||||||
|
# [skills.X] in config.toml must NOT be honored — only customize.toml is.
|
||||||
|
with tempfile.TemporaryDirectory() as t, tempfile.TemporaryDirectory() as g:
|
||||||
|
skill = make_skill(Path(t), "bmad-prd", '[knobs]\ndepth = "low"\n')
|
||||||
|
(Path(g) / "config.toml").write_text(
|
||||||
|
'[skills.bmad-prd.knobs]\ndepth = "should-be-ignored"\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
data = run(skill, env_overrides={"BMAD_HOME": g})
|
||||||
|
self.assertEqual(data["knobs"]["depth"], "low")
|
||||||
|
|
||||||
|
def test_specificity_within_layer(self):
|
||||||
|
# Within ONE customize layer: exact skill name beats wildcard.
|
||||||
|
with tempfile.TemporaryDirectory() as t, tempfile.TemporaryDirectory() as g:
|
||||||
|
skill = make_skill(Path(t), "bmad-prd", '[knobs]\ndepth = "low"\n')
|
||||||
|
(Path(g) / "customize.toml").write_text(
|
||||||
|
"[skills.\"*\".knobs]\ndepth = \"catchall\"\n"
|
||||||
|
"[skills.bmad-prd.knobs]\ndepth = \"exact\"\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
data = run(skill, env_overrides={"BMAD_HOME": g})
|
||||||
|
self.assertEqual(data["knobs"]["depth"], "exact")
|
||||||
|
|
||||||
|
def test_layer_precedence_overrides_specificity(self):
|
||||||
|
# Across layers: higher layer wins even with less-specific pattern.
|
||||||
|
# Project custom user (highest customize layer) uses '*';
|
||||||
|
# global (lowest) uses exact — project custom should still win.
|
||||||
|
with tempfile.TemporaryDirectory() as t, tempfile.TemporaryDirectory() as g:
|
||||||
|
project = Path(t) / "project"
|
||||||
|
bmad = project / "_bmad"
|
||||||
|
(bmad / "custom").mkdir(parents=True)
|
||||||
|
skill = make_skill(project, "bmad-prd", '[knobs]\ndepth = "low"\n')
|
||||||
|
|
||||||
|
(Path(g) / "customize.toml").write_text(
|
||||||
|
"[skills.bmad-prd.knobs]\ndepth = \"global-exact\"\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(bmad / "custom" / "customize.user.toml").write_text(
|
||||||
|
"[skills.\"*\".knobs]\ndepth = \"custom-wildcard\"\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
data = run(skill, env_overrides={"BMAD_HOME": g})
|
||||||
|
self.assertEqual(data["knobs"]["depth"], "custom-wildcard")
|
||||||
|
|
||||||
|
def test_per_skill_custom_file_beats_customize_section(self):
|
||||||
|
# Per-skill _bmad/custom/{skill}.user.toml is still the highest tier.
|
||||||
|
with tempfile.TemporaryDirectory() as t:
|
||||||
|
project = Path(t) / "project"
|
||||||
|
bmad = project / "_bmad"
|
||||||
|
(bmad / "custom").mkdir(parents=True)
|
||||||
|
skill = make_skill(project, "bmad-prd", '[knobs]\ndepth = "low"\n')
|
||||||
|
|
||||||
|
(bmad / "custom" / "customize.user.toml").write_text(
|
||||||
|
"[skills.bmad-prd.knobs]\ndepth = \"via-section\"\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(bmad / "custom" / "bmad-prd.user.toml").write_text(
|
||||||
|
"[knobs]\ndepth = \"via-per-skill-file\"\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
data = run(skill)
|
||||||
|
self.assertEqual(data["knobs"]["depth"], "via-per-skill-file")
|
||||||
|
|
||||||
|
def test_qualified_module_pattern_matches(self):
|
||||||
|
# Skill under bmm-skills/ → qualified name 'bmm/bmad-prd'.
|
||||||
|
# Pattern '[skills."bmm/*"]' should match.
|
||||||
|
with tempfile.TemporaryDirectory() as t, tempfile.TemporaryDirectory() as g:
|
||||||
|
skill = make_skill(
|
||||||
|
Path(t), "bmad-prd", '[knobs]\ndepth = "low"\n', module="bmm"
|
||||||
|
)
|
||||||
|
(Path(g) / "customize.toml").write_text(
|
||||||
|
"[skills.\"bmm/*\".knobs]\ndepth = \"all-bmm\"\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
data = run(skill, env_overrides={"BMAD_HOME": g})
|
||||||
|
self.assertEqual(data["knobs"]["depth"], "all-bmm")
|
||||||
|
|
||||||
|
def test_qualified_exact_beats_module_wildcard(self):
|
||||||
|
# Within one layer: 'bmm/bmad-prd' (exact) beats 'bmm/*' (wildcard).
|
||||||
|
with tempfile.TemporaryDirectory() as t, tempfile.TemporaryDirectory() as g:
|
||||||
|
skill = make_skill(
|
||||||
|
Path(t), "bmad-prd", '[knobs]\ndepth = "low"\n', module="bmm"
|
||||||
|
)
|
||||||
|
(Path(g) / "customize.toml").write_text(
|
||||||
|
"[skills.\"bmm/*\".knobs]\ndepth = \"module-wide\"\n"
|
||||||
|
"[skills.\"bmm/bmad-prd\".knobs]\ndepth = \"pinned\"\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
data = run(skill, env_overrides={"BMAD_HOME": g})
|
||||||
|
self.assertEqual(data["knobs"]["depth"], "pinned")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -1728,7 +1728,7 @@ async function runTests() {
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Test Suite 35: Central Config Emission
|
// Test Suite 35: Central Config Emission (lean overrides + global routing)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
console.log(`${colors.yellow}Test Suite 35: Central Config Emission${colors.reset}\n`);
|
console.log(`${colors.yellow}Test Suite 35: Central Config Emission${colors.reset}\n`);
|
||||||
|
|
||||||
|
|
@ -1737,6 +1737,12 @@ async function runTests() {
|
||||||
// getModulePath). Only the destination bmadDir is a temp dir, which the
|
// getModulePath). Only the destination bmadDir is a temp dir, which the
|
||||||
// installer writes config.toml / config.user.toml / custom/ into.
|
// installer writes config.toml / config.user.toml / custom/ into.
|
||||||
const tempBmadDir35 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-central-config-'));
|
const tempBmadDir35 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-central-config-'));
|
||||||
|
// Phase 1: writeCentralConfig now writes scope:user core values to
|
||||||
|
// ~/.bmad/config.user.toml. Isolate via BMAD_HOME so we don't pollute
|
||||||
|
// the developer's real global config.
|
||||||
|
const tempGlobalDir35 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-global-'));
|
||||||
|
const priorBmadHome35 = process.env.BMAD_HOME;
|
||||||
|
process.env.BMAD_HOME = tempGlobalDir35;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const moduleConfigs = {
|
const moduleConfigs = {
|
||||||
|
|
@ -1799,43 +1805,54 @@ async function runTests() {
|
||||||
assert(await fs.pathExists(teamPath), 'config.toml is written to disk');
|
assert(await fs.pathExists(teamPath), 'config.toml is written to disk');
|
||||||
assert(await fs.pathExists(userPath), 'config.user.toml is written to disk');
|
assert(await fs.pathExists(userPath), 'config.user.toml is written to disk');
|
||||||
|
|
||||||
|
// generateManifests would call writeGlobalUserCore right after this; we
|
||||||
|
// call it directly here to test the full contract.
|
||||||
|
const globalCorePath = await generator35.writeGlobalUserCore(moduleConfigs);
|
||||||
|
assert(globalCorePath !== null, 'writeGlobalUserCore writes a file when scope:user values exist');
|
||||||
|
assert(globalCorePath.startsWith(tempGlobalDir35), 'writeGlobalUserCore targets $BMAD_HOME');
|
||||||
|
assert(await fs.pathExists(globalCorePath), '~/.bmad/config.user.toml is written to disk');
|
||||||
|
|
||||||
const teamContent = await fs.readFile(teamPath, 'utf8');
|
const teamContent = await fs.readFile(teamPath, 'utf8');
|
||||||
const userContent = await fs.readFile(userPath, 'utf8');
|
const userContent = await fs.readFile(userPath, 'utf8');
|
||||||
|
const globalContent = await fs.readFile(globalCorePath, 'utf8');
|
||||||
|
|
||||||
// [core] — team-scoped keys land in config.toml
|
// [core] — lean: only deltas from module.yaml defaults remain.
|
||||||
assert(teamContent.includes('[core]'), 'config.toml has [core] section');
|
// project_name = "demo-project" differs from default ({directory_name}).
|
||||||
assert(teamContent.includes('document_output_language = "English"'), 'Team-scope core key lands in config.toml');
|
// document_output_language = "English" EQUALS default — stripped.
|
||||||
assert(teamContent.includes('output_folder = "_bmad-output"'), 'Team-scope output_folder lands in config.toml');
|
// output_folder = "_bmad-output" EQUALS default — stripped.
|
||||||
assert(teamContent.includes('project_name = "demo-project"'), 'project_name lands in [core] (core key as of #2279)');
|
assert(teamContent.includes('[core]'), 'config.toml has [core] section (carries project_name delta)');
|
||||||
|
assert(teamContent.includes('project_name = "demo-project"'), 'Delta core key (project_name) lands in config.toml');
|
||||||
|
assert(!teamContent.includes('document_output_language'), 'Default core values are stripped from config.toml (Task F)');
|
||||||
assert(!teamContent.includes('user_name'), 'user_name (scope: user) is absent from config.toml');
|
assert(!teamContent.includes('user_name'), 'user_name (scope: user) is absent from config.toml');
|
||||||
assert(!teamContent.includes('communication_language'), 'communication_language (scope: user) is absent from config.toml');
|
assert(!teamContent.includes('communication_language'), 'communication_language (scope: user) is absent from config.toml');
|
||||||
|
|
||||||
// [core] — user-scoped keys land in config.user.toml
|
// [core] user-scope no longer goes to project user file — it's routed
|
||||||
assert(userContent.includes('[core]'), 'config.user.toml has [core] section');
|
// to ~/.bmad/config.user.toml (Task D).
|
||||||
assert(userContent.includes('user_name = "TestUser"'), 'user_name lands in config.user.toml');
|
assert(!userContent.includes('user_name'), 'config.user.toml does NOT contain user_name (Task D: routed to global)');
|
||||||
assert(userContent.includes('communication_language = "Spanish"'), 'communication_language lands in config.user.toml');
|
assert(
|
||||||
assert(!userContent.includes('document_output_language'), 'Team-scope key is absent from config.user.toml');
|
!userContent.includes('communication_language'),
|
||||||
|
'config.user.toml does NOT contain communication_language (Task D: routed to global)',
|
||||||
|
);
|
||||||
|
|
||||||
// [modules.bmm] — core-key pollution stripped; own user-scope key routed to user file
|
// Global file at ~/.bmad/config.user.toml carries the scope:user core values
|
||||||
const bmmTeamMatch = teamContent.match(/\[modules\.bmm\][\s\S]*?(?=\n\[|$)/);
|
assert(globalContent.includes('[core]'), '~/.bmad/config.user.toml has [core] section');
|
||||||
assert(bmmTeamMatch !== null, 'config.toml has [modules.bmm] section');
|
assert(globalContent.includes('user_name = "TestUser"'), 'user_name lands in ~/.bmad/config.user.toml');
|
||||||
if (bmmTeamMatch) {
|
assert(globalContent.includes('communication_language = "Spanish"'), 'communication_language lands in ~/.bmad/config.user.toml');
|
||||||
const bmmTeamBlock = bmmTeamMatch[0];
|
|
||||||
assert(bmmTeamBlock.includes('planning_artifacts'), 'bmm-owned team-scope key (planning_artifacts) lands under [modules.bmm]');
|
|
||||||
assert(!bmmTeamBlock.includes('project_name'), 'project_name stripped from [modules.bmm] (now a core key, #2279)');
|
|
||||||
assert(!bmmTeamBlock.includes('stale-bmm-copy'), 'stale bmm-copy of project_name not leaked into config.toml');
|
|
||||||
assert(!bmmTeamBlock.includes('user_name'), 'user_name stripped from [modules.bmm] (core-key pollution)');
|
|
||||||
assert(!bmmTeamBlock.includes('communication_language'), 'communication_language stripped from [modules.bmm]');
|
|
||||||
assert(!bmmTeamBlock.includes('user_skill_level'), 'user_skill_level (scope: user) absent from [modules.bmm] in config.toml');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// [modules.bmm] — bmm answers in this fixture are ALL defaults (the test
|
||||||
|
// intentionally seeds the same values module.yaml defaults to). With
|
||||||
|
// Task F, an all-defaults section is omitted entirely.
|
||||||
|
// user_skill_level = "expert" IS a delta from default "intermediate"
|
||||||
|
// → that one key remains in [modules.bmm] in config.user.toml.
|
||||||
|
assert(!teamContent.includes('[modules.bmm]'), 'config.toml has NO [modules.bmm] section when all values equal defaults (Task F)');
|
||||||
const bmmUserMatch = userContent.match(/\[modules\.bmm\][\s\S]*?(?=\n\[|$)/);
|
const bmmUserMatch = userContent.match(/\[modules\.bmm\][\s\S]*?(?=\n\[|$)/);
|
||||||
assert(bmmUserMatch !== null, 'config.user.toml has [modules.bmm] section');
|
assert(bmmUserMatch !== null, 'config.user.toml has [modules.bmm] section (carries the user_skill_level delta)');
|
||||||
if (bmmUserMatch) {
|
if (bmmUserMatch) {
|
||||||
assert(bmmUserMatch[0].includes('user_skill_level = "expert"'), 'user_skill_level lands in config.user.toml [modules.bmm]');
|
assert(bmmUserMatch[0].includes('user_skill_level = "expert"'), 'user_skill_level delta lands in config.user.toml');
|
||||||
}
|
}
|
||||||
|
|
||||||
// [modules.external-mod] — unknown schema, falls through as team; core keys still stripped
|
// [modules.external-mod] — unknown schema, no defaults to compare. Falls
|
||||||
|
// through as team; core-key pollution still stripped.
|
||||||
const extMatch = teamContent.match(/\[modules\.external-mod\][\s\S]*?(?=\n\[|$)/);
|
const extMatch = teamContent.match(/\[modules\.external-mod\][\s\S]*?(?=\n\[|$)/);
|
||||||
assert(extMatch !== null, 'Unknown-schema module survives with its own [modules.*] section');
|
assert(extMatch !== null, 'Unknown-schema module survives with its own [modules.*] section');
|
||||||
if (extMatch) {
|
if (extMatch) {
|
||||||
|
|
@ -1845,20 +1862,54 @@ async function runTests() {
|
||||||
assert(!extBlock.includes('communication_language'), 'All core-key pollution stripped from unknown-schema module');
|
assert(!extBlock.includes('communication_language'), 'All core-key pollution stripped from unknown-schema module');
|
||||||
}
|
}
|
||||||
|
|
||||||
// [agents.*] — agent roster from bmm module.yaml baked into config.toml (team-only)
|
// [agents.*] — Task F: NEVER emitted to config.toml. Roster lives in
|
||||||
assert(teamContent.includes('[agents.bmad-agent-analyst]'), 'config.toml has [agents.bmad-agent-analyst] table');
|
// _bmad/{module}/module.toml floor (written by writeModuleTomls).
|
||||||
assert(teamContent.includes('[agents.bmad-agent-dev]'), 'config.toml has [agents.bmad-agent-dev] table');
|
assert(!teamContent.includes('[agents.'), 'config.toml has NO [agents.*] sections (Task F)');
|
||||||
assert(teamContent.includes('module = "bmm"'), 'Agent entry serializes module field');
|
|
||||||
assert(teamContent.includes('team = "software-development"'), 'Agent entry serializes team field');
|
|
||||||
assert(teamContent.includes('name = "Mary"'), 'Agent entry serializes name');
|
|
||||||
assert(teamContent.includes('icon = "📊"'), 'Agent entry serializes icon');
|
|
||||||
assert(!userContent.includes('[agents.'), '[agents.*] tables are never written to config.user.toml');
|
assert(!userContent.includes('[agents.'), '[agents.*] tables are never written to config.user.toml');
|
||||||
|
|
||||||
// Header comments present on both files
|
// Header comments present on both project files
|
||||||
assert(teamContent.includes('Installer-managed. Regenerated on every install'), 'config.toml has installer-managed header');
|
assert(teamContent.includes('Installer-managed. Regenerated on every install'), 'config.toml has installer-managed header');
|
||||||
assert(userContent.includes('Holds install answers scoped to YOU personally.'), 'config.user.toml header clarifies user scope');
|
assert(userContent.includes('Holds install answers scoped to YOU personally.'), 'config.user.toml header clarifies user scope');
|
||||||
|
assert(globalContent.includes('Global personal BMad config'), '~/.bmad/config.user.toml has global-personal header');
|
||||||
|
|
||||||
|
// writeGlobalUserCore must preserve hand-edits in shapes the old
|
||||||
|
// round-trip parser silently dropped — arrays, single-quoted strings,
|
||||||
|
// dotted/quoted keys, \uXXXX escapes, custom sections.
|
||||||
|
const handEdited = [
|
||||||
|
'# user-authored — should survive installer rewrites',
|
||||||
|
'[core]',
|
||||||
|
'user_name = "WillBeOverwritten"',
|
||||||
|
'nickname = "blank-on-purpose"',
|
||||||
|
'',
|
||||||
|
'[custom_section]',
|
||||||
|
"literal = 'single-quoted string'",
|
||||||
|
'tags = ["a", "b", "c"]',
|
||||||
|
String.raw`unicode = "Märy"`,
|
||||||
|
'"weird.key" = "quoted-dotted key"',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
await fs.writeFile(globalCorePath, handEdited, 'utf8');
|
||||||
|
await generator35.writeGlobalUserCore({ core: { user_name: 'Updated', communication_language: 'Italian' } });
|
||||||
|
const afterReplay = await fs.readFile(globalCorePath, 'utf8');
|
||||||
|
assert(afterReplay.includes('user_name = "Updated"'), 'writeGlobalUserCore updates the key we own');
|
||||||
|
assert(afterReplay.includes('communication_language = "Italian"'), 'writeGlobalUserCore writes new scope:user key');
|
||||||
|
assert(afterReplay.includes("literal = 'single-quoted string'"), 'writeGlobalUserCore preserves single-quoted string values');
|
||||||
|
assert(afterReplay.includes('tags = ["a", "b", "c"]'), 'writeGlobalUserCore preserves array values');
|
||||||
|
assert(afterReplay.includes(String.raw`unicode = "Märy"`), String.raw`writeGlobalUserCore preserves \uXXXX escapes verbatim`);
|
||||||
|
assert(afterReplay.includes('"weird.key" = "quoted-dotted key"'), 'writeGlobalUserCore preserves quoted/dotted keys');
|
||||||
|
assert(afterReplay.includes('[custom_section]'), 'writeGlobalUserCore preserves user-authored sections');
|
||||||
|
assert(
|
||||||
|
afterReplay.includes('nickname = "blank-on-purpose"'),
|
||||||
|
'writeGlobalUserCore preserves hand-written keys outside the installer schema',
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
await fs.remove(tempBmadDir35).catch(() => {});
|
await fs.remove(tempBmadDir35).catch(() => {});
|
||||||
|
await fs.remove(tempGlobalDir35).catch(() => {});
|
||||||
|
if (priorBmadHome35 === undefined) {
|
||||||
|
delete process.env.BMAD_HOME;
|
||||||
|
} else {
|
||||||
|
process.env.BMAD_HOME = priorBmadHome35;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1901,66 +1952,56 @@ async function runTests() {
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Test Suite 37: Agent Preservation for Non-Contributing Modules
|
// Test Suite 37: Agent roster routes through module.toml, not config.toml
|
||||||
// ============================================================
|
// ============================================================
|
||||||
console.log(`${colors.yellow}Test Suite 37: Agent Preservation for Non-Contributing Modules${colors.reset}\n`);
|
console.log(`${colors.yellow}Test Suite 37: Agent roster routes through module.toml${colors.reset}\n`);
|
||||||
|
|
||||||
{
|
{
|
||||||
// Scenario: quickUpdate preserves a module whose source isn't available
|
// Phase 1 (Task F): writeCentralConfig no longer emits [agents.*] blocks.
|
||||||
// (e.g. external/marketplace). Its module.yaml isn't read, so its agents
|
// The roster lives in _bmad/{module}/module.toml (the resolver floor).
|
||||||
// aren't in this.agents. writeCentralConfig must read the prior config.toml
|
// This suite verifies the relocation — same data, different home.
|
||||||
// and keep those [agents.*] blocks so the roster doesn't silently shrink.
|
//
|
||||||
const tempBmadDir37 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-agent-preserve-'));
|
// Preservation across reinstalls (the OLD concern of this suite) is now
|
||||||
|
// a non-issue: writeModuleTomls always reads the source module.yaml at
|
||||||
|
// install time and rewrites module.toml from scratch. There's no "stale
|
||||||
|
// entry" to preserve because the file gets fully regenerated.
|
||||||
|
const tempBmadDir37 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-agents-floor-'));
|
||||||
|
const tempGlobalDir37 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-global-37-'));
|
||||||
|
const priorBmadHome37 = process.env.BMAD_HOME;
|
||||||
|
process.env.BMAD_HOME = tempGlobalDir37;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Seed a prior config.toml with an agent from an external module
|
// Set up bmm/ directory so writeModuleTomls has a place to write
|
||||||
const priorToml = [
|
await fs.ensureDir(path.join(tempBmadDir37, 'bmm'));
|
||||||
'# prior',
|
|
||||||
'',
|
|
||||||
'[agents.bmad-agent-analyst]',
|
|
||||||
'module = "bmm"',
|
|
||||||
'team = "bmm"',
|
|
||||||
'name = "Stale Mary"',
|
|
||||||
'',
|
|
||||||
'[agents.external-hero]',
|
|
||||||
'module = "external-mod"',
|
|
||||||
'team = "external-mod"',
|
|
||||||
'name = "Hero"',
|
|
||||||
'title = "External Agent"',
|
|
||||||
'icon = "🦸"',
|
|
||||||
'description = "Ships with the marketplace module."',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
await fs.writeFile(path.join(tempBmadDir37, 'config.toml'), priorToml);
|
|
||||||
|
|
||||||
const generator37 = new ManifestGenerator();
|
const generator37 = new ManifestGenerator();
|
||||||
generator37.bmadDir = tempBmadDir37;
|
generator37.bmadDir = tempBmadDir37;
|
||||||
generator37.bmadFolderName = path.basename(tempBmadDir37);
|
generator37.bmadFolderName = path.basename(tempBmadDir37);
|
||||||
generator37.updatedModules = ['core', 'bmm', 'external-mod'];
|
generator37.updatedModules = ['core', 'bmm'];
|
||||||
|
|
||||||
// bmm source is available; external-mod is not — it's a preserved module
|
|
||||||
await generator37.collectAgentsFromModuleYaml();
|
await generator37.collectAgentsFromModuleYaml();
|
||||||
const freshModules = new Set(generator37.agents.map((a) => a.module));
|
await generator37.writeCentralConfig(tempBmadDir37, { core: {}, bmm: {} });
|
||||||
assert(freshModules.has('bmm'), 'bmm contributes fresh agents from src module.yaml');
|
await generator37.writeModuleTomls(tempBmadDir37);
|
||||||
assert(!freshModules.has('external-mod'), 'external-mod source is unavailable (preserved-module scenario)');
|
|
||||||
|
|
||||||
await generator37.writeCentralConfig(tempBmadDir37, { core: {}, bmm: {}, 'external-mod': {} });
|
|
||||||
|
|
||||||
const teamContent = await fs.readFile(path.join(tempBmadDir37, 'config.toml'), 'utf8');
|
const teamContent = await fs.readFile(path.join(tempBmadDir37, 'config.toml'), 'utf8');
|
||||||
|
const bmmTomlContent = await fs.readFile(path.join(tempBmadDir37, 'bmm', 'module.toml'), 'utf8');
|
||||||
|
|
||||||
assert(
|
// Task F: config.toml carries no [agents.*] sections
|
||||||
teamContent.includes('[agents.external-hero]'),
|
assert(!teamContent.includes('[agents.'), 'config.toml has no [agents.*] sections (Task F)');
|
||||||
'Preserved [agents.external-hero] block survives rewrite even though external-mod source was unavailable',
|
|
||||||
);
|
|
||||||
assert(teamContent.includes('Ships with the marketplace module.'), 'Preserved block keeps its original description');
|
|
||||||
assert(teamContent.includes('module = "external-mod"'), 'Preserved block keeps its module field');
|
|
||||||
|
|
||||||
// Freshly collected agents win over stale entries with the same code
|
// The roster lives in module.toml instead
|
||||||
const maryMatches = teamContent.match(/\[agents\.bmad-agent-analyst\]/g) || [];
|
assert(bmmTomlContent.includes('[agents.bmad-agent-analyst]'), 'bmm/module.toml carries [agents.bmad-agent-analyst]');
|
||||||
assert(maryMatches.length === 1, 'bmad-agent-analyst emitted exactly once (fresh wins; stale not duplicated)');
|
assert(bmmTomlContent.includes('[agents.bmad-agent-dev]'), 'bmm/module.toml carries [agents.bmad-agent-dev]');
|
||||||
assert(!teamContent.includes('Stale Mary'), 'Stale name from prior config.toml is discarded when fresh module.yaml is read');
|
assert(bmmTomlContent.includes('module = "bmm"'), 'Agent block in module.toml has module field');
|
||||||
|
assert(bmmTomlContent.includes('name = "Mary"'), 'Agent block in module.toml has name');
|
||||||
} finally {
|
} finally {
|
||||||
await fs.remove(tempBmadDir37).catch(() => {});
|
await fs.remove(tempBmadDir37).catch(() => {});
|
||||||
|
await fs.remove(tempGlobalDir37).catch(() => {});
|
||||||
|
if (priorBmadHome37 === undefined) {
|
||||||
|
delete process.env.BMAD_HOME;
|
||||||
|
} else {
|
||||||
|
process.env.BMAD_HOME = priorBmadHome37;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2041,17 +2082,46 @@ async function runTests() {
|
||||||
assert(byCode.get('bmad-fake-ext-agent-one').module === 'fake-ext', 'agent.module matches the owning external module name');
|
assert(byCode.get('bmad-fake-ext-agent-one').module === 'fake-ext', 'agent.module matches the owning external module name');
|
||||||
assert(byCode.get('bmad-fake-ext-agent-one').team === 'fake', 'explicit team from module.yaml is preserved');
|
assert(byCode.get('bmad-fake-ext-agent-one').team === 'fake', 'explicit team from module.yaml is preserved');
|
||||||
|
|
||||||
await generator38.writeCentralConfig(tempBmadDir38, {
|
// Set up per-module dirs so writeModuleTomls has somewhere to write
|
||||||
core: {},
|
await fs.ensureDir(path.join(tempBmadDir38, 'fake-ext'));
|
||||||
bmm: {},
|
await fs.ensureDir(path.join(tempBmadDir38, 'fake-skills'));
|
||||||
'fake-ext': {},
|
|
||||||
'fake-skills': {},
|
|
||||||
});
|
|
||||||
|
|
||||||
const teamContent = await fs.readFile(path.join(tempBmadDir38, 'config.toml'), 'utf8');
|
// Isolate BMAD_HOME for the global-config write step
|
||||||
assert(teamContent.includes('[agents.bmad-fake-ext-agent-one]'), 'external-module agents land in config.toml [agents.*] section');
|
const tempGlobalDir38 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-global-38-'));
|
||||||
assert(teamContent.includes('[agents.bmad-fake-skills-agent]'), 'skills-layout external module agents also land in config.toml');
|
const priorBmadHome38 = process.env.BMAD_HOME;
|
||||||
assert(teamContent.includes('First fake external agent.'), 'agent description from external module.yaml is written');
|
process.env.BMAD_HOME = tempGlobalDir38;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await generator38.writeCentralConfig(tempBmadDir38, {
|
||||||
|
core: {},
|
||||||
|
bmm: {},
|
||||||
|
'fake-ext': {},
|
||||||
|
'fake-skills': {},
|
||||||
|
});
|
||||||
|
await generator38.writeModuleTomls(tempBmadDir38);
|
||||||
|
|
||||||
|
const teamContent = await fs.readFile(path.join(tempBmadDir38, 'config.toml'), 'utf8');
|
||||||
|
// Task F: agents NEVER land in config.toml anymore
|
||||||
|
assert(!teamContent.includes('[agents.'), 'External module agents are NOT in config.toml (Task F)');
|
||||||
|
|
||||||
|
// Instead, each external module gets its own _bmad/{module}/module.toml floor
|
||||||
|
const fakeExtToml = await fs.readFile(path.join(tempBmadDir38, 'fake-ext', 'module.toml'), 'utf8');
|
||||||
|
const fakeSkillsToml = await fs.readFile(path.join(tempBmadDir38, 'fake-skills', 'module.toml'), 'utf8');
|
||||||
|
|
||||||
|
assert(fakeExtToml.includes('[agents.bmad-fake-ext-agent-one]'), 'external-module agents land in their own module.toml floor');
|
||||||
|
assert(
|
||||||
|
fakeSkillsToml.includes('[agents.bmad-fake-skills-agent]'),
|
||||||
|
'skills-layout external module agents land in their own module.toml floor',
|
||||||
|
);
|
||||||
|
assert(fakeExtToml.includes('First fake external agent.'), 'agent description from external module.yaml lands in module.toml');
|
||||||
|
} finally {
|
||||||
|
await fs.remove(tempGlobalDir38).catch(() => {});
|
||||||
|
if (priorBmadHome38 === undefined) {
|
||||||
|
delete process.env.BMAD_HOME;
|
||||||
|
} else {
|
||||||
|
process.env.BMAD_HOME = priorBmadHome38;
|
||||||
|
}
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (priorCacheEnv === undefined) {
|
if (priorCacheEnv === undefined) {
|
||||||
delete process.env.BMAD_EXTERNAL_MODULES_CACHE;
|
delete process.env.BMAD_EXTERNAL_MODULES_CACHE;
|
||||||
|
|
@ -3023,6 +3093,12 @@ async function runTests() {
|
||||||
// Test Suite 44: --set <module>.<key>=<value> CLI overrides (#1663)
|
// Test Suite 44: --set <module>.<key>=<value> CLI overrides (#1663)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
console.log(`${colors.yellow}Test Suite 44: --set CLI overrides${colors.reset}\n`);
|
console.log(`${colors.yellow}Test Suite 44: --set CLI overrides${colors.reset}\n`);
|
||||||
|
// Isolate $BMAD_HOME for the whole suite — applySetOverrides now consults
|
||||||
|
// ~/.bmad/config.user.toml for routing decisions, and we don't want tests
|
||||||
|
// touching the developer's actual global identity.
|
||||||
|
const tempGlobalDir44 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-applyset-global-'));
|
||||||
|
const priorBmadHome44 = process.env.BMAD_HOME;
|
||||||
|
process.env.BMAD_HOME = tempGlobalDir44;
|
||||||
try {
|
try {
|
||||||
const { parseSetEntry, parseSetEntries, applySetOverrides, upsertTomlKey, tomlString } = require('../tools/installer/set-overrides');
|
const { parseSetEntry, parseSetEntries, applySetOverrides, upsertTomlKey, tomlString } = require('../tools/installer/set-overrides');
|
||||||
const { discoverOfficialModuleYamls, formatOptionsList } = require('../tools/installer/list-options');
|
const { discoverOfficialModuleYamls, formatOptionsList } = require('../tools/installer/list-options');
|
||||||
|
|
@ -3081,6 +3157,17 @@ async function runTests() {
|
||||||
assert(tomlString(String.raw`back\slash`) === String.raw`"back\\slash"`, 'tomlString escapes backslashes');
|
assert(tomlString(String.raw`back\slash`) === String.raw`"back\\slash"`, 'tomlString escapes backslashes');
|
||||||
assert(tomlString('line1\nline2') === String.raw`"line1\nline2"`, 'tomlString escapes newlines');
|
assert(tomlString('line1\nline2') === String.raw`"line1\nline2"`, 'tomlString escapes newlines');
|
||||||
|
|
||||||
|
// ---- tomlString: type inference (--set bmm.workers=4 must be int) ----
|
||||||
|
assert(tomlString('true') === 'true', 'tomlString emits bare bool for "true"');
|
||||||
|
assert(tomlString('false') === 'false', 'tomlString emits bare bool for "false"');
|
||||||
|
assert(tomlString('4') === '4', 'tomlString emits bare integer for digit string');
|
||||||
|
assert(tomlString('-17') === '-17', 'tomlString emits bare integer for negative');
|
||||||
|
assert(tomlString('3.14') === '3.14', 'tomlString emits bare float for decimal');
|
||||||
|
assert(tomlString('-0.5') === '-0.5', 'tomlString emits bare float for negative decimal');
|
||||||
|
assert(tomlString('1e10') === '"1e10"', 'tomlString quotes scientific notation (not in inferred set)');
|
||||||
|
assert(tomlString('4.') === '"4."', 'tomlString quotes incomplete float (preserves as string)');
|
||||||
|
assert(tomlString('"true"') === String.raw`"\"true\""`, 'tomlString preserves explicitly-quoted "true" as string');
|
||||||
|
|
||||||
// ---- upsertTomlKey: insert into existing section ---------------------
|
// ---- upsertTomlKey: insert into existing section ---------------------
|
||||||
{
|
{
|
||||||
const before = `[core]\nuser_name = "Brian"\n\n[modules.bmm]\nproject_knowledge = "{project-root}/docs"\n`;
|
const before = `[core]\nuser_name = "Brian"\n\n[modules.bmm]\nproject_knowledge = "{project-root}/docs"\n`;
|
||||||
|
|
@ -3122,6 +3209,26 @@ async function runTests() {
|
||||||
assert(!withoutTrailing.endsWith('\n'), 'upsertTomlKey preserves absence of trailing newline');
|
assert(!withoutTrailing.endsWith('\n'), 'upsertTomlKey preserves absence of trailing newline');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- upsertTomlKey: `#` inside string value is NOT a comment ---------
|
||||||
|
// Per TOML spec, basic strings may contain unescaped `#`. The previous
|
||||||
|
// /\s+#/ scanner truncated such values, producing malformed TOML.
|
||||||
|
{
|
||||||
|
const before = `[core]\nproject_name = "hello # world"\n`;
|
||||||
|
const after = upsertTomlKey(before, '[core]', 'project_name', '"updated # value"');
|
||||||
|
assert(after.includes('project_name = "updated # value"'), 'upsertTomlKey writes value containing # intact');
|
||||||
|
assert(!after.includes('# world'), 'upsertTomlKey does not preserve a fake comment that lived inside the old string');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- upsertTomlKey: section header with inline comment is found -----
|
||||||
|
{
|
||||||
|
const before = `[core] # personal identity\nuser_name = "old"\n`;
|
||||||
|
const after = upsertTomlKey(before, '[core]', 'user_name', '"Brian"');
|
||||||
|
assert(after.includes('user_name = "Brian"'), 'upsertTomlKey updates key under header with inline comment');
|
||||||
|
// Should not have appended a duplicate [core] block at EOF.
|
||||||
|
const headerOccurrences = (after.match(/^\[core]/gm) || []).length;
|
||||||
|
assert(headerOccurrences === 1, `upsertTomlKey reuses existing [core] header (got ${headerOccurrences} headers)`);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- applySetOverrides happy path ------------------------------------
|
// ---- applySetOverrides happy path ------------------------------------
|
||||||
{
|
{
|
||||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-applyset-'));
|
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-applyset-'));
|
||||||
|
|
@ -3187,15 +3294,24 @@ async function runTests() {
|
||||||
await fs.writeFile(path.join(bmadDir, 'config.toml'), '[core]\nuser_name = "Brian"\n', 'utf8');
|
await fs.writeFile(path.join(bmadDir, 'config.toml'), '[core]\nuser_name = "Brian"\n', 'utf8');
|
||||||
await fs.ensureDir(path.join(bmadDir, 'core'));
|
await fs.ensureDir(path.join(bmadDir, 'core'));
|
||||||
await fs.writeFile(path.join(bmadDir, 'core', 'config.yaml'), 'user_name: Brian\n', 'utf8');
|
await fs.writeFile(path.join(bmadDir, 'core', 'config.yaml'), 'user_name: Brian\n', 'utf8');
|
||||||
// Override targets a key only in team config; routes to team. user.toml
|
// Override targets a core scope:user key. Per Task D's 3-tier routing,
|
||||||
// never gets created in this case (correct — no user-scope writes).
|
// core.user_name lands in ~/.bmad/config.user.toml regardless of whether
|
||||||
|
// project user.toml exists — that's where the resolver picks it up.
|
||||||
|
// Project files (team and user) are left untouched.
|
||||||
await applySetOverrides({ core: { user_name: 'Updated' } }, bmadDir);
|
await applySetOverrides({ core: { user_name: 'Updated' } }, bmadDir);
|
||||||
const team = await fs.readFile(path.join(bmadDir, 'config.toml'), 'utf8');
|
const team = await fs.readFile(path.join(bmadDir, 'config.toml'), 'utf8');
|
||||||
assert(team.includes('user_name = "Updated"'), 'applySetOverrides updates team key when user.toml is absent');
|
const globalContent = await fs.readFile(path.join(tempGlobalDir44, 'config.user.toml'), 'utf8');
|
||||||
|
assert(
|
||||||
|
globalContent.includes('user_name = "Updated"'),
|
||||||
|
'applySetOverrides routes core.user_name to global when project user.toml is absent',
|
||||||
|
);
|
||||||
|
assert(!team.includes('user_name = "Updated"'), 'applySetOverrides does not write core scope:user keys to project team config');
|
||||||
assert(
|
assert(
|
||||||
!(await fs.pathExists(path.join(bmadDir, 'config.user.toml'))),
|
!(await fs.pathExists(path.join(bmadDir, 'config.user.toml'))),
|
||||||
'applySetOverrides does not create config.user.toml unnecessarily',
|
'applySetOverrides does not create project config.user.toml for scope:user core keys',
|
||||||
);
|
);
|
||||||
|
// Reset global file for the next test block.
|
||||||
|
await fs.remove(path.join(tempGlobalDir44, 'config.user.toml')).catch(() => {});
|
||||||
await fs.remove(tmp).catch(() => {});
|
await fs.remove(tmp).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3209,11 +3325,17 @@ async function runTests() {
|
||||||
await fs.writeFile(path.join(bmadDir, 'core', 'config.yaml'), 'user_name: Brian\n', 'utf8');
|
await fs.writeFile(path.join(bmadDir, 'core', 'config.yaml'), 'user_name: Brian\n', 'utf8');
|
||||||
// bmm is not installed (no `_bmad/bmm/config.yaml`). The override for
|
// bmm is not installed (no `_bmad/bmm/config.yaml`). The override for
|
||||||
// bmm should be silently skipped, no `[modules.bmm]` section created.
|
// bmm should be silently skipped, no `[modules.bmm]` section created.
|
||||||
|
// core.user_name is scope:user → routed to global (~/.bmad).
|
||||||
const applied = await applySetOverrides({ bmm: { foo: 'bar' }, core: { user_name: 'Updated' } }, bmadDir);
|
const applied = await applySetOverrides({ bmm: { foo: 'bar' }, core: { user_name: 'Updated' } }, bmadDir);
|
||||||
const team = await fs.readFile(path.join(bmadDir, 'config.toml'), 'utf8');
|
const team = await fs.readFile(path.join(bmadDir, 'config.toml'), 'utf8');
|
||||||
|
const globalContent = await fs.readFile(path.join(tempGlobalDir44, 'config.user.toml'), 'utf8');
|
||||||
assert(!team.includes('[modules.bmm]'), 'applySetOverrides does NOT create section for uninstalled module');
|
assert(!team.includes('[modules.bmm]'), 'applySetOverrides does NOT create section for uninstalled module');
|
||||||
assert(team.includes('user_name = "Updated"'), 'applySetOverrides still applies overrides for installed modules');
|
assert(
|
||||||
|
globalContent.includes('user_name = "Updated"'),
|
||||||
|
'applySetOverrides still applies overrides for installed modules (routed to global for scope:user core)',
|
||||||
|
);
|
||||||
assert(applied.length === 1 && applied[0].module === 'core', 'applySetOverrides reports only the installed-module entries');
|
assert(applied.length === 1 && applied[0].module === 'core', 'applySetOverrides reports only the installed-module entries');
|
||||||
|
await fs.remove(path.join(tempGlobalDir44, 'config.user.toml')).catch(() => {});
|
||||||
await fs.remove(tmp).catch(() => {});
|
await fs.remove(tmp).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3269,6 +3391,447 @@ async function runTests() {
|
||||||
console.log(`${colors.red}Test Suite 44 setup failed: ${error.message}${colors.reset}`);
|
console.log(`${colors.red}Test Suite 44 setup failed: ${error.message}${colors.reset}`);
|
||||||
console.log(error.stack);
|
console.log(error.stack);
|
||||||
failed++;
|
failed++;
|
||||||
|
} finally {
|
||||||
|
if (priorBmadHome44 === undefined) {
|
||||||
|
delete process.env.BMAD_HOME;
|
||||||
|
} else {
|
||||||
|
process.env.BMAD_HOME = priorBmadHome44;
|
||||||
|
}
|
||||||
|
await fs.remove(tempGlobalDir44).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Test Suite 45: Per-module module.toml floor (writeModuleTomls)
|
||||||
|
// ============================================================
|
||||||
|
console.log(`${colors.yellow}Test Suite 45: Per-module module.toml floor${colors.reset}\n`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tempBmadDir45 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-module-toml-'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Real bmm src tree provides module.yaml. Installer expects each
|
||||||
|
// installed module directory to exist before writeModuleTomls runs.
|
||||||
|
await fs.ensureDir(path.join(tempBmadDir45, 'bmm'));
|
||||||
|
await fs.ensureDir(path.join(tempBmadDir45, 'core'));
|
||||||
|
|
||||||
|
const generator45 = new ManifestGenerator();
|
||||||
|
generator45.bmadDir = tempBmadDir45;
|
||||||
|
generator45.bmadFolderName = path.basename(tempBmadDir45);
|
||||||
|
generator45.updatedModules = ['core', 'bmm'];
|
||||||
|
|
||||||
|
// Collect agents (needed for [agents.X] blocks per module)
|
||||||
|
await generator45.collectAgentsFromModuleYaml();
|
||||||
|
assert(generator45.agents.length >= 6, 'agents collected before writeModuleTomls');
|
||||||
|
|
||||||
|
const written = await generator45.writeModuleTomls(tempBmadDir45);
|
||||||
|
assert(Array.isArray(written), 'writeModuleTomls returns array of written paths');
|
||||||
|
assert(written.length > 0, 'writeModuleTomls writes at least one module.toml');
|
||||||
|
|
||||||
|
const bmmTomlPath = path.join(tempBmadDir45, 'bmm', 'module.toml');
|
||||||
|
assert(await fs.pathExists(bmmTomlPath), 'bmm/module.toml is written to disk');
|
||||||
|
|
||||||
|
const bmmContent = await fs.readFile(bmmTomlPath, 'utf8');
|
||||||
|
|
||||||
|
// Header comment present
|
||||||
|
assert(bmmContent.includes('Module-shipped defaults'), 'module.toml has header comment');
|
||||||
|
assert(bmmContent.includes('Build artifact'), 'module.toml warns against hand-editing');
|
||||||
|
|
||||||
|
// [modules.bmm] section with defaults from module.yaml
|
||||||
|
assert(bmmContent.includes('[modules.bmm]'), 'bmm/module.toml has [modules.bmm] section');
|
||||||
|
assert(/planning_artifacts\s*=\s*"[^"]+"/.test(bmmContent), '[modules.bmm] carries planning_artifacts default');
|
||||||
|
// {project-root} preserved literal (runtime substitution); {output_folder}
|
||||||
|
// resolved at install time against core's default output_folder. Final
|
||||||
|
// shape matches the legacy config.yaml convention.
|
||||||
|
assert(
|
||||||
|
bmmContent.includes('{project-root}/_bmad-output/planning-artifacts'),
|
||||||
|
'Cross-key placeholders resolved at install time ({output_folder} → "_bmad-output"); {project-root} preserved',
|
||||||
|
);
|
||||||
|
assert(!bmmContent.includes('{output_folder}'), '{output_folder} placeholder is NOT left literal — resolved against module defaults');
|
||||||
|
|
||||||
|
// [agents.X] blocks for module-owned agents
|
||||||
|
assert(bmmContent.includes('[agents.bmad-agent-analyst]'), 'bmm/module.toml has [agents.bmad-agent-analyst]');
|
||||||
|
assert(bmmContent.includes('[agents.bmad-agent-dev]'), 'bmm/module.toml has [agents.bmad-agent-dev]');
|
||||||
|
assert(bmmContent.includes('module = "bmm"'), 'Agent block carries module field');
|
||||||
|
assert(bmmContent.includes('name = "Mary"'), 'Agent block carries name');
|
||||||
|
assert(bmmContent.includes('icon = "📊"'), 'Agent block carries emoji icon');
|
||||||
|
|
||||||
|
// module.toml is SCOPED to one module — bmm should not carry core's agents
|
||||||
|
// (core/module.yaml ships no agents today, but the principle stands)
|
||||||
|
const coreTomlPath = path.join(tempBmadDir45, 'core', 'module.toml');
|
||||||
|
assert(await fs.pathExists(coreTomlPath), 'core/module.toml is written even without agents');
|
||||||
|
|
||||||
|
const coreContent = await fs.readFile(coreTomlPath, 'utf8');
|
||||||
|
// bmm agents must NOT appear in core/module.toml
|
||||||
|
assert(!coreContent.includes('[agents.bmad-agent-analyst]'), 'bmm-owned agents do NOT leak into core/module.toml');
|
||||||
|
|
||||||
|
// [core] questions (user_name, project_name, etc.) are core's defaults
|
||||||
|
// and must live at top-level [core] — resolvers + consumers read core.*
|
||||||
|
// from that namespace, not from a nested [modules.core] subtree.
|
||||||
|
assert(coreContent.includes('[core]'), 'core/module.toml has top-level [core] section');
|
||||||
|
assert(!coreContent.includes('[modules.core]'), 'core defaults do NOT live under [modules.core]');
|
||||||
|
assert(coreContent.includes('user_name = "BMad"'), 'core/module.toml carries user_name default ("BMad" from module.yaml)');
|
||||||
|
assert(coreContent.includes('document_output_language = "English"'), 'core/module.toml carries document_output_language default');
|
||||||
|
|
||||||
|
// Question UI machinery NEVER appears as a TOML key in module.toml
|
||||||
|
// (Match on line-start "key =" so we don't catch the word "prompt" inside
|
||||||
|
// a description string — e.g. "Speaks like a terminal prompt".)
|
||||||
|
assert(!/^prompt\s*=/m.test(bmmContent), 'No prompt key in module.toml');
|
||||||
|
assert(!/^scope\s*=/m.test(bmmContent), 'No scope key in module.toml');
|
||||||
|
assert(!bmmContent.includes('single-select'), 'No single-select machinery in module.toml');
|
||||||
|
} finally {
|
||||||
|
await fs.remove(tempBmadDir45).catch(() => {});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`${colors.red}Test Suite 45 setup failed: ${error.message}${colors.reset}`);
|
||||||
|
console.log(error.stack);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Test Suite 46: Phase 1 end-to-end — fresh machine + second project
|
||||||
|
// ============================================================
|
||||||
|
console.log(`${colors.yellow}Test Suite 46: Phase 1 end-to-end (fresh machine + second project)${colors.reset}\n`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tempGlobalDir46 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-e2e-global-'));
|
||||||
|
const priorBmadHome46 = process.env.BMAD_HOME;
|
||||||
|
process.env.BMAD_HOME = tempGlobalDir46;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ────────────────────────────────────────────────────────────
|
||||||
|
// Part A: Fresh-machine install, project #1
|
||||||
|
// ────────────────────────────────────────────────────────────
|
||||||
|
const project1 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-e2e-proj1-'));
|
||||||
|
const bmad1 = path.join(project1, '_bmad');
|
||||||
|
await fs.ensureDir(path.join(bmad1, 'core'));
|
||||||
|
await fs.ensureDir(path.join(bmad1, 'bmm'));
|
||||||
|
|
||||||
|
// Simulated installer output for project #1: user kept defaults for
|
||||||
|
// every core key EXCEPT project_name, which is per-project.
|
||||||
|
const project1Configs = {
|
||||||
|
core: {
|
||||||
|
user_name: 'Brian',
|
||||||
|
project_name: 'first-project',
|
||||||
|
communication_language: 'English',
|
||||||
|
document_output_language: 'English',
|
||||||
|
output_folder: '{project-root}/_bmad-output',
|
||||||
|
},
|
||||||
|
bmm: {
|
||||||
|
user_skill_level: 'intermediate',
|
||||||
|
planning_artifacts: '{project-root}/_bmad-output/planning-artifacts',
|
||||||
|
implementation_artifacts: '{project-root}/_bmad-output/implementation-artifacts',
|
||||||
|
project_knowledge: '{project-root}/docs',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const gen1 = new ManifestGenerator();
|
||||||
|
gen1.bmadDir = bmad1;
|
||||||
|
gen1.bmadFolderName = path.basename(bmad1);
|
||||||
|
gen1.updatedModules = ['core', 'bmm'];
|
||||||
|
await gen1.collectAgentsFromModuleYaml();
|
||||||
|
await gen1.writeCentralConfig(bmad1, project1Configs);
|
||||||
|
await gen1.writeModuleTomls(bmad1);
|
||||||
|
await gen1.writeGlobalUserCore(project1Configs);
|
||||||
|
|
||||||
|
// Project #1: config.toml is lean — only project_name (the one delta)
|
||||||
|
const team1 = await fs.readFile(path.join(bmad1, 'config.toml'), 'utf8');
|
||||||
|
assert(team1.includes('project_name = "first-project"'), 'P1 config.toml carries project_name delta');
|
||||||
|
assert(!team1.includes('user_name'), 'P1 config.toml has no user_name (scope:user routed to global)');
|
||||||
|
assert(!team1.includes('document_output_language'), 'P1 config.toml strips default document_output_language');
|
||||||
|
assert(!team1.includes('[agents.'), 'P1 config.toml has NO [agents.*] sections');
|
||||||
|
assert(!team1.includes('[modules.bmm]'), 'P1 config.toml has NO [modules.bmm] section (all defaults)');
|
||||||
|
|
||||||
|
// Project #1: config.user.toml is just the header
|
||||||
|
const user1 = await fs.readFile(path.join(bmad1, 'config.user.toml'), 'utf8');
|
||||||
|
assert(!user1.includes('user_name'), 'P1 config.user.toml has no user_name');
|
||||||
|
assert(!user1.includes('[modules.bmm]'), 'P1 config.user.toml has no [modules.bmm]');
|
||||||
|
|
||||||
|
// Global file populated with scope:user core values from project #1
|
||||||
|
const globalPath = path.join(tempGlobalDir46, 'config.user.toml');
|
||||||
|
assert(await fs.pathExists(globalPath), 'Global ~/.bmad/config.user.toml created on first install');
|
||||||
|
const global1 = await fs.readFile(globalPath, 'utf8');
|
||||||
|
assert(global1.includes('user_name = "Brian"'), 'Global identity carries Brian after P1');
|
||||||
|
assert(global1.includes('communication_language = "English"'), 'Global identity carries language after P1');
|
||||||
|
|
||||||
|
// module.toml floor: cross-key placeholders resolved at install time
|
||||||
|
const bmm1 = await fs.readFile(path.join(bmad1, 'bmm', 'module.toml'), 'utf8');
|
||||||
|
assert(
|
||||||
|
bmm1.includes('planning_artifacts = "{project-root}/_bmad-output/planning-artifacts"'),
|
||||||
|
'P1 bmm/module.toml has cross-key-resolved planning_artifacts',
|
||||||
|
);
|
||||||
|
assert(bmm1.includes('[agents.bmad-agent-analyst]'), 'P1 bmm/module.toml carries agent roster');
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────
|
||||||
|
// Part B: Second project install on the same machine
|
||||||
|
// - Global identity from P1 should be picked up by the OfficialModules
|
||||||
|
// loader (loadGlobalConfig) as a silent default-source for scope:user
|
||||||
|
// core questions (D) and a seed for non-user-scope ones (E).
|
||||||
|
// - Resolver chain at runtime: global.core.user_name → reaches skills
|
||||||
|
// even though P2 never wrote it locally.
|
||||||
|
// ────────────────────────────────────────────────────────────
|
||||||
|
const { OfficialModules } = require('../tools/installer/modules/official-modules');
|
||||||
|
|
||||||
|
const project2 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-e2e-proj2-'));
|
||||||
|
const bmad2 = path.join(project2, '_bmad');
|
||||||
|
await fs.ensureDir(bmad2);
|
||||||
|
|
||||||
|
const om = new OfficialModules();
|
||||||
|
// Simulate what collectAllConfigurations does on entry: load global.
|
||||||
|
om.globalConfig = await require('../tools/installer/global-config').loadGlobalConfig();
|
||||||
|
assert(om.globalConfig.merged.core, 'OfficialModules.loadGlobalConfig returns populated [core]');
|
||||||
|
assert(om.globalConfig.merged.core.user_name === 'Brian', 'Global config from P1 visible to OfficialModules on P2 install');
|
||||||
|
assert(om.globalConfig.merged.core.communication_language === 'English', 'Global identity round-trips through TOML correctly');
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────
|
||||||
|
// Part C: Python resolver sees identity via global even when P2's
|
||||||
|
// project _bmad has no [core] user_name. Validates the full layered
|
||||||
|
// resolver chain end-to-end.
|
||||||
|
// ────────────────────────────────────────────────────────────
|
||||||
|
// Write a minimal P2 install (just project_name in config.toml)
|
||||||
|
const gen2 = new ManifestGenerator();
|
||||||
|
gen2.bmadDir = bmad2;
|
||||||
|
gen2.bmadFolderName = path.basename(bmad2);
|
||||||
|
gen2.updatedModules = ['core', 'bmm'];
|
||||||
|
await fs.ensureDir(path.join(bmad2, 'core'));
|
||||||
|
await fs.ensureDir(path.join(bmad2, 'bmm'));
|
||||||
|
const project2Configs = {
|
||||||
|
core: {
|
||||||
|
user_name: 'Brian', // Reused from global silently (D) — would normally bypass write
|
||||||
|
project_name: 'second-project',
|
||||||
|
communication_language: 'English',
|
||||||
|
document_output_language: 'English',
|
||||||
|
output_folder: '{project-root}/_bmad-output',
|
||||||
|
},
|
||||||
|
bmm: {
|
||||||
|
user_skill_level: 'intermediate',
|
||||||
|
planning_artifacts: '{project-root}/_bmad-output/planning-artifacts',
|
||||||
|
implementation_artifacts: '{project-root}/_bmad-output/implementation-artifacts',
|
||||||
|
project_knowledge: '{project-root}/docs',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await gen2.collectAgentsFromModuleYaml();
|
||||||
|
await gen2.writeCentralConfig(bmad2, project2Configs);
|
||||||
|
await gen2.writeModuleTomls(bmad2);
|
||||||
|
// Note: do NOT call writeGlobalUserCore again — values would be a no-op
|
||||||
|
// merge, but in the real install they'd silently skip.
|
||||||
|
|
||||||
|
const { execSync } = require('node:child_process');
|
||||||
|
const resolverPath = path.resolve(__dirname, '..', 'src/scripts/resolve_config.py');
|
||||||
|
const resolved = JSON.parse(
|
||||||
|
execSync(
|
||||||
|
`python3 ${resolverPath} --project-root ${project2} ` +
|
||||||
|
`--key core.user_name --key core.project_name --key agents.bmad-agent-analyst.name`,
|
||||||
|
{ encoding: 'utf8', env: { ...process.env, BMAD_HOME: tempGlobalDir46 } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert(resolved['core.user_name'] === 'Brian', 'Resolver returns user_name from global layer (P2 lean config.toml omitted it)');
|
||||||
|
assert(
|
||||||
|
resolved['core.project_name'] === 'second-project',
|
||||||
|
'Resolver returns project_name from P2 config.toml (project layer beats global)',
|
||||||
|
);
|
||||||
|
assert(resolved['agents.bmad-agent-analyst.name'] === 'Mary', 'Resolver returns agent name from P2 module.toml floor');
|
||||||
|
|
||||||
|
await fs.remove(project1).catch(() => {});
|
||||||
|
await fs.remove(project2).catch(() => {});
|
||||||
|
} finally {
|
||||||
|
await fs.remove(tempGlobalDir46).catch(() => {});
|
||||||
|
if (priorBmadHome46 === undefined) {
|
||||||
|
delete process.env.BMAD_HOME;
|
||||||
|
} else {
|
||||||
|
process.env.BMAD_HOME = priorBmadHome46;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`${colors.red}Test Suite 46 setup failed: ${error.message}${colors.reset}`);
|
||||||
|
console.log(error.stack);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Test Suite 45: _cleanupSkillDirs prunes empty parent dirs (#empty-bmm-folders)
|
||||||
|
// ============================================================
|
||||||
|
console.log(`${colors.yellow}Test Suite 45: cleanup prunes empty skill-group dirs${colors.reset}\n`);
|
||||||
|
|
||||||
|
let root45;
|
||||||
|
try {
|
||||||
|
root45 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-cleanup-test-'));
|
||||||
|
const bmadDir45 = path.join(root45, '_bmad');
|
||||||
|
await fs.ensureDir(path.join(bmadDir45, '_config'));
|
||||||
|
|
||||||
|
// Two skills nested under the same grouping dir (1-analysis), plus a
|
||||||
|
// module-level file that must survive the cleanup.
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(bmadDir45, '_config', 'skill-manifest.csv'),
|
||||||
|
[
|
||||||
|
'canonicalId,name,description,module,path',
|
||||||
|
'"bmad-agent-analyst","bmad-agent-analyst","fixture","bmm","_bmad/bmm/1-analysis/bmad-agent-analyst/SKILL.md"',
|
||||||
|
'"bmad-research","bmad-research","fixture","bmm","_bmad/bmm/1-analysis/research/bmad-research/SKILL.md"',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
await fs.ensureDir(path.join(bmadDir45, 'bmm', '1-analysis', 'bmad-agent-analyst'));
|
||||||
|
await fs.writeFile(path.join(bmadDir45, 'bmm', '1-analysis', 'bmad-agent-analyst', 'SKILL.md'), 'x');
|
||||||
|
await fs.ensureDir(path.join(bmadDir45, 'bmm', '1-analysis', 'research', 'bmad-research'));
|
||||||
|
await fs.writeFile(path.join(bmadDir45, 'bmm', '1-analysis', 'research', 'bmad-research', 'SKILL.md'), 'x');
|
||||||
|
await fs.writeFile(path.join(bmadDir45, 'bmm', 'config.yaml'), 'module: bmm\n');
|
||||||
|
|
||||||
|
const installer45 = new Installer();
|
||||||
|
await installer45._cleanupSkillDirs(bmadDir45);
|
||||||
|
|
||||||
|
assert(!(await fs.pathExists(path.join(bmadDir45, 'bmm', '1-analysis'))), 'empty skill-group dir is pruned after cleanup');
|
||||||
|
assert(!(await fs.pathExists(path.join(bmadDir45, 'bmm', '1-analysis', 'research'))), 'empty nested skill-group dir is pruned');
|
||||||
|
assert(await fs.pathExists(path.join(bmadDir45, 'bmm', 'config.yaml')), 'module-level files are preserved');
|
||||||
|
assert(await fs.pathExists(bmadDir45), 'bmad root is never removed');
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`${colors.red}Test Suite 45 setup failed: ${error.message}${colors.reset}`);
|
||||||
|
console.log(error.stack);
|
||||||
|
failed++;
|
||||||
|
} finally {
|
||||||
|
if (root45) await fs.remove(root45).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Test Suite 46: Python environment check (version parsing + classification)
|
||||||
|
// ============================================================
|
||||||
|
console.log(`${colors.yellow}Test Suite 46: python-check version parsing and classification${colors.reset}\n`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { parsePythonVersion, classifyPython, detectPython } = require('../tools/installer/core/python-check');
|
||||||
|
|
||||||
|
// Version parsing
|
||||||
|
const v312 = parsePythonVersion('Python 3.12.1');
|
||||||
|
assert(v312 && v312.major === 3 && v312.minor === 12 && v312.patch === 1, 'parses "Python 3.12.1"');
|
||||||
|
const v311 = parsePythonVersion('Python 3.11.0\n');
|
||||||
|
assert(v311 && v311.raw === '3.11.0', 'parses with trailing newline');
|
||||||
|
const v2 = parsePythonVersion('\nPython 2.7.18');
|
||||||
|
assert(v2 && v2.major === 2, 'parses Python 2 output (stderr-style)');
|
||||||
|
const noPatch = parsePythonVersion('Python 3.13');
|
||||||
|
assert(noPatch && noPatch.patch === 0, 'missing patch defaults to 0');
|
||||||
|
assert(parsePythonVersion('') === null, 'empty output returns null');
|
||||||
|
assert(parsePythonVersion('command not found: python3') === null, 'non-version output returns null');
|
||||||
|
assert(parsePythonVersion(null) === null, 'null output returns null');
|
||||||
|
|
||||||
|
// Classification against feature requirements
|
||||||
|
assert(classifyPython({ major: 3, minor: 11 }) === 'full', '3.11 is full support (tomllib floor)');
|
||||||
|
assert(classifyPython({ major: 3, minor: 13 }) === 'full', '3.13 is full support');
|
||||||
|
assert(classifyPython({ major: 4, minor: 0 }) === 'full', 'hypothetical 4.0 is full support');
|
||||||
|
assert(classifyPython({ major: 3, minor: 10 }) === 'partial', '3.10 is partial (memlog yes, tomllib no)');
|
||||||
|
assert(classifyPython({ major: 3, minor: 8 }) === 'partial', '3.8 is partial (memlog floor)');
|
||||||
|
assert(classifyPython({ major: 3, minor: 7 }) === 'unsupported', '3.7 is unsupported');
|
||||||
|
assert(classifyPython({ major: 2, minor: 7 }) === 'unsupported', '2.7 is unsupported');
|
||||||
|
assert(classifyPython(null) === 'none', 'no python is none');
|
||||||
|
|
||||||
|
// Detection smoke test — must not throw, and if it finds a Python the
|
||||||
|
// result must be well-formed. (CI machines may or may not have Python.)
|
||||||
|
const detected = detectPython();
|
||||||
|
assert(
|
||||||
|
detected === null ||
|
||||||
|
(typeof detected.command === 'string' &&
|
||||||
|
typeof detected.version.raw === 'string' &&
|
||||||
|
typeof detected.isRuntimeCommand === 'boolean'),
|
||||||
|
'detectPython returns null or a well-formed result',
|
||||||
|
);
|
||||||
|
|
||||||
|
// checkPythonEnvironment branch coverage — stub detection, prompts, and
|
||||||
|
// process.exit so the assertions are deterministic regardless of the
|
||||||
|
// machine's Python. python-check resolves detectPython via module.exports
|
||||||
|
// and prompts via the shared module object, so swapping properties works.
|
||||||
|
const pythonCheck = require('../tools/installer/core/python-check');
|
||||||
|
const promptsModule = require('../tools/installer/prompts');
|
||||||
|
const real = {
|
||||||
|
detectPython: pythonCheck.detectPython,
|
||||||
|
log: promptsModule.log,
|
||||||
|
note: promptsModule.note,
|
||||||
|
select: promptsModule.select,
|
||||||
|
cancel: promptsModule.cancel,
|
||||||
|
exit: process.exit,
|
||||||
|
};
|
||||||
|
const stub = (detectResult, selectAnswer) => {
|
||||||
|
const seen = { success: [], warn: [], info: [], note: [], select: [], cancel: [], exit: [] };
|
||||||
|
pythonCheck.detectPython = () => detectResult;
|
||||||
|
promptsModule.log = {
|
||||||
|
success: async (m) => void seen.success.push(m),
|
||||||
|
warn: async (m) => void seen.warn.push(m),
|
||||||
|
info: async (m) => void seen.info.push(m),
|
||||||
|
error: async () => {},
|
||||||
|
};
|
||||||
|
promptsModule.note = async (m, t) => void seen.note.push(t || m);
|
||||||
|
promptsModule.select = async (opts) => {
|
||||||
|
seen.select.push(opts.message);
|
||||||
|
return selectAnswer;
|
||||||
|
};
|
||||||
|
promptsModule.cancel = async (m) => void seen.cancel.push(m);
|
||||||
|
process.exit = (code) => {
|
||||||
|
seen.exit.push(code);
|
||||||
|
throw new Error('__stub_exit__');
|
||||||
|
};
|
||||||
|
return seen;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const v = (major, minor, patch) => ({ major, minor, patch, raw: `${major}.${minor}.${patch}` });
|
||||||
|
|
||||||
|
// Branch: full support via the runtime command — success, no prompt.
|
||||||
|
let seen = stub({ command: 'python3', version: v(3, 12, 1), isRuntimeCommand: true }, 'continue');
|
||||||
|
let result = await pythonCheck.checkPythonEnvironment();
|
||||||
|
assert(result.status === 'full' && seen.success.length === 1, 'full support via python3 logs success');
|
||||||
|
assert(seen.select.length === 0 && seen.warn.length === 0, 'full support via python3 skips warning and ack prompt');
|
||||||
|
|
||||||
|
// Branch: modern Python found, but not as `python3` — runtime mismatch.
|
||||||
|
seen = stub({ command: 'py -3', version: v(3, 12, 0), isRuntimeCommand: false }, 'continue');
|
||||||
|
result = await pythonCheck.checkPythonEnvironment();
|
||||||
|
assert(seen.success.length === 0, 'python3-mismatch never reports full support');
|
||||||
|
assert(
|
||||||
|
seen.warn.length === 1 && seen.warn[0].includes('python3') && seen.warn[0].includes('py -3'),
|
||||||
|
'python3-mismatch warns that scripts invoke python3',
|
||||||
|
);
|
||||||
|
assert(seen.select.length === 1 && result.status === 'full', 'python3-mismatch still requires the ack prompt');
|
||||||
|
|
||||||
|
// Branch: partial support (3.8–3.10) — warn + ack, continue returns.
|
||||||
|
seen = stub({ command: 'python3', version: v(3, 9, 5), isRuntimeCommand: true }, 'continue');
|
||||||
|
result = await pythonCheck.checkPythonEnvironment();
|
||||||
|
assert(
|
||||||
|
result.status === 'partial' && seen.warn.length === 1 && seen.warn[0].includes('3.11+'),
|
||||||
|
'partial support warns about tomllib floor',
|
||||||
|
);
|
||||||
|
assert(seen.select.length === 1 && seen.exit.length === 0, 'partial support prompts and continue proceeds');
|
||||||
|
|
||||||
|
// Branch: no Python, non-interactive — warn + info, never prompts.
|
||||||
|
seen = stub(null, 'continue');
|
||||||
|
result = await pythonCheck.checkPythonEnvironment({ nonInteractive: true });
|
||||||
|
assert(result.status === 'none' && seen.warn[0].includes('No Python found'), 'non-interactive with no Python warns');
|
||||||
|
assert(seen.select.length === 0 && seen.info.length === 1, 'non-interactive skips the ack prompt and logs continuation');
|
||||||
|
|
||||||
|
// Branch: no Python, interactive, user quits — cancel message + exit 0.
|
||||||
|
seen = stub(null, 'quit');
|
||||||
|
let threw = false;
|
||||||
|
try {
|
||||||
|
await pythonCheck.checkPythonEnvironment();
|
||||||
|
} catch (error) {
|
||||||
|
threw = error.message === '__stub_exit__';
|
||||||
|
}
|
||||||
|
assert(threw && seen.exit.length === 1 && seen.exit[0] === 0, 'quit choice exits 0 (user-cancel convention)');
|
||||||
|
assert(seen.cancel.length === 1, 'quit choice shows the cancel guidance');
|
||||||
|
} finally {
|
||||||
|
pythonCheck.detectPython = real.detectPython;
|
||||||
|
promptsModule.log = real.log;
|
||||||
|
promptsModule.note = real.note;
|
||||||
|
promptsModule.select = real.select;
|
||||||
|
promptsModule.cancel = real.cancel;
|
||||||
|
process.exit = real.exit;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`${colors.red}Test Suite 46 setup failed: ${error.message}${colors.reset}`);
|
||||||
|
console.log(error.stack);
|
||||||
|
failed++;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
|
||||||
|
|
@ -419,10 +419,35 @@ class Installer {
|
||||||
const sourceDir = path.dirname(path.join(bmadDir, relativePath));
|
const sourceDir = path.dirname(path.join(bmadDir, relativePath));
|
||||||
if (await fs.pathExists(sourceDir)) {
|
if (await fs.pathExists(sourceDir)) {
|
||||||
await fs.remove(sourceDir);
|
await fs.remove(sourceDir);
|
||||||
|
await this._removeEmptyParents(path.dirname(sourceDir), bmadDir);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove now-empty parent directories left behind after skill dir cleanup.
|
||||||
|
* Walks up from dir, stopping at (and never removing) bmadDir. Best-effort:
|
||||||
|
* a directory that vanishes or fills in mid-walk just ends the walk.
|
||||||
|
* @param {string} dir - Directory to start walking up from
|
||||||
|
* @param {string} bmadDir - BMAD installation directory (boundary)
|
||||||
|
*/
|
||||||
|
async _removeEmptyParents(dir, bmadDir) {
|
||||||
|
let current = dir;
|
||||||
|
while (true) {
|
||||||
|
// Path-boundary check (not a string prefix, so siblings like _bmad2 don't match).
|
||||||
|
const rel = path.relative(bmadDir, current);
|
||||||
|
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) break;
|
||||||
|
try {
|
||||||
|
const entries = await fs.readdir(current);
|
||||||
|
if (entries.length > 0) break;
|
||||||
|
await fs.rmdir(current);
|
||||||
|
} catch {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
current = path.dirname(current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async _readSkillManifestRows(bmadDir) {
|
async _readSkillManifestRows(bmadDir) {
|
||||||
const csvPath = path.join(bmadDir, '_config', 'skill-manifest.csv');
|
const csvPath = path.join(bmadDir, '_config', 'skill-manifest.csv');
|
||||||
if (!(await fs.pathExists(csvPath))) return [];
|
if (!(await fs.pathExists(csvPath))) return [];
|
||||||
|
|
@ -630,6 +655,7 @@ class Installer {
|
||||||
/**
|
/**
|
||||||
* Sync src/scripts/* → _bmad/scripts/ so shared Python scripts
|
* Sync src/scripts/* → _bmad/scripts/ so shared Python scripts
|
||||||
* (e.g. resolve_customization.py) are available at install time.
|
* (e.g. resolve_customization.py) are available at install time.
|
||||||
|
* Excludes dev-only tests and Python caches so they don't ship to users.
|
||||||
* Wipes the destination first so files removed or renamed in source
|
* Wipes the destination first so files removed or renamed in source
|
||||||
* don't linger and get recorded as installed. Also seeds
|
* don't linger and get recorded as installed. Also seeds
|
||||||
* _bmad/custom/.gitignore on fresh installs so *.user.toml overrides
|
* _bmad/custom/.gitignore on fresh installs so *.user.toml overrides
|
||||||
|
|
@ -643,7 +669,12 @@ class Installer {
|
||||||
|
|
||||||
await fs.remove(paths.scriptsDir);
|
await fs.remove(paths.scriptsDir);
|
||||||
await fs.ensureDir(paths.scriptsDir);
|
await fs.ensureDir(paths.scriptsDir);
|
||||||
await fs.copy(srcScriptsDir, paths.scriptsDir, { overwrite: true });
|
// Ship only the runtime scripts — dev-only tests and Python caches must not land in user projects.
|
||||||
|
const isInstallable = (srcPath) => {
|
||||||
|
const base = path.basename(srcPath);
|
||||||
|
return base !== 'tests' && base !== '__pycache__' && base !== '.pytest_cache' && !base.endsWith('.pyc');
|
||||||
|
};
|
||||||
|
await fs.copy(srcScriptsDir, paths.scriptsDir, { overwrite: true, filter: isInstallable });
|
||||||
await this._trackFilesRecursive(paths.scriptsDir);
|
await this._trackFilesRecursive(paths.scriptsDir);
|
||||||
|
|
||||||
const customGitignore = path.join(paths.customDir, '.gitignore');
|
const customGitignore = path.join(paths.customDir, '.gitignore');
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ const fs = require('../fs-native');
|
||||||
const yaml = require('yaml');
|
const yaml = require('yaml');
|
||||||
const crypto = require('node:crypto');
|
const crypto = require('node:crypto');
|
||||||
const { resolveInstalledModuleYaml } = require('../project-root');
|
const { resolveInstalledModuleYaml } = require('../project-root');
|
||||||
|
const { globalUserConfigPath, loadGlobalConfig } = require('../global-config');
|
||||||
|
const { upsertTomlKey } = require('../set-overrides');
|
||||||
const prompts = require('../prompts');
|
const prompts = require('../prompts');
|
||||||
|
|
||||||
// Load package.json for version info
|
// Load package.json for version info
|
||||||
|
|
@ -82,11 +84,20 @@ class ManifestGenerator {
|
||||||
|
|
||||||
// Write manifest files and collect their paths
|
// Write manifest files and collect their paths
|
||||||
const [teamConfigPath, userConfigPath] = await this.writeCentralConfig(bmadDir, options.moduleConfigs || {});
|
const [teamConfigPath, userConfigPath] = await this.writeCentralConfig(bmadDir, options.moduleConfigs || {});
|
||||||
|
// Per-module module.toml floor — shipped defaults + agent roster, read
|
||||||
|
// by resolve_config.py as the lowest-priority layer. Independent of the
|
||||||
|
// central config.toml; remains stable across user customizations.
|
||||||
|
const moduleTomlPaths = await this.writeModuleTomls(bmadDir);
|
||||||
|
// Task D: route scope:user core answers to ~/.bmad/config.user.toml.
|
||||||
|
// Identity persists across projects on this machine, so re-installs in
|
||||||
|
// other directories don't re-prompt for the same name/language.
|
||||||
|
await this.writeGlobalUserCore(options.moduleConfigs || {});
|
||||||
const manifestFiles = [
|
const manifestFiles = [
|
||||||
await this.writeMainManifest(cfgDir),
|
await this.writeMainManifest(cfgDir),
|
||||||
await this.writeSkillManifest(cfgDir),
|
await this.writeSkillManifest(cfgDir),
|
||||||
teamConfigPath,
|
teamConfigPath,
|
||||||
userConfigPath,
|
userConfigPath,
|
||||||
|
...moduleTomlPaths,
|
||||||
await this.writeFilesManifest(cfgDir),
|
await this.writeFilesManifest(cfgDir),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -419,25 +430,47 @@ class ManifestGenerator {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Write central _bmad/config.toml with [core], [modules.<code>], [agents.<code>] tables.
|
* Write central _bmad/config.toml as a LEAN OVERRIDE FILE — only values
|
||||||
* Install-owned. Team-scope answers → config.toml; user-scope answers → config.user.toml.
|
* the user actually changed from defaults land here. Defaults flow through
|
||||||
* Both files are regenerated on every install. User overrides live in
|
* the module.toml floor (written by writeModuleTomls) and the global
|
||||||
* _bmad/custom/config.toml and _bmad/custom/config.user.toml (never touched by installer).
|
* config layer (~/.bmad/...).
|
||||||
|
*
|
||||||
|
* Specifically (Phase 1 — tasks D + F):
|
||||||
|
* - [core] team-scope: emit only keys whose value differs from BOTH the
|
||||||
|
* module.yaml default AND the global value (if any). Identity that
|
||||||
|
* equals the global wins via the resolver chain regardless.
|
||||||
|
* - [core] user-scope (user_name, communication_language): NEVER written
|
||||||
|
* here. Routed to ~/.bmad/config.user.toml via writeGlobalUserCore.
|
||||||
|
* - [modules.X]: emit only keys whose value differs from the module's
|
||||||
|
* processed default. Skip the section entirely if all keys are default.
|
||||||
|
* - [agents.X]: NEVER emitted. The roster lives in module.toml floor;
|
||||||
|
* custom agents go in _bmad/custom/config.toml (never touched by us).
|
||||||
|
*
|
||||||
|
* Install-owned: both files are regenerated on every install. User
|
||||||
|
* overrides live in _bmad/custom/config.toml and _bmad/custom/config.user.toml
|
||||||
|
* (never touched by installer).
|
||||||
|
*
|
||||||
* @returns {string[]} Paths to the written config files
|
* @returns {string[]} Paths to the written config files
|
||||||
*/
|
*/
|
||||||
async writeCentralConfig(bmadDir, moduleConfigs) {
|
async writeCentralConfig(bmadDir, moduleConfigs) {
|
||||||
const teamPath = path.join(bmadDir, 'config.toml');
|
const teamPath = path.join(bmadDir, 'config.toml');
|
||||||
const userPath = path.join(bmadDir, 'config.user.toml');
|
const userPath = path.join(bmadDir, 'config.user.toml');
|
||||||
|
|
||||||
// Load each module's source module.yaml to determine scope per prompt key.
|
// Load each module's source module.yaml to determine:
|
||||||
// Default scope is 'team' when the prompt doesn't declare one.
|
// 1. scope per prompt key (team vs user)
|
||||||
// When a module.yaml is unreadable we warn — for known official modules
|
// 2. the canonical module code (for [modules.{code}] section names)
|
||||||
// this means user-scoped keys (e.g. user_name) could mis-file into the
|
// 3. processed defaults per key (for delta detection)
|
||||||
// team config, so the operator should notice.
|
//
|
||||||
|
// Pass 1: parse every module.yaml and capture its raw shipped defaults.
|
||||||
|
// We use those — NOT the user's answered moduleConfigs — to resolve
|
||||||
|
// cross-key placeholders like `{output_folder}`. Otherwise a user override
|
||||||
|
// of output_folder would make every derived default (e.g. planning_artifacts)
|
||||||
|
// match the user's value and get stripped from config.toml as "default",
|
||||||
|
// even though module.toml's floor still carries the shipped path.
|
||||||
const scopeByModuleKey = {};
|
const scopeByModuleKey = {};
|
||||||
// Maps installer moduleName (may be full display name) → module code field
|
|
||||||
// from module.yaml, so TOML sections use [modules.<code>] not [modules.<name>].
|
|
||||||
const codeByModuleName = {};
|
const codeByModuleName = {};
|
||||||
|
const defaultsByModuleKey = {};
|
||||||
|
const parsedByModule = {};
|
||||||
for (const moduleName of this.updatedModules) {
|
for (const moduleName of this.updatedModules) {
|
||||||
const moduleYamlPath = await resolveInstalledModuleYaml(moduleName);
|
const moduleYamlPath = await resolveInstalledModuleYaml(moduleName);
|
||||||
if (!moduleYamlPath) {
|
if (!moduleYamlPath) {
|
||||||
|
|
@ -450,13 +483,8 @@ class ManifestGenerator {
|
||||||
try {
|
try {
|
||||||
const parsed = yaml.parse(await fs.readFile(moduleYamlPath, 'utf8'));
|
const parsed = yaml.parse(await fs.readFile(moduleYamlPath, 'utf8'));
|
||||||
if (!parsed || typeof parsed !== 'object') continue;
|
if (!parsed || typeof parsed !== 'object') continue;
|
||||||
|
parsedByModule[moduleName] = parsed;
|
||||||
if (parsed.code) codeByModuleName[moduleName] = parsed.code;
|
if (parsed.code) codeByModuleName[moduleName] = parsed.code;
|
||||||
scopeByModuleKey[moduleName] = {};
|
|
||||||
for (const [key, value] of Object.entries(parsed)) {
|
|
||||||
if (value && typeof value === 'object' && 'prompt' in value) {
|
|
||||||
scopeByModuleKey[moduleName][key] = value.scope === 'user' ? 'user' : 'team';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`[warn] writeCentralConfig: could not parse module.yaml for '${moduleName}' (${error.message}). ` +
|
`[warn] writeCentralConfig: could not parse module.yaml for '${moduleName}' (${error.message}). ` +
|
||||||
|
|
@ -465,6 +493,41 @@ class ManifestGenerator {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build the cross-key defaults map (same logic writeModuleTomls uses).
|
||||||
|
// Shipped defaults only — never user answers.
|
||||||
|
const crossKeyDefaults = {};
|
||||||
|
for (const parsed of Object.values(parsedByModule)) {
|
||||||
|
const raw = extractModuleDefaults(parsed);
|
||||||
|
for (const [key, value] of Object.entries(raw)) {
|
||||||
|
if (crossKeyDefaults[key] !== undefined) continue;
|
||||||
|
let stripped = value;
|
||||||
|
if (typeof stripped === 'string' && stripped.startsWith('{project-root}/')) {
|
||||||
|
stripped = stripped.slice('{project-root}/'.length);
|
||||||
|
}
|
||||||
|
crossKeyDefaults[key] = stripped;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 2: compute scopes and processed defaults using the symmetric map.
|
||||||
|
for (const [moduleName, parsed] of Object.entries(parsedByModule)) {
|
||||||
|
scopeByModuleKey[moduleName] = {};
|
||||||
|
defaultsByModuleKey[moduleName] = {};
|
||||||
|
for (const [key, value] of Object.entries(parsed)) {
|
||||||
|
if (!value || typeof value !== 'object' || !('prompt' in value)) continue;
|
||||||
|
scopeByModuleKey[moduleName][key] = value.scope === 'user' ? 'user' : 'team';
|
||||||
|
const processedDefault = computeProcessedDefault(value, crossKeyDefaults);
|
||||||
|
if (processedDefault !== undefined) {
|
||||||
|
defaultsByModuleKey[moduleName][key] = processedDefault;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the global config snapshot for [core] delta detection. If a key's
|
||||||
|
// current value equals the global value, no need to duplicate it into the
|
||||||
|
// project file — the resolver finds it globally.
|
||||||
|
const globalSnapshot = await loadGlobalConfig().catch(() => ({ merged: {} }));
|
||||||
|
const globalCore = (globalSnapshot.merged && globalSnapshot.merged.core) || {};
|
||||||
|
|
||||||
// Core keys are always known (core module.yaml is built-in). These are
|
// Core keys are always known (core module.yaml is built-in). These are
|
||||||
// the only keys allowed in [core]; they must be stripped from every
|
// the only keys allowed in [core]; they must be stripped from every
|
||||||
// non-core module bucket because legacy _bmad/{mod}/config.yaml files
|
// non-core module bucket because legacy _bmad/{mod}/config.yaml files
|
||||||
|
|
@ -494,6 +557,23 @@ class ManifestGenerator {
|
||||||
return { team, user };
|
return { team, user };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Drop entries whose value equals an already-known default. Tasks F + D
|
||||||
|
// both want config.toml to be a *delta* file — anything that matches
|
||||||
|
// either the module.yaml default or the global config gets resolved
|
||||||
|
// through the layer chain at read time, so writing it here is dead weight.
|
||||||
|
const stripDefaults = (entries, perKeyDefaults = {}, fallbackDefaults = {}) => {
|
||||||
|
const result = {};
|
||||||
|
for (const [key, value] of Object.entries(entries)) {
|
||||||
|
const moduleDefault = perKeyDefaults[key];
|
||||||
|
const fallbackDefault = fallbackDefaults[key];
|
||||||
|
const isDefault =
|
||||||
|
(moduleDefault !== undefined && deepEqualScalar(moduleDefault, value)) ||
|
||||||
|
(fallbackDefault !== undefined && deepEqualScalar(fallbackDefault, value));
|
||||||
|
if (!isDefault) result[key] = value;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
const teamHeader = [
|
const teamHeader = [
|
||||||
'# ─────────────────────────────────────────────────────────────────',
|
'# ─────────────────────────────────────────────────────────────────',
|
||||||
'# Installer-managed. Regenerated on every install — treat as read-only.',
|
'# Installer-managed. Regenerated on every install — treat as read-only.',
|
||||||
|
|
@ -526,9 +606,10 @@ class ManifestGenerator {
|
||||||
const teamLines = [...teamHeader];
|
const teamLines = [...teamHeader];
|
||||||
const userLines = [...userHeader];
|
const userLines = [...userHeader];
|
||||||
|
|
||||||
// [core] — split into team and user
|
// [core] team — emit only deltas from module.yaml default AND global value.
|
||||||
const coreConfig = moduleConfigs.core || {};
|
const coreConfig = moduleConfigs.core || {};
|
||||||
const { team: coreTeam, user: coreUser } = partition('core', coreConfig);
|
const { team: coreTeamRaw } = partition('core', coreConfig);
|
||||||
|
const coreTeam = stripDefaults(coreTeamRaw, defaultsByModuleKey.core || {}, globalCore);
|
||||||
if (Object.keys(coreTeam).length > 0) {
|
if (Object.keys(coreTeam).length > 0) {
|
||||||
teamLines.push('[core]');
|
teamLines.push('[core]');
|
||||||
for (const [key, value] of Object.entries(coreTeam)) {
|
for (const [key, value] of Object.entries(coreTeam)) {
|
||||||
|
|
@ -536,28 +617,24 @@ class ManifestGenerator {
|
||||||
}
|
}
|
||||||
teamLines.push('');
|
teamLines.push('');
|
||||||
}
|
}
|
||||||
if (Object.keys(coreUser).length > 0) {
|
// [core] user-scope: never written to the project user.toml. Task D routes
|
||||||
userLines.push('[core]');
|
// these to ~/.bmad/config.user.toml via writeGlobalUserCore (called by
|
||||||
for (const [key, value] of Object.entries(coreUser)) {
|
// generateManifests after this method returns). config.user.toml stays
|
||||||
userLines.push(`${key} = ${formatTomlValue(value)}`);
|
// empty unless the user has manually pinned a per-project override.
|
||||||
}
|
|
||||||
userLines.push('');
|
|
||||||
}
|
|
||||||
|
|
||||||
// [modules.<code>] — split per module
|
// [modules.<code>] — emit only deltas; skip section if no deltas.
|
||||||
for (const moduleName of this.updatedModules) {
|
for (const moduleName of this.updatedModules) {
|
||||||
if (moduleName === 'core') continue;
|
if (moduleName === 'core') continue;
|
||||||
const cfg = moduleConfigs[moduleName];
|
const cfg = moduleConfigs[moduleName];
|
||||||
if (!cfg || Object.keys(cfg).length === 0) continue;
|
if (!cfg || Object.keys(cfg).length === 0) continue;
|
||||||
// Use the module's code field from module.yaml as the TOML key so the
|
|
||||||
// section is [modules.mdo] not [modules.MDO: Maxio DevOps Operations].
|
|
||||||
const sectionKey = codeByModuleName[moduleName] || moduleName;
|
const sectionKey = codeByModuleName[moduleName] || moduleName;
|
||||||
// Only filter out spread-from-core pollution when we actually know
|
|
||||||
// this module's prompt schema. For external/marketplace modules whose
|
|
||||||
// module.yaml isn't in the src tree, fall through as all-team so we
|
|
||||||
// don't drop their real answers.
|
|
||||||
const haveSchema = Object.keys(scopeByModuleKey[moduleName] || {}).length > 0;
|
const haveSchema = Object.keys(scopeByModuleKey[moduleName] || {}).length > 0;
|
||||||
const { team: modTeam, user: modUser } = partition(moduleName, cfg, haveSchema);
|
const { team: modTeamRaw, user: modUserRaw } = partition(moduleName, cfg, haveSchema);
|
||||||
|
|
||||||
|
const moduleDefaults = defaultsByModuleKey[moduleName] || {};
|
||||||
|
const modTeam = stripDefaults(modTeamRaw, moduleDefaults);
|
||||||
|
const modUser = stripDefaults(modUserRaw, moduleDefaults);
|
||||||
|
|
||||||
if (Object.keys(modTeam).length > 0) {
|
if (Object.keys(modTeam).length > 0) {
|
||||||
teamLines.push(`[modules.${sectionKey}]`);
|
teamLines.push(`[modules.${sectionKey}]`);
|
||||||
for (const [key, value] of Object.entries(modTeam)) {
|
for (const [key, value] of Object.entries(modTeam)) {
|
||||||
|
|
@ -574,43 +651,10 @@ class ManifestGenerator {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// [agents.<code>] — always team (agent roster is organizational).
|
// [agents.<code>] — intentionally NOT emitted (Task F). The roster lives
|
||||||
// Freshly collected agents come from module.yaml this run. If a module
|
// in the per-module module.toml floor. Users who want to override or
|
||||||
// was preserved (e.g. during quickUpdate when its source isn't available),
|
// add agents per-project edit _bmad/custom/config.toml; that file is
|
||||||
// its module.yaml wasn't read — so its agents aren't in `this.agents` and
|
// never touched by the installer.
|
||||||
// would silently disappear from the roster. Preserve those existing
|
|
||||||
// [agents.*] blocks verbatim from the prior config.toml.
|
|
||||||
const freshAgentCodes = new Set(this.agents.map((a) => a.code));
|
|
||||||
const contributingModules = new Set(this.agents.map((a) => a.module));
|
|
||||||
const preservedModules = this.updatedModules.filter((m) => !contributingModules.has(m));
|
|
||||||
const preservedBlocks = [];
|
|
||||||
if (preservedModules.length > 0 && (await fs.pathExists(teamPath))) {
|
|
||||||
try {
|
|
||||||
const prev = await fs.readFile(teamPath, 'utf8');
|
|
||||||
for (const block of extractAgentBlocks(prev)) {
|
|
||||||
if (freshAgentCodes.has(block.code)) continue;
|
|
||||||
if (block.module && preservedModules.includes(block.module)) {
|
|
||||||
preservedBlocks.push(block.body);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`[warn] writeCentralConfig: could not read prior config.toml to preserve agents: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const agent of this.agents) {
|
|
||||||
const agentLines = [`[agents.${agent.code}]`, `module = ${formatTomlValue(agent.module)}`, `team = ${formatTomlValue(agent.team)}`];
|
|
||||||
if (agent.name) agentLines.push(`name = ${formatTomlValue(agent.name)}`);
|
|
||||||
if (agent.title) agentLines.push(`title = ${formatTomlValue(agent.title)}`);
|
|
||||||
if (agent.icon) agentLines.push(`icon = ${formatTomlValue(agent.icon)}`);
|
|
||||||
if (agent.description) agentLines.push(`description = ${formatTomlValue(agent.description)}`);
|
|
||||||
agentLines.push('');
|
|
||||||
teamLines.push(...agentLines);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const body of preservedBlocks) {
|
|
||||||
teamLines.push(body, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
const teamContent = teamLines.join('\n').replace(/\n+$/, '\n');
|
const teamContent = teamLines.join('\n').replace(/\n+$/, '\n');
|
||||||
const userContent = userLines.join('\n').replace(/\n+$/, '\n');
|
const userContent = userLines.join('\n').replace(/\n+$/, '\n');
|
||||||
|
|
@ -619,6 +663,198 @@ class ManifestGenerator {
|
||||||
return [teamPath, userPath];
|
return [teamPath, userPath];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write scope:user core values to ~/.bmad/config.user.toml (Task D).
|
||||||
|
* Merge-preserves any existing global content the user hand-edited.
|
||||||
|
*
|
||||||
|
* Why a global write step at all? Identity values (user_name,
|
||||||
|
* communication_language) are properties of the human, not the project.
|
||||||
|
* Asking them at every install is friction. Phase 1 stores them globally
|
||||||
|
* so they're answered once per machine.
|
||||||
|
*
|
||||||
|
* @param {object} moduleConfigs - the fully-resolved moduleConfigs map
|
||||||
|
* @returns {Promise<string|null>} the path written, or null if no values
|
||||||
|
*/
|
||||||
|
async writeGlobalUserCore(moduleConfigs) {
|
||||||
|
const coreScopes = {};
|
||||||
|
// We need core's module.yaml scope map. Build it lazily here so this
|
||||||
|
// method is callable without re-doing the writeCentralConfig setup.
|
||||||
|
const coreYamlPath = await resolveInstalledModuleYaml('core');
|
||||||
|
if (!coreYamlPath) return null;
|
||||||
|
try {
|
||||||
|
const parsed = yaml.parse(await fs.readFile(coreYamlPath, 'utf8'));
|
||||||
|
if (parsed && typeof parsed === 'object') {
|
||||||
|
for (const [key, value] of Object.entries(parsed)) {
|
||||||
|
if (value && typeof value === 'object' && 'prompt' in value) {
|
||||||
|
coreScopes[key] = value.scope === 'user' ? 'user' : 'team';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userScopeValues = {};
|
||||||
|
for (const [key, value] of Object.entries(moduleConfigs.core || {})) {
|
||||||
|
if (coreScopes[key] === 'user' && value !== undefined && value !== null && value !== '') {
|
||||||
|
userScopeValues[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(userScopeValues).length === 0) return null;
|
||||||
|
|
||||||
|
const globalPath = globalUserConfigPath();
|
||||||
|
await fs.ensureDir(path.dirname(globalPath));
|
||||||
|
|
||||||
|
// Line-surgery upsert into the existing file (or a fresh one with the
|
||||||
|
// installer header). We only touch the [core] keys we own. Every other
|
||||||
|
// section, comment, and value passes through byte-for-byte — including
|
||||||
|
// shapes the previous round-trip parser quietly dropped (arrays,
|
||||||
|
// single-quoted strings, dotted/quoted keys, \uXXXX escapes, etc.).
|
||||||
|
let content;
|
||||||
|
if (await fs.pathExists(globalPath)) {
|
||||||
|
content = await fs.readFile(globalPath, 'utf8');
|
||||||
|
} else {
|
||||||
|
content =
|
||||||
|
[
|
||||||
|
'# ─────────────────────────────────────────────────────────────────',
|
||||||
|
'# Global personal BMad config — values tied to YOU as a user, not',
|
||||||
|
'# any specific project. Installer writes scope:user identity here',
|
||||||
|
'# (user_name, communication_language) so re-installs across projects',
|
||||||
|
"# don't re-ask the same questions.",
|
||||||
|
'#',
|
||||||
|
'# Location precedence: $BMAD_HOME if set, else ~/.bmad',
|
||||||
|
'# Resolver tier: lower than project-level _bmad/*.toml.',
|
||||||
|
'# ─────────────────────────────────────────────────────────────────',
|
||||||
|
'',
|
||||||
|
].join('\n') + '\n';
|
||||||
|
}
|
||||||
|
for (const [key, value] of Object.entries(userScopeValues)) {
|
||||||
|
content = upsertTomlKey(content, '[core]', key, formatTomlValue(value));
|
||||||
|
}
|
||||||
|
await fs.writeFile(globalPath, content);
|
||||||
|
return globalPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write per-module `_bmad/{module}/module.toml` files — the "module floor"
|
||||||
|
* read by resolve_config.py as the lowest-priority layer.
|
||||||
|
*
|
||||||
|
* Each file contains the shipped defaults for one module:
|
||||||
|
* [modules.{code}] paths and other module-shape values (from
|
||||||
|
* module.yaml question defaults, with `result:`
|
||||||
|
* template applied + cross-key placeholders like
|
||||||
|
* `{output_folder}` resolved against other modules'
|
||||||
|
* defaults at install time)
|
||||||
|
* [agents.{agent-code}] one block per agent owned by this module
|
||||||
|
*
|
||||||
|
* `{project-root}` is preserved literally — runtime substitution by skills.
|
||||||
|
* Other cross-key references resolve against module.yaml DEFAULTS only,
|
||||||
|
* not the user's actual answers. This keeps module.toml stable as a "what
|
||||||
|
* the module ships" snapshot independent of per-project customization.
|
||||||
|
* User overrides land in _bmad/config.toml above the floor.
|
||||||
|
*
|
||||||
|
* Source of truth is the authored module.yaml. This file is a build artifact
|
||||||
|
* — regenerated on every install, never hand-edited.
|
||||||
|
*
|
||||||
|
* @param {string} bmadDir
|
||||||
|
* @returns {Promise<string[]>} Paths to all written module.toml files
|
||||||
|
*/
|
||||||
|
async writeModuleTomls(bmadDir) {
|
||||||
|
// Pass 1: parse every installed module.yaml, extract its raw defaults
|
||||||
|
// (just `{value}` substituted; cross-key placeholders left literal).
|
||||||
|
const moduleData = [];
|
||||||
|
for (const moduleName of this.updatedModules) {
|
||||||
|
const moduleYamlPath = await resolveInstalledModuleYaml(moduleName);
|
||||||
|
if (!moduleYamlPath) continue;
|
||||||
|
|
||||||
|
let moduleDef;
|
||||||
|
try {
|
||||||
|
moduleDef = yaml.parse(await fs.readFile(moduleYamlPath, 'utf8'));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
`[warn] writeModuleTomls: could not parse module.yaml for '${moduleName}' (${error.message}). ` +
|
||||||
|
`Skipping module.toml for this module.`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!moduleDef || typeof moduleDef !== 'object') continue;
|
||||||
|
|
||||||
|
const moduleDir = path.join(bmadDir, moduleName);
|
||||||
|
if (!(await fs.pathExists(moduleDir))) continue;
|
||||||
|
|
||||||
|
const moduleCode = typeof moduleDef.code === 'string' ? moduleDef.code : moduleName;
|
||||||
|
const rawDefaults = extractModuleDefaults(moduleDef);
|
||||||
|
moduleData.push({ moduleName, moduleCode, moduleDir, rawDefaults });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a flat cross-key lookup map from every module's raw defaults.
|
||||||
|
// First-define wins (deterministic given sorted updatedModules input).
|
||||||
|
// Values are stripped of a leading `{project-root}/` so substitutions
|
||||||
|
// re-compose cleanly when consumed in a `{project-root}/{key}/...` slot
|
||||||
|
// — matches the installer's processResultTemplate convention.
|
||||||
|
const crossKeyMap = {};
|
||||||
|
for (const { rawDefaults } of moduleData) {
|
||||||
|
for (const [key, value] of Object.entries(rawDefaults)) {
|
||||||
|
if (crossKeyMap[key] !== undefined) continue;
|
||||||
|
let stripped = value;
|
||||||
|
if (typeof stripped === 'string' && stripped.startsWith('{project-root}/')) {
|
||||||
|
stripped = stripped.slice('{project-root}/'.length);
|
||||||
|
}
|
||||||
|
crossKeyMap[key] = stripped;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 2: resolve cross-key placeholders in each module's defaults and
|
||||||
|
// write the final module.toml file.
|
||||||
|
const written = [];
|
||||||
|
for (const { moduleName, moduleCode, moduleDir, rawDefaults } of moduleData) {
|
||||||
|
const resolvedDefaults = {};
|
||||||
|
for (const [key, value] of Object.entries(rawDefaults)) {
|
||||||
|
resolvedDefaults[key] = resolveCrossKeyPlaceholders(value, crossKeyMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
'# Module-shipped defaults. Build artifact — do not edit by hand.',
|
||||||
|
"# Source: this module's module.yaml (authored at source).",
|
||||||
|
'# Regenerated on every install.',
|
||||||
|
'#',
|
||||||
|
'# Read by _bmad/scripts/resolve_config.py as the lowest-priority',
|
||||||
|
'# floor of the config layer chain. Project _bmad/config.toml and',
|
||||||
|
'# user overrides in _bmad/custom/ sit above this and win.',
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (Object.keys(resolvedDefaults).length > 0) {
|
||||||
|
// Core's defaults belong under top-level [core] — that's where
|
||||||
|
// writeCentralConfig emits core deltas and where resolve_config.py
|
||||||
|
// consumers read core.* from. Everything else gets the per-module
|
||||||
|
// [modules.<code>] namespace.
|
||||||
|
const sectionHeader = moduleCode === 'core' ? '[core]' : `[modules.${moduleCode}]`;
|
||||||
|
lines.push(sectionHeader);
|
||||||
|
for (const [key, value] of Object.entries(resolvedDefaults)) {
|
||||||
|
lines.push(`${key} = ${formatTomlValue(value)}`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const moduleAgents = this.agents.filter((a) => a.module === moduleName);
|
||||||
|
for (const agent of moduleAgents) {
|
||||||
|
lines.push(`[agents.${agent.code}]`, `module = ${formatTomlValue(agent.module)}`, `team = ${formatTomlValue(agent.team)}`);
|
||||||
|
if (agent.name) lines.push(`name = ${formatTomlValue(agent.name)}`);
|
||||||
|
if (agent.title) lines.push(`title = ${formatTomlValue(agent.title)}`);
|
||||||
|
if (agent.icon) lines.push(`icon = ${formatTomlValue(agent.icon)}`);
|
||||||
|
if (agent.description) lines.push(`description = ${formatTomlValue(agent.description)}`);
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputPath = path.join(moduleDir, 'module.toml');
|
||||||
|
const content = lines.join('\n').replace(/\n+$/, '\n');
|
||||||
|
await fs.writeFile(outputPath, content);
|
||||||
|
written.push(outputPath);
|
||||||
|
}
|
||||||
|
return written;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create empty _bmad/custom/config.toml and _bmad/custom/config.user.toml stubs
|
* Create empty _bmad/custom/config.toml and _bmad/custom/config.user.toml stubs
|
||||||
* on first install only. Installer never touches these files again after creation.
|
* on first install only. Installer never touches these files again after creation.
|
||||||
|
|
@ -806,6 +1042,130 @@ class ManifestGenerator {
|
||||||
* Handles strings (quoted + escaped), booleans, numbers, and arrays of scalars.
|
* Handles strings (quoted + escaped), booleans, numbers, and arrays of scalars.
|
||||||
* Objects are not expected at this emit path.
|
* Objects are not expected at this emit path.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Compute the processed default value for a module.yaml question item.
|
||||||
|
* Resolves `{key}` cross-references against the flat `crossKeyDefaults` lookup
|
||||||
|
* (shipped defaults, never user answers — see writeCentralConfig comment).
|
||||||
|
* Used by writeCentralConfig to detect default-equal values that should NOT
|
||||||
|
* be re-emitted into the lean config.toml. Matches the lookup table that
|
||||||
|
* writeModuleTomls uses, so module.toml's floor and config.toml's delta
|
||||||
|
* detection agree on what "default" means.
|
||||||
|
*
|
||||||
|
* Steps:
|
||||||
|
* 1. Substitute {key} references against crossKeyDefaults (with leading
|
||||||
|
* "{project-root}/" stripped, matching the installer's
|
||||||
|
* processResultTemplate behavior).
|
||||||
|
* 2. Apply the result: template with {value} substituted.
|
||||||
|
*
|
||||||
|
* Returns undefined for items without a default.
|
||||||
|
*
|
||||||
|
* @param {object} item - one module.yaml question schema
|
||||||
|
* @param {Record<string, *>} crossKeyDefaults - flat shipped-defaults lookup
|
||||||
|
* @returns {*} processed default value (string/scalar) or undefined
|
||||||
|
*/
|
||||||
|
function computeProcessedDefault(item, crossKeyDefaults) {
|
||||||
|
if (!item || item.default === undefined || item.default === null) return;
|
||||||
|
let value = item.default;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
value = value.replaceAll(/{([^}]+)}/g, (match, refKey) => {
|
||||||
|
if (refKey === 'project-root' || refKey === 'value' || refKey === 'directory_name') {
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
const replacement = (crossKeyDefaults || {})[refKey];
|
||||||
|
return replacement === undefined ? match : String(replacement);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (typeof item.result === 'string' && value !== undefined) {
|
||||||
|
return item.result.replaceAll('{value}', String(value));
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve `{key}` cross-references in a string value against a flat
|
||||||
|
* `{key: value}` lookup map. `{project-root}` and `{directory_name}` are
|
||||||
|
* preserved literal — they're runtime placeholders, substituted by the skill
|
||||||
|
* or resolver when the value is consumed. Unknown keys are left literal too.
|
||||||
|
*
|
||||||
|
* Used by writeModuleTomls so that module.toml's [modules.X] keys carry the
|
||||||
|
* same shape as the installer's resolved config (e.g.
|
||||||
|
* `"{project-root}/_bmad-output/planning-artifacts"`) — making the floor a
|
||||||
|
* drop-in for the central config when the latter omits a value as default.
|
||||||
|
*
|
||||||
|
* @param {*} value - typically a string; non-strings returned unchanged
|
||||||
|
* @param {Record<string, *>} crossKeyMap
|
||||||
|
* @returns {*}
|
||||||
|
*/
|
||||||
|
function resolveCrossKeyPlaceholders(value, crossKeyMap) {
|
||||||
|
if (typeof value !== 'string') return value;
|
||||||
|
return value.replaceAll(/{([^}]+)}/g, (match, key) => {
|
||||||
|
if (key === 'project-root' || key === 'directory_name' || key === 'value') {
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
const replacement = crossKeyMap[key];
|
||||||
|
return replacement === undefined ? match : String(replacement);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scalar / shallow equality for delta detection. Handles strings, numbers,
|
||||||
|
* booleans, and arrays of scalars (the only shapes module.yaml defaults
|
||||||
|
* produce). Different types compare unequal.
|
||||||
|
*/
|
||||||
|
function deepEqualScalar(a, b) {
|
||||||
|
if (a === b) return true;
|
||||||
|
if (Array.isArray(a) && Array.isArray(b)) {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
return a.every((item, i) => deepEqualScalar(item, b[i]));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Module.yaml top-level keys that are metadata, not question knobs. These
|
||||||
|
* never appear in a module's [modules.{code}] floor section.
|
||||||
|
*/
|
||||||
|
const MODULE_DEFAULTS_SKIP = new Set([
|
||||||
|
'code',
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
'default_selected',
|
||||||
|
'header',
|
||||||
|
'subheader',
|
||||||
|
'agents',
|
||||||
|
'directories',
|
||||||
|
'dependencies',
|
||||||
|
'prompt',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract shipped defaults from a parsed module.yaml. For each question-style
|
||||||
|
* key (object with a `default` field), capture the default and apply its
|
||||||
|
* `result:` template with `{value}` substituted. Cross-key placeholders like
|
||||||
|
* `{output_folder}` are left as literal strings — see writeModuleTomls() doc
|
||||||
|
* for why and how that's handled in phase 1.
|
||||||
|
*
|
||||||
|
* @param {object} moduleDef - parsed module.yaml content
|
||||||
|
* @returns {Record<string, string|number|boolean|Array<*>>}
|
||||||
|
*/
|
||||||
|
function extractModuleDefaults(moduleDef) {
|
||||||
|
const defaults = {};
|
||||||
|
for (const [key, value] of Object.entries(moduleDef)) {
|
||||||
|
if (MODULE_DEFAULTS_SKIP.has(key)) continue;
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) continue;
|
||||||
|
if (!('default' in value)) continue;
|
||||||
|
|
||||||
|
let resolved = value.default;
|
||||||
|
if (typeof value.result === 'string' && resolved !== undefined) {
|
||||||
|
// Apply `result:` template with `{value}` substituted by default. Leave
|
||||||
|
// other placeholders (`{project-root}`, `{output_folder}`, ...) literal.
|
||||||
|
resolved = value.result.replaceAll('{value}', String(resolved));
|
||||||
|
}
|
||||||
|
defaults[key] = resolved;
|
||||||
|
}
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
|
||||||
function formatTomlValue(value) {
|
function formatTomlValue(value) {
|
||||||
if (value === null || value === undefined) return '""';
|
if (value === null || value === undefined) return '""';
|
||||||
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
||||||
|
|
@ -821,39 +1181,4 @@ function formatTomlValue(value) {
|
||||||
return `"${escaped}"`;
|
return `"${escaped}"`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract [agents.<code>] blocks from a previously-emitted config.toml.
|
|
||||||
* We only need this for roster preservation — the file is our own controlled
|
|
||||||
* output, so a simple line scanner is safer than adding a TOML parser
|
|
||||||
* dependency. Each block runs from its `[agents.<code>]` header until the
|
|
||||||
* next `[` heading or EOF; the `module = "..."` line inside drives which
|
|
||||||
* entries we keep on the next write.
|
|
||||||
* @returns {Array<{code: string, module: string | null, body: string}>}
|
|
||||||
*/
|
|
||||||
function extractAgentBlocks(tomlContent) {
|
|
||||||
const blocks = [];
|
|
||||||
const lines = tomlContent.split('\n');
|
|
||||||
let i = 0;
|
|
||||||
while (i < lines.length) {
|
|
||||||
const header = lines[i].match(/^\[agents\.([^\]]+)]\s*$/);
|
|
||||||
if (!header) {
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const code = header[1];
|
|
||||||
const blockLines = [lines[i]];
|
|
||||||
let moduleName = null;
|
|
||||||
i++;
|
|
||||||
while (i < lines.length && !lines[i].startsWith('[')) {
|
|
||||||
blockLines.push(lines[i]);
|
|
||||||
const m = lines[i].match(/^module\s*=\s*"((?:[^"\\]|\\.)*)"\s*$/);
|
|
||||||
if (m) moduleName = m[1];
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
while (blockLines.length > 1 && blockLines.at(-1) === '') blockLines.pop();
|
|
||||||
blocks.push({ code, module: moduleName, body: blockLines.join('\n') });
|
|
||||||
}
|
|
||||||
return blocks;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { ManifestGenerator };
|
module.exports = { ManifestGenerator };
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,199 @@
|
||||||
|
const { spawnSync } = require('node:child_process');
|
||||||
|
const prompts = require('../prompts');
|
||||||
|
|
||||||
|
// Python 3.11 added stdlib `tomllib` (PEP 680), which the shared scripts in
|
||||||
|
// src/scripts/ (resolve_config.py, resolve_customization.py) require to read
|
||||||
|
// BMAD's TOML config files. memlog.py is more lenient and runs on 3.8+.
|
||||||
|
const PYTHON_FULL_SUPPORT = { major: 3, minor: 11 };
|
||||||
|
const PYTHON_PARTIAL_SUPPORT = { major: 3, minor: 8 };
|
||||||
|
|
||||||
|
// Every runtime call site (skill steps, on_complete hooks) invokes a literal
|
||||||
|
// `python3`, so only that command's version vouches for BMAD features. The
|
||||||
|
// fallback probes exist to tell the user "Python is installed, but not under
|
||||||
|
// the name BMAD uses" instead of a misleading "No Python found".
|
||||||
|
const RUNTIME_COMMAND = 'python3';
|
||||||
|
const PROBE_CANDIDATES =
|
||||||
|
process.platform === 'win32'
|
||||||
|
? [
|
||||||
|
{ command: 'python3', args: ['--version'] },
|
||||||
|
{ command: 'py', args: ['-3', '--version'] },
|
||||||
|
{ command: 'python', args: ['--version'] },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ command: 'python3', args: ['--version'] },
|
||||||
|
{ command: 'python', args: ['--version'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a `python --version` output line into version parts.
|
||||||
|
* Python 3 prints to stdout; Python 2 printed to stderr — callers pass both.
|
||||||
|
* @param {string} output - Combined stdout/stderr from `python --version`
|
||||||
|
* @returns {{major: number, minor: number, patch: number, raw: string}|null}
|
||||||
|
*/
|
||||||
|
function parsePythonVersion(output) {
|
||||||
|
if (!output) return null;
|
||||||
|
const match = output.match(/Python\s+(\d+)\.(\d+)(?:\.(\d+))?/);
|
||||||
|
if (!match) return null;
|
||||||
|
return {
|
||||||
|
major: Number(match[1]),
|
||||||
|
minor: Number(match[2]),
|
||||||
|
patch: Number(match[3] || 0),
|
||||||
|
raw: `${match[1]}.${match[2]}.${match[3] || 0}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify a detected Python version against BMAD's feature requirements.
|
||||||
|
* @param {{major: number, minor: number}|null} version
|
||||||
|
* @returns {'full'|'partial'|'unsupported'|'none'}
|
||||||
|
*/
|
||||||
|
function classifyPython(version) {
|
||||||
|
if (!version) return 'none';
|
||||||
|
const { major, minor } = version;
|
||||||
|
if (major > PYTHON_FULL_SUPPORT.major || (major === PYTHON_FULL_SUPPORT.major && minor >= PYTHON_FULL_SUPPORT.minor)) {
|
||||||
|
return 'full';
|
||||||
|
}
|
||||||
|
if (major === PYTHON_PARTIAL_SUPPORT.major && minor >= PYTHON_PARTIAL_SUPPORT.minor) {
|
||||||
|
return 'partial';
|
||||||
|
}
|
||||||
|
return 'unsupported';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run one probe candidate and return its parsed version, or null.
|
||||||
|
* @param {{command: string, args: string[]}} candidate
|
||||||
|
* @returns {{major: number, minor: number, patch: number, raw: string}|null}
|
||||||
|
*/
|
||||||
|
function probeVersion(candidate) {
|
||||||
|
const run = (extra = {}) =>
|
||||||
|
spawnSync(candidate.command, candidate.args, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 5000,
|
||||||
|
windowsHide: true,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
let result = run();
|
||||||
|
// Node >=18.20/20.12 refuses to spawn .bat/.cmd without a shell
|
||||||
|
// (CVE-2024-27980 hardening) and reports EINVAL — pyenv-win ships its
|
||||||
|
// python shims as .bat. Args here are static literals, so a shell retry
|
||||||
|
// is injection-safe.
|
||||||
|
if (result.error && result.error.code === 'EINVAL' && process.platform === 'win32') {
|
||||||
|
result = run({ shell: true });
|
||||||
|
}
|
||||||
|
if (result.error) return null;
|
||||||
|
return parsePythonVersion(`${result.stdout || ''}\n${result.stderr || ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Probe the local environment for a Python interpreter.
|
||||||
|
* Tries each candidate command and returns the first that reports a version.
|
||||||
|
* `isRuntimeCommand` is true only when the match is `python3` — the command
|
||||||
|
* BMAD scripts actually invoke.
|
||||||
|
* @returns {{command: string, version: {major: number, minor: number, patch: number, raw: string}, isRuntimeCommand: boolean}|null}
|
||||||
|
*/
|
||||||
|
function detectPython() {
|
||||||
|
for (const candidate of PROBE_CANDIDATES) {
|
||||||
|
try {
|
||||||
|
const version = probeVersion(candidate);
|
||||||
|
if (version) {
|
||||||
|
const display = candidate.args.length > 1 ? `${candidate.command} ${candidate.args.slice(0, -1).join(' ')}` : candidate.command;
|
||||||
|
return { command: display, version, isRuntimeCommand: candidate.command === RUNTIME_COMMAND };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Candidate not runnable — try the next one.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function upgradeHints() {
|
||||||
|
return [
|
||||||
|
'How to get Python 3.11+ (as `python3`):',
|
||||||
|
' macOS: brew install python3',
|
||||||
|
' Windows: winget install Python.Python.3.12 (then ensure `python3` resolves, e.g. enable the python3 alias)',
|
||||||
|
' Linux/WSL: sudo apt install python3 (Ubuntu 24.04+ ships 3.12; older distros: use pyenv or deadsnakes)',
|
||||||
|
' Docker: add python3 to your image (e.g. apk add python3 / apt-get install -y python3)',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check the local Python environment and warn about degraded BMAD features.
|
||||||
|
*
|
||||||
|
* Warn-don't-block: most of BMAD works without Python, so the install always
|
||||||
|
* may proceed — but the user must explicitly acknowledge the warning so it
|
||||||
|
* can't scroll past unseen. In non-interactive runs (--yes, or stdin is not
|
||||||
|
* a TTY) the warning is logged and the install continues without a prompt.
|
||||||
|
*
|
||||||
|
* @param {Object} [options]
|
||||||
|
* @param {boolean} [options.nonInteractive=false] - Skip the ack prompt (--yes, or no TTY)
|
||||||
|
* @returns {Promise<{status: string, detected: Object|null}>}
|
||||||
|
*/
|
||||||
|
async function checkPythonEnvironment({ nonInteractive = false } = {}) {
|
||||||
|
// Called via module.exports so tests can stub detection.
|
||||||
|
const detected = module.exports.detectPython();
|
||||||
|
const status = classifyPython(detected ? detected.version : null);
|
||||||
|
|
||||||
|
if (status === 'full' && detected.isRuntimeCommand) {
|
||||||
|
await prompts.log.success(`Python ${detected.version.raw} detected (${detected.command}) — all BMAD features supported.`);
|
||||||
|
return { status, detected };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detected && !detected.isRuntimeCommand) {
|
||||||
|
await prompts.log.warn(
|
||||||
|
`Python ${detected.version.raw} found via \`${detected.command}\`, but BMAD scripts invoke \`python3\`, which is not on PATH.\n` +
|
||||||
|
`Python-powered features (memlog session memory, TOML config resolution) won't run until \`python3\` resolves —\n` +
|
||||||
|
`add a python3 alias/shim, or reinstall Python with the python3 launcher enabled.`,
|
||||||
|
);
|
||||||
|
} else if (status === 'partial') {
|
||||||
|
await prompts.log.warn(
|
||||||
|
`Python ${detected.version.raw} detected (${detected.command}) — BMAD's TOML config tools need Python 3.11+ (stdlib tomllib).\n` +
|
||||||
|
`Works: memlog session memory. Won't work: config/customization resolution scripts.`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const found =
|
||||||
|
status === 'unsupported' ? `Python ${detected.version.raw} detected (${detected.command}) — too old.` : 'No Python found on PATH.';
|
||||||
|
await prompts.log.warn(
|
||||||
|
`${found} BMAD installs fine without it, but Python-powered features\n` +
|
||||||
|
`(memlog session memory, TOML config resolution) won't run until Python 3.11+ is available.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await prompts.note(upgradeHints(), 'Python 3.11+ recommended');
|
||||||
|
|
||||||
|
if (nonInteractive) {
|
||||||
|
await prompts.log.info('Continuing anyway (non-interactive run). You can fix Python later — no reinstall needed.');
|
||||||
|
return { status, detected };
|
||||||
|
}
|
||||||
|
|
||||||
|
const choice = await prompts.select({
|
||||||
|
message: "BMAD's Python-powered features won't work yet. How do you want to proceed?",
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
name: 'Continue install',
|
||||||
|
value: 'continue',
|
||||||
|
hint: 'BMAD works without Python — you can fix Python later, no reinstall needed',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Quit and fix Python first',
|
||||||
|
value: 'quit',
|
||||||
|
hint: 'make Python 3.11+ available as python3, then re-run the installer',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
default: 'continue',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (choice === 'quit') {
|
||||||
|
await prompts.cancel('Make Python 3.11+ available as `python3` (see hints above), then re-run the installer.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status, detected };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
checkPythonEnvironment,
|
||||||
|
detectPython,
|
||||||
|
parsePythonVersion,
|
||||||
|
classifyPython,
|
||||||
|
PYTHON_FULL_SUPPORT,
|
||||||
|
PYTHON_PARTIAL_SUPPORT,
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,180 @@
|
||||||
|
/**
|
||||||
|
* Helpers for the cross-platform global BMad config directory.
|
||||||
|
*
|
||||||
|
* The "global" tier is read-only to most installer code paths — only core
|
||||||
|
* scope:user answers (user_name, communication_language) and identity defaults
|
||||||
|
* are written there, and only by the post-install global-write step. Everything
|
||||||
|
* else reads.
|
||||||
|
*
|
||||||
|
* Location precedence:
|
||||||
|
* 1. $BMAD_HOME (for CI / corporate / multi-account setups)
|
||||||
|
* 2. ~/.bmad
|
||||||
|
*
|
||||||
|
* Works on macOS, Linux, WSL, and Windows: os.homedir() returns the
|
||||||
|
* platform-appropriate home (and on WSL, the Linux home — each WSL distro has
|
||||||
|
* its own global config).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const path = require('node:path');
|
||||||
|
const os = require('node:os');
|
||||||
|
const fs = require('./fs-native');
|
||||||
|
|
||||||
|
function resolveGlobalDir() {
|
||||||
|
const override = process.env.BMAD_HOME;
|
||||||
|
if (override && override.trim()) {
|
||||||
|
return path.resolve(expandTilde(override.trim()));
|
||||||
|
}
|
||||||
|
return path.join(os.homedir(), '.bmad');
|
||||||
|
}
|
||||||
|
|
||||||
|
// JS counterpart to Python's Path.expanduser() — keeps installer/resolver
|
||||||
|
// agreement when BMAD_HOME is set in non-shell contexts (Docker, .env files,
|
||||||
|
// Windows env var GUI) where the shell never expands `~`.
|
||||||
|
function expandTilde(input) {
|
||||||
|
if (input === '~') return os.homedir();
|
||||||
|
if (input.startsWith('~/') || input.startsWith('~\\')) {
|
||||||
|
return path.join(os.homedir(), input.slice(2));
|
||||||
|
}
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
function globalTeamConfigPath() {
|
||||||
|
return path.join(resolveGlobalDir(), 'config.toml');
|
||||||
|
}
|
||||||
|
|
||||||
|
function globalUserConfigPath() {
|
||||||
|
return path.join(resolveGlobalDir(), 'config.user.toml');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a minimal subset of TOML — enough for the installer-owned files:
|
||||||
|
* top-level tables ([section] / [section.sub]) and simple scalar values
|
||||||
|
* (string, number, boolean). No arrays of tables, inline tables, datetimes,
|
||||||
|
* or multiline strings — those don't appear in files we author. Reader stays
|
||||||
|
* dependency-free; we only consume what we emit.
|
||||||
|
*
|
||||||
|
* For an unrecognized shape, the offending line is silently dropped (rather
|
||||||
|
* than erroring) to keep the installer resilient against hand-edits that
|
||||||
|
* went slightly outside the documented schema.
|
||||||
|
*/
|
||||||
|
function parseSimpleToml(content) {
|
||||||
|
const result = {};
|
||||||
|
let currentTable = result;
|
||||||
|
|
||||||
|
for (const rawLine of content.split('\n')) {
|
||||||
|
const line = stripInlineComment(rawLine).trim();
|
||||||
|
if (!line) continue;
|
||||||
|
|
||||||
|
const sectionMatch = line.match(/^\[([^\]]+)]\s*$/);
|
||||||
|
if (sectionMatch) {
|
||||||
|
const parts = sectionMatch[1].split('.').map((p) => p.trim());
|
||||||
|
currentTable = result;
|
||||||
|
for (const part of parts) {
|
||||||
|
if (!currentTable[part] || typeof currentTable[part] !== 'object' || Array.isArray(currentTable[part])) {
|
||||||
|
currentTable[part] = {};
|
||||||
|
}
|
||||||
|
currentTable = currentTable[part];
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const kvMatch = line.match(/^([A-Za-z0-9_-]+)\s*=\s*(.+)$/);
|
||||||
|
if (kvMatch) {
|
||||||
|
const [, key, rawValue] = kvMatch;
|
||||||
|
const parsed = parseTomlScalar(rawValue.trim());
|
||||||
|
if (parsed !== undefined) {
|
||||||
|
currentTable[key] = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip a trailing `# comment` from a TOML line, but only when the `#` lives
|
||||||
|
* outside a double-quoted string. We don't author multiline strings or
|
||||||
|
* literal strings, so a single double-quote scanner is sufficient.
|
||||||
|
*/
|
||||||
|
function stripInlineComment(line) {
|
||||||
|
let inString = false;
|
||||||
|
let escaped = false;
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const ch = line[i];
|
||||||
|
if (escaped) {
|
||||||
|
escaped = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '\\') {
|
||||||
|
escaped = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '"') {
|
||||||
|
inString = !inString;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '#' && !inString) {
|
||||||
|
return line.slice(0, i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTomlScalar(raw) {
|
||||||
|
if (raw.startsWith('"') && raw.endsWith('"') && raw.length >= 2) {
|
||||||
|
// Single-pass unescape — sequential replaceAll lets `\\n` (backslash + n)
|
||||||
|
// collapse into a newline because the second pass sees the just-produced
|
||||||
|
// `\n` and treats it as the escape sequence. One regex avoids that.
|
||||||
|
const escapes = { '\\\\': '\\', '\\"': '"', '\\n': '\n', '\\r': '\r', '\\t': '\t' };
|
||||||
|
return raw.slice(1, -1).replaceAll(/\\["\\nrt]/g, (m) => escapes[m] ?? m);
|
||||||
|
}
|
||||||
|
if (raw === 'true') return true;
|
||||||
|
if (raw === 'false') return false;
|
||||||
|
if (/^-?\d+$/.test(raw)) return Number.parseInt(raw, 10);
|
||||||
|
if (/^-?\d+\.\d+$/.test(raw)) return Number.parseFloat(raw);
|
||||||
|
return; // dropped silently — see header comment
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load both global TOML files. Either may be missing; returns merged result.
|
||||||
|
* Files are read but never written by this helper.
|
||||||
|
*
|
||||||
|
* @returns {Promise<{ team: object, user: object, merged: object }>}
|
||||||
|
*/
|
||||||
|
async function loadGlobalConfig() {
|
||||||
|
const team = await readTomlFile(globalTeamConfigPath());
|
||||||
|
const user = await readTomlFile(globalUserConfigPath());
|
||||||
|
// Shallow-deep merge: user table wins over team at every key path. The
|
||||||
|
// installer only consults the merged view for default-seeding, so this is
|
||||||
|
// sufficient (we don't need the full structural-merge of resolve_config.py).
|
||||||
|
const merged = mergeDeep(team, user);
|
||||||
|
return { team, user, merged };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readTomlFile(filePath) {
|
||||||
|
if (!(await fs.pathExists(filePath))) return {};
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(filePath, 'utf8');
|
||||||
|
return parseSimpleToml(content);
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeDeep(base, override) {
|
||||||
|
if (!override || typeof override !== 'object' || Array.isArray(override)) return override === undefined ? base : override;
|
||||||
|
if (!base || typeof base !== 'object' || Array.isArray(base)) return override;
|
||||||
|
const result = { ...base };
|
||||||
|
for (const [key, value] of Object.entries(override)) {
|
||||||
|
result[key] = mergeDeep(result[key], value);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
resolveGlobalDir,
|
||||||
|
globalTeamConfigPath,
|
||||||
|
globalUserConfigPath,
|
||||||
|
parseSimpleToml,
|
||||||
|
loadGlobalConfig,
|
||||||
|
};
|
||||||
|
|
@ -5,6 +5,7 @@ const prompts = require('../prompts');
|
||||||
const { getProjectRoot, getSourcePath, getModulePath } = require('../project-root');
|
const { getProjectRoot, getSourcePath, getModulePath } = require('../project-root');
|
||||||
const { CLIUtils } = require('../cli-utils');
|
const { CLIUtils } = require('../cli-utils');
|
||||||
const { ExternalModuleManager } = require('./external-manager');
|
const { ExternalModuleManager } = require('./external-manager');
|
||||||
|
const { loadGlobalConfig } = require('../global-config');
|
||||||
|
|
||||||
class OfficialModules {
|
class OfficialModules {
|
||||||
constructor(options = {}) {
|
constructor(options = {}) {
|
||||||
|
|
@ -1019,11 +1020,26 @@ class OfficialModules {
|
||||||
* @param {string} projectDir - Target project directory
|
* @param {string} projectDir - Target project directory
|
||||||
* @param {Object} options - Additional options
|
* @param {Object} options - Additional options
|
||||||
* @param {boolean} options.skipPrompts - Skip prompts and use defaults (for --yes flag)
|
* @param {boolean} options.skipPrompts - Skip prompts and use defaults (for --yes flag)
|
||||||
|
*
|
||||||
|
* Non-core modules are always silent: they accept module.yaml defaults
|
||||||
|
* without prompting. Users adjust values later via the `bmad-customize`
|
||||||
|
* skill or direct edits to _bmad/custom/config.toml / customize.toml.
|
||||||
|
* Core is still fully prompted (identity questions live there) — though
|
||||||
|
* scope:user core values that are already in ~/.bmad/config.user.toml
|
||||||
|
* are silently reused (see below).
|
||||||
*/
|
*/
|
||||||
async collectAllConfigurations(modules, projectDir, options = {}) {
|
async collectAllConfigurations(modules, projectDir, options = {}) {
|
||||||
this.skipPrompts = options.skipPrompts || false;
|
this.skipPrompts = options.skipPrompts || false;
|
||||||
this.modulesToCustomize = undefined;
|
this.modulesToCustomize = undefined;
|
||||||
await this.loadExistingConfig(projectDir);
|
await this.loadExistingConfig(projectDir);
|
||||||
|
// Read the cross-platform global config (~/.bmad or $BMAD_HOME). Used to:
|
||||||
|
// - Skip core scope:user questions whose value is already known globally
|
||||||
|
// (task D — ask identity once per machine, not once per project).
|
||||||
|
// - Seed defaults for non-user-scope core questions (task E — let users
|
||||||
|
// pre-pin default project_name/output_folder/etc. globally if they want).
|
||||||
|
// Project-installed values still beat global at resolve time; this is
|
||||||
|
// purely about what we ASK during install.
|
||||||
|
this.globalConfig = await loadGlobalConfig();
|
||||||
|
|
||||||
// Check if core was already collected (e.g., in early collection phase)
|
// Check if core was already collected (e.g., in early collection phase)
|
||||||
const coreAlreadyCollected = this.collectedConfig.core && Object.keys(this.collectedConfig.core).length > 0;
|
const coreAlreadyCollected = this.collectedConfig.core && Object.keys(this.collectedConfig.core).length > 0;
|
||||||
|
|
@ -1045,44 +1061,17 @@ class OfficialModules {
|
||||||
await this.collectModuleConfig(moduleName, projectDir);
|
await this.collectModuleConfig(moduleName, projectDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show batch configuration gateway for non-core modules
|
// Non-core modules are always silent: accept module.yaml defaults without
|
||||||
// Scan all non-core module schemas for display names and config metadata
|
// prompting. Users adjust via `bmad-customize` later or by editing
|
||||||
|
// _bmad/custom/{config,customize}.toml. Setting modulesToCustomize to an
|
||||||
|
// empty Set bypasses the legacy per-module "Accept Defaults?" confirm
|
||||||
|
// (collectModuleConfig only fires that when modulesToCustomize is
|
||||||
|
// undefined). scanModuleSchemas is still called so the spinner can show
|
||||||
|
// friendly display names while applying defaults.
|
||||||
let scannedModules = [];
|
let scannedModules = [];
|
||||||
if (!this.skipPrompts && nonCoreModules.length > 0) {
|
if (!this.skipPrompts && nonCoreModules.length > 0) {
|
||||||
|
this.modulesToCustomize = new Set();
|
||||||
scannedModules = await this.scanModuleSchemas(nonCoreModules);
|
scannedModules = await this.scanModuleSchemas(nonCoreModules);
|
||||||
const customizableModules = scannedModules.filter((m) => m.questionCount > 0);
|
|
||||||
|
|
||||||
if (customizableModules.length > 0) {
|
|
||||||
const configMode = await prompts.select({
|
|
||||||
message: 'Module configuration',
|
|
||||||
choices: [
|
|
||||||
{ name: 'Express Setup', value: 'express', hint: 'accept all defaults (recommended)' },
|
|
||||||
{ name: 'Customize', value: 'customize', hint: 'choose modules to configure' },
|
|
||||||
],
|
|
||||||
default: 'express',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (configMode === 'customize') {
|
|
||||||
const choices = customizableModules.map((m) => ({
|
|
||||||
name: `${m.displayName} (${m.questionCount} option${m.questionCount === 1 ? '' : 's'})`,
|
|
||||||
value: m.moduleName,
|
|
||||||
hint: m.hasFieldsWithoutDefaults ? 'has fields without defaults' : undefined,
|
|
||||||
checked: m.hasFieldsWithoutDefaults,
|
|
||||||
}));
|
|
||||||
const selected = await prompts.multiselect({
|
|
||||||
message: 'Select modules to customize:',
|
|
||||||
choices,
|
|
||||||
required: false,
|
|
||||||
});
|
|
||||||
this.modulesToCustomize = new Set(selected);
|
|
||||||
} else {
|
|
||||||
// Express mode: no modules to customize
|
|
||||||
this.modulesToCustomize = new Set();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// All non-core modules have zero config - no gateway needed
|
|
||||||
this.modulesToCustomize = new Set();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect remaining non-core modules
|
// Collect remaining non-core modules
|
||||||
|
|
@ -1155,6 +1144,18 @@ class OfficialModules {
|
||||||
if (!this._existingConfig) {
|
if (!this._existingConfig) {
|
||||||
await this.loadExistingConfig(projectDir);
|
await this.loadExistingConfig(projectDir);
|
||||||
}
|
}
|
||||||
|
// Lazy-load global config so identity fallbacks below can consult
|
||||||
|
// ~/.bmad/config.user.toml. quickUpdate doesn't go through
|
||||||
|
// collectAllConfigurations, so this.globalConfig would otherwise be unset
|
||||||
|
// and user_name would silently default to the OS username — overwriting
|
||||||
|
// the value the user previously committed to global.
|
||||||
|
if (!this.globalConfig) {
|
||||||
|
try {
|
||||||
|
this.globalConfig = await loadGlobalConfig();
|
||||||
|
} catch {
|
||||||
|
this.globalConfig = { merged: {} };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize allAnswers if not already initialized
|
// Initialize allAnswers if not already initialized
|
||||||
if (!this.allAnswers) {
|
if (!this.allAnswers) {
|
||||||
|
|
@ -1231,12 +1232,14 @@ class OfficialModules {
|
||||||
}
|
}
|
||||||
this.collectedConfig[moduleName] = { ...this._existingConfig[moduleName] };
|
this.collectedConfig[moduleName] = { ...this._existingConfig[moduleName] };
|
||||||
|
|
||||||
// Special handling for user_name: ensure it has a value
|
// Special handling for user_name: ensure it has a value. Prefer the
|
||||||
|
// global value (~/.bmad/config.user.toml) before the OS username, or
|
||||||
|
// we'll silently overwrite the user's prior global identity.
|
||||||
if (
|
if (
|
||||||
moduleName === 'core' &&
|
moduleName === 'core' &&
|
||||||
(!this.collectedConfig[moduleName].user_name || this.collectedConfig[moduleName].user_name === '[USER_NAME]')
|
(!this.collectedConfig[moduleName].user_name || this.collectedConfig[moduleName].user_name === '[USER_NAME]')
|
||||||
) {
|
) {
|
||||||
this.collectedConfig[moduleName].user_name = this.getDefaultUsername();
|
this.collectedConfig[moduleName].user_name = this._identityFallback('user_name');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also populate allAnswers for cross-referencing
|
// Also populate allAnswers for cross-referencing
|
||||||
|
|
@ -1244,18 +1247,20 @@ class OfficialModules {
|
||||||
// Ensure user_name is properly set in allAnswers too
|
// Ensure user_name is properly set in allAnswers too
|
||||||
let finalValue = value;
|
let finalValue = value;
|
||||||
if (moduleName === 'core' && key === 'user_name' && (!value || value === '[USER_NAME]')) {
|
if (moduleName === 'core' && key === 'user_name' && (!value || value === '[USER_NAME]')) {
|
||||||
finalValue = this.getDefaultUsername();
|
finalValue = this._identityFallback('user_name');
|
||||||
}
|
}
|
||||||
this.allAnswers[`${moduleName}_${key}`] = finalValue;
|
this.allAnswers[`${moduleName}_${key}`] = finalValue;
|
||||||
}
|
}
|
||||||
} else if (moduleName === 'core') {
|
} else if (moduleName === 'core') {
|
||||||
// No existing core config - ensure we at least have user_name
|
// No existing core config - ensure we at least have user_name.
|
||||||
|
// Same global-first preference as above.
|
||||||
if (!this.collectedConfig[moduleName]) {
|
if (!this.collectedConfig[moduleName]) {
|
||||||
this.collectedConfig[moduleName] = {};
|
this.collectedConfig[moduleName] = {};
|
||||||
}
|
}
|
||||||
if (!this.collectedConfig[moduleName].user_name) {
|
if (!this.collectedConfig[moduleName].user_name) {
|
||||||
this.collectedConfig[moduleName].user_name = this.getDefaultUsername();
|
const fallback = this._identityFallback('user_name');
|
||||||
this.allAnswers[`${moduleName}_user_name`] = this.getDefaultUsername();
|
this.collectedConfig[moduleName].user_name = fallback;
|
||||||
|
this.allAnswers[`${moduleName}_user_name`] = fallback;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1433,6 +1438,22 @@ class OfficialModules {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fall back through identity sources for a core scope:user key. Prefers the
|
||||||
|
* global value (~/.bmad/config.user.toml) so quickUpdate / re-install never
|
||||||
|
* silently overwrites a previously-set identity with the OS username.
|
||||||
|
* Only user_name has an OS-derived ultimate fallback; other keys return
|
||||||
|
* undefined so the caller can decide.
|
||||||
|
*/
|
||||||
|
_identityFallback(key) {
|
||||||
|
const globalCore = (this.globalConfig && this.globalConfig.merged && this.globalConfig.merged.core) || {};
|
||||||
|
if (globalCore[key] !== undefined && globalCore[key] !== '' && globalCore[key] !== null) {
|
||||||
|
return globalCore[key];
|
||||||
|
}
|
||||||
|
if (key === 'user_name') return this.getDefaultUsername();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collect configuration for a single module
|
* Collect configuration for a single module
|
||||||
* @param {string} moduleName - Module name
|
* @param {string} moduleName - Module name
|
||||||
|
|
@ -1507,6 +1528,79 @@ class OfficialModules {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tasks D + E: for the core module, consult the global config (~/.bmad).
|
||||||
|
// D — scope:user keys (user_name, communication_language): if already
|
||||||
|
// known globally, accept silently. Don't prompt; store the final
|
||||||
|
// form directly in collectedConfig so cross-references still work.
|
||||||
|
// E — non-user-scope keys (project_name, output_folder, ...): if known
|
||||||
|
// globally, seed the prompt default from there. User can still
|
||||||
|
// change it per-project.
|
||||||
|
// Track silently-reused keys so the user knows where the values came from
|
||||||
|
// (otherwise they'd see questions they previously answered just disappear).
|
||||||
|
const reusedFromGlobal = [];
|
||||||
|
const seededFromGlobal = [];
|
||||||
|
if (moduleName === 'core' && this.globalConfig && this.globalConfig.merged && this.globalConfig.merged.core) {
|
||||||
|
const globalCore = this.globalConfig.merged.core;
|
||||||
|
const remaining = [];
|
||||||
|
|
||||||
|
for (const question of questions) {
|
||||||
|
const key = question.name.replace(`${moduleName}_`, '');
|
||||||
|
const item = moduleConfig[key];
|
||||||
|
const globalValue = globalCore[key];
|
||||||
|
|
||||||
|
if (globalValue === undefined || globalValue === null || globalValue === '') {
|
||||||
|
remaining.push(question);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item && item.scope === 'user') {
|
||||||
|
// D: silent reuse. Stash final form into collectedConfig and skip
|
||||||
|
// adding this question to the prompt list. The post-answer
|
||||||
|
// result-template loop never sees this key.
|
||||||
|
this.collectedConfig[moduleName] = this.collectedConfig[moduleName] || {};
|
||||||
|
this.collectedConfig[moduleName][key] = globalValue;
|
||||||
|
reusedFromGlobal.push({ key, value: globalValue });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// E: pre-seed the prompt default. Strip {project-root}/ for clean
|
||||||
|
// display — the result: template will add it back after the user
|
||||||
|
// accepts or edits.
|
||||||
|
question.default = this.cleanPromptValue(globalValue);
|
||||||
|
seededFromGlobal.push({ key, value: question.default });
|
||||||
|
remaining.push(question);
|
||||||
|
}
|
||||||
|
|
||||||
|
questions.length = 0;
|
||||||
|
questions.push(...remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tell the user when global values are in play. Silent reuse (D) is the
|
||||||
|
// important one — otherwise a question that fired on a prior install
|
||||||
|
// would just vanish, leaving the user wondering what happened. Seeded
|
||||||
|
// defaults (E) get a softer mention since the user still sees the value
|
||||||
|
// in the prompt.
|
||||||
|
if (!this.skipPrompts && (reusedFromGlobal.length > 0 || seededFromGlobal.length > 0)) {
|
||||||
|
const { globalUserConfigPath } = require('../global-config');
|
||||||
|
const globalPath = globalUserConfigPath();
|
||||||
|
const lines = [];
|
||||||
|
if (reusedFromGlobal.length > 0) {
|
||||||
|
lines.push('Using values from your global BMad config:');
|
||||||
|
for (const { key, value } of reusedFromGlobal) {
|
||||||
|
lines.push(` ${key} = ${JSON.stringify(value)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (seededFromGlobal.length > 0) {
|
||||||
|
if (lines.length > 0) lines.push('');
|
||||||
|
lines.push('Defaults pre-filled from your global BMad config:');
|
||||||
|
for (const { key, value } of seededFromGlobal) {
|
||||||
|
lines.push(` ${key} = ${JSON.stringify(value)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push('', `To change these, edit ${globalPath}`, '(or unset them there to be prompted on the next install).');
|
||||||
|
await prompts.log.info(lines.join('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
// Collect all answers (static + prompted)
|
// Collect all answers (static + prompted)
|
||||||
let allAnswers = { ...staticAnswers };
|
let allAnswers = { ...staticAnswers };
|
||||||
|
|
||||||
|
|
@ -1870,9 +1964,10 @@ class OfficialModules {
|
||||||
existingValue = this.normalizeExistingValueForPrompt(existingValue, moduleName, item, moduleConfig);
|
existingValue = this.normalizeExistingValueForPrompt(existingValue, moduleName, item, moduleConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Special handling for user_name: default to system user
|
// Special handling for user_name: prefer global identity (~/.bmad) over
|
||||||
|
// OS username so the prompt's default reflects what the user already chose.
|
||||||
if (moduleName === 'core' && key === 'user_name' && !existingValue) {
|
if (moduleName === 'core' && key === 'user_name' && !existingValue) {
|
||||||
item.default = this.getDefaultUsername();
|
item.default = this._identityFallback('user_name');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine question type and default value
|
// Determine question type and default value
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ const PROTOTYPE_POLLUTING_NAMES = new Set(['__proto__', 'prototype', 'constructo
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const fs = require('./fs-native');
|
const fs = require('./fs-native');
|
||||||
const yaml = require('yaml');
|
const yaml = require('yaml');
|
||||||
|
const { globalUserConfigPath } = require('./global-config');
|
||||||
|
const { resolveInstalledModuleYaml } = require('./project-root');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a single `--set <module>.<key>=<value>` entry.
|
* Parse a single `--set <module>.<key>=<value>` entry.
|
||||||
|
|
@ -83,11 +85,25 @@ function parseSetEntries(entries) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encode a JS string as a TOML basic string (double-quoted with escapes).
|
* Encode a `--set` value as a TOML literal. Types are inferred from the value
|
||||||
* @param {string} value
|
* so `--set bmm.workers=4` writes `workers = 4` (integer), not `"4"` (string).
|
||||||
|
*
|
||||||
|
* Rules (mirror how TOML would interpret the literal hand-typed in a config):
|
||||||
|
* - `true` / `false` → boolean
|
||||||
|
* - `-?\d+` → integer
|
||||||
|
* - `-?\d+\.\d+` → float
|
||||||
|
* - everything else → quoted basic string
|
||||||
|
*
|
||||||
|
* To force a string that looks like a bool/number, wrap in literal quotes:
|
||||||
|
* --set foo.x='"true"' → x = "true"
|
||||||
|
*
|
||||||
|
* @param {string} value raw value as received from the --set flag
|
||||||
*/
|
*/
|
||||||
function tomlString(value) {
|
function tomlString(value) {
|
||||||
const s = String(value);
|
const s = String(value);
|
||||||
|
if (s === 'true' || s === 'false') return s;
|
||||||
|
if (/^-?\d+$/.test(s)) return s;
|
||||||
|
if (/^-?\d+\.\d+$/.test(s)) return s;
|
||||||
// Per the TOML spec, basic strings escape `\`, `"`, and control characters.
|
// Per the TOML spec, basic strings escape `\`, `"`, and control characters.
|
||||||
return (
|
return (
|
||||||
'"' +
|
'"' +
|
||||||
|
|
@ -142,8 +158,10 @@ function upsertTomlKey(content, section, key, valueToml) {
|
||||||
const hadTrailingNewline = lines.length > 0 && lines.at(-1) === '';
|
const hadTrailingNewline = lines.length > 0 && lines.at(-1) === '';
|
||||||
if (hadTrailingNewline) lines.pop();
|
if (hadTrailingNewline) lines.pop();
|
||||||
|
|
||||||
// Locate the target section.
|
// Locate the target section. Tolerates a trailing inline comment on the
|
||||||
const sectionStart = lines.findIndex((line) => line.trim() === section);
|
// header (`[core] # personal`) and a header line with non-newline
|
||||||
|
// trailing whitespace — `line.trim() === section` would miss both.
|
||||||
|
const sectionStart = lines.findIndex((line) => isSectionHeader(line, section));
|
||||||
if (sectionStart === -1) {
|
if (sectionStart === -1) {
|
||||||
// Section doesn't exist — append a new block. Pad with a blank line if
|
// Section doesn't exist — append a new block. Pad with a blank line if
|
||||||
// the file is non-empty so sections stay visually separated.
|
// the file is non-empty so sections stay visually separated.
|
||||||
|
|
@ -168,11 +186,12 @@ function upsertTomlKey(content, section, key, valueToml) {
|
||||||
const match = lines[i].match(keyPattern);
|
const match = lines[i].match(keyPattern);
|
||||||
if (match) {
|
if (match) {
|
||||||
const indent = match[1];
|
const indent = match[1];
|
||||||
// Preserve trailing comment if present. We split on the first `#` that
|
// Preserve trailing comment if present. `findInlineCommentStart` tracks
|
||||||
// is preceded by whitespace — TOML strings can't contain unescaped `#`
|
// double-quoted string state so a `#` inside a value like
|
||||||
// in basic-string form so this is safe for the values we emit.
|
// `"path with # hash"` isn't mistaken for a comment marker (per TOML
|
||||||
|
// spec, basic strings may contain unescaped `#`).
|
||||||
const tail = match[2];
|
const tail = match[2];
|
||||||
const commentIdx = tail.search(/\s+#/);
|
const commentIdx = findInlineCommentStart(tail);
|
||||||
const commentSuffix = commentIdx === -1 ? '' : tail.slice(commentIdx);
|
const commentSuffix = commentIdx === -1 ? '' : tail.slice(commentIdx);
|
||||||
lines[i] = `${indent}${key} = ${valueToml}${commentSuffix}`;
|
lines[i] = `${indent}${key} = ${valueToml}${commentSuffix}`;
|
||||||
return lines.join('\n') + (hadTrailingNewline ? '\n' : '');
|
return lines.join('\n') + (hadTrailingNewline ? '\n' : '');
|
||||||
|
|
@ -194,6 +213,51 @@ function escapeRegExp(s) {
|
||||||
return s.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
|
return s.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match a TOML section header line against a target like `[core]`. Tolerates
|
||||||
|
* leading/trailing whitespace and a trailing inline comment, which the
|
||||||
|
* previous `line.trim() === section` check missed.
|
||||||
|
*/
|
||||||
|
function isSectionHeader(line, target) {
|
||||||
|
const trimmed = line.trimStart();
|
||||||
|
if (!trimmed.startsWith(target)) return false;
|
||||||
|
const after = trimmed.slice(target.length);
|
||||||
|
return /^\s*(?:#.*)?$/.test(after);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the start index of an inline `# comment` in a TOML value tail,
|
||||||
|
* tracking double-quoted string state so a `#` inside a string literal is
|
||||||
|
* not treated as a comment. Returns the index of the whitespace run that
|
||||||
|
* precedes the `#` (matching the contract of the old `/\s+#/` regex), or
|
||||||
|
* -1 if there's no inline comment.
|
||||||
|
*/
|
||||||
|
function findInlineCommentStart(text) {
|
||||||
|
let inString = false;
|
||||||
|
let escaped = false;
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
const ch = text[i];
|
||||||
|
if (escaped) {
|
||||||
|
escaped = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '\\') {
|
||||||
|
escaped = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '"') {
|
||||||
|
inString = !inString;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '#' && !inString) {
|
||||||
|
let j = i;
|
||||||
|
while (j > 0 && /\s/.test(text[j - 1])) j--;
|
||||||
|
if (j < i) return j;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Look up `[section] key` in a TOML file. Returns true if the file exists,
|
* Look up `[section] key` in a TOML file. Returns true if the file exists,
|
||||||
* the section is present, and `key` is set within it. Used by
|
* the section is present, and `key` is set within it. Used by
|
||||||
|
|
@ -205,29 +269,66 @@ async function tomlHasKey(filePath, section, key) {
|
||||||
if (!(await fs.pathExists(filePath))) return false;
|
if (!(await fs.pathExists(filePath))) return false;
|
||||||
const content = await fs.readFile(filePath, 'utf8');
|
const content = await fs.readFile(filePath, 'utf8');
|
||||||
const lines = content.split('\n');
|
const lines = content.split('\n');
|
||||||
const sectionStart = lines.findIndex((line) => line.trim() === section);
|
|
||||||
if (sectionStart === -1) return false;
|
|
||||||
const keyPattern = new RegExp(`^\\s*${escapeRegExp(key)}\\s*=`);
|
const keyPattern = new RegExp(`^\\s*${escapeRegExp(key)}\\s*=`);
|
||||||
for (let i = sectionStart + 1; i < lines.length; i++) {
|
// Walk every line tracking whether we're inside the target section. This
|
||||||
if (/^\s*\[/.test(lines[i])) return false;
|
// both tolerates inline-commented headers (`[core] # personal`) and
|
||||||
if (keyPattern.test(lines[i])) return true;
|
// handles the edge case where the same section appears more than once
|
||||||
|
// (legal in TOML — tomllib merges them — but the previous `findIndex`
|
||||||
|
// only checked the first block, misrouting `--set`).
|
||||||
|
let inTargetSection = false;
|
||||||
|
for (const line of lines) {
|
||||||
|
if (/^\s*\[/.test(line)) {
|
||||||
|
inTargetSection = isSectionHeader(line, section);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inTargetSection && keyPattern.test(line)) return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up which prompt keys in `core/module.yaml` are declared `scope: user`.
|
||||||
|
* Used so `--set` routes core scope:user keys (user_name, communication_language)
|
||||||
|
* to the global identity file the installer's writeGlobalUserCore writes to,
|
||||||
|
* rather than polluting project config.toml as a team-scope key.
|
||||||
|
* Returns an empty set if core isn't installed or the schema can't be parsed.
|
||||||
|
*/
|
||||||
|
async function loadCoreUserScopeKeys() {
|
||||||
|
const result = new Set();
|
||||||
|
try {
|
||||||
|
const corePath = await resolveInstalledModuleYaml('core');
|
||||||
|
if (!corePath) return result;
|
||||||
|
const parsed = yaml.parse(await fs.readFile(corePath, 'utf8'));
|
||||||
|
if (!parsed || typeof parsed !== 'object') return result;
|
||||||
|
for (const [key, value] of Object.entries(parsed)) {
|
||||||
|
if (value && typeof value === 'object' && 'prompt' in value && value.scope === 'user') {
|
||||||
|
result.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Schema unavailable — fall back to two-tier routing.
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply parsed `--set` overrides to the central TOML files written by the
|
* Apply parsed `--set` overrides to the central TOML files written by the
|
||||||
* installer. Called at the end of an install / quick-update.
|
* installer. Called at the end of an install / quick-update.
|
||||||
*
|
*
|
||||||
* Routing per (module, key):
|
* Routing per (module, key):
|
||||||
* 1. If `_bmad/config.user.toml` already has `[section] key`, update there
|
* 1. If `~/.bmad/config.user.toml` already has `[section] key`, update there
|
||||||
* (user-scope key like `core.user_name`, `bmm.user_skill_level`).
|
* (global identity store — same place writeGlobalUserCore writes to).
|
||||||
* 2. Otherwise update `_bmad/config.toml` (team scope, the default).
|
* 2. Else if `_bmad/config.user.toml` already has `[section] key`, update
|
||||||
|
* there (project-scoped personal override).
|
||||||
|
* 3. Else if the key is a known core scope:user key (user_name,
|
||||||
|
* communication_language per core/module.yaml), route to global. Otherwise
|
||||||
|
* writeCentralConfig's next-install partition would strip the value out
|
||||||
|
* of project files.
|
||||||
|
* 4. Otherwise update `_bmad/config.toml` (team scope, the default).
|
||||||
*
|
*
|
||||||
* The schema-correct user/team partition lives in `manifest-generator`. We
|
* The schema-correct partition lives in `manifest-generator`. We only reach
|
||||||
* intentionally don't re-read module schemas here — the only goal is to
|
* for the core schema (small, always present) so the first-run case for
|
||||||
* match the file the installer just wrote the key to. For brand-new keys
|
* `--set core.user_name=...` doesn't land in the wrong file.
|
||||||
* (not in either file yet), team scope is the safe default.
|
|
||||||
*
|
*
|
||||||
* @param {Object<string, Object<string, string>>} overrides
|
* @param {Object<string, Object<string, string>>} overrides
|
||||||
* @param {string} bmadDir absolute path to `_bmad/`
|
* @param {string} bmadDir absolute path to `_bmad/`
|
||||||
|
|
@ -240,6 +341,8 @@ async function applySetOverrides(overrides, bmadDir) {
|
||||||
|
|
||||||
const teamPath = path.join(bmadDir, 'config.toml');
|
const teamPath = path.join(bmadDir, 'config.toml');
|
||||||
const userPath = path.join(bmadDir, 'config.user.toml');
|
const userPath = path.join(bmadDir, 'config.user.toml');
|
||||||
|
const globalPath = globalUserConfigPath();
|
||||||
|
const coreUserKeys = await loadCoreUserScopeKeys();
|
||||||
|
|
||||||
for (const moduleCode of Object.keys(overrides)) {
|
for (const moduleCode of Object.keys(overrides)) {
|
||||||
// Skip overrides for modules not actually installed. The installer writes
|
// Skip overrides for modules not actually installed. The installer writes
|
||||||
|
|
@ -258,16 +361,35 @@ async function applySetOverrides(overrides, bmadDir) {
|
||||||
const value = moduleOverrides[key];
|
const value = moduleOverrides[key];
|
||||||
const valueToml = tomlString(value);
|
const valueToml = tomlString(value);
|
||||||
|
|
||||||
const userOwnsIt = await tomlHasKey(userPath, section, key);
|
// 3-tier routing: prefer the file that already owns the key; otherwise
|
||||||
const targetPath = userOwnsIt ? userPath : teamPath;
|
// honor core's user-scope partition (so `--set core.user_name` lands in
|
||||||
|
// ~/.bmad on a fresh install, not in project team config).
|
||||||
|
const globalOwnsIt = moduleCode === 'core' && (await tomlHasKey(globalPath, section, key));
|
||||||
|
const userOwnsIt = !globalOwnsIt && (await tomlHasKey(userPath, section, key));
|
||||||
|
const isCoreUserScope = moduleCode === 'core' && coreUserKeys.has(key) && !userOwnsIt;
|
||||||
|
let targetPath;
|
||||||
|
let scope;
|
||||||
|
if (globalOwnsIt || isCoreUserScope) {
|
||||||
|
targetPath = globalPath;
|
||||||
|
scope = 'user';
|
||||||
|
} else if (userOwnsIt) {
|
||||||
|
targetPath = userPath;
|
||||||
|
scope = 'user';
|
||||||
|
} else {
|
||||||
|
targetPath = teamPath;
|
||||||
|
scope = 'team';
|
||||||
|
}
|
||||||
|
|
||||||
// The team file always exists post-install; the user file only exists
|
// The team file always exists post-install; the user/global files only
|
||||||
// if the install wrote at least one user-scope key. If we're routing to
|
// exist once the installer has reason to write to them. If we're routing
|
||||||
// it but it doesn't exist yet, create it with a minimal header so it
|
// to one that doesn't exist yet, create it with a minimal header so it
|
||||||
// has the same shape as installer-written user toml.
|
// has the same shape as installer-written files.
|
||||||
let content = '';
|
let content = '';
|
||||||
if (await fs.pathExists(targetPath)) {
|
if (await fs.pathExists(targetPath)) {
|
||||||
content = await fs.readFile(targetPath, 'utf8');
|
content = await fs.readFile(targetPath, 'utf8');
|
||||||
|
} else if (targetPath === globalPath) {
|
||||||
|
await fs.ensureDir(path.dirname(globalPath));
|
||||||
|
content = '# Global personal BMad config (see ~/.bmad).\n';
|
||||||
} else {
|
} else {
|
||||||
content = '# Personal overrides for _bmad/config.toml.\n';
|
content = '# Personal overrides for _bmad/config.toml.\n';
|
||||||
}
|
}
|
||||||
|
|
@ -277,8 +399,8 @@ async function applySetOverrides(overrides, bmadDir) {
|
||||||
applied.push({
|
applied.push({
|
||||||
module: moduleCode,
|
module: moduleCode,
|
||||||
key,
|
key,
|
||||||
scope: userOwnsIt ? 'user' : 'team',
|
scope,
|
||||||
file: path.basename(targetPath),
|
file: targetPath === globalPath ? '~/.bmad/config.user.toml' : path.basename(targetPath),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,16 @@ class UI {
|
||||||
const messageLoader = new MessageLoader();
|
const messageLoader = new MessageLoader();
|
||||||
await messageLoader.displayStartMessage();
|
await messageLoader.displayStartMessage();
|
||||||
|
|
||||||
|
// Probe the local Python before any other prompts: several BMAD features
|
||||||
|
// (memlog session memory, TOML config resolution) need Python 3.11+ at
|
||||||
|
// runtime. Warn-don't-block, but require an explicit ack so the warning
|
||||||
|
// can't scroll past unseen. The installer runs in the destination
|
||||||
|
// environment, so probing PATH here tests the right machine.
|
||||||
|
// Skip the ack when stdin isn't a TTY (CI/Docker/piped): clack's select
|
||||||
|
// on closed stdin resolves to cancel, which would silently exit 0.
|
||||||
|
const { checkPythonEnvironment } = require('./core/python-check');
|
||||||
|
await checkPythonEnvironment({ nonInteractive: !!options.yes || !process.stdin.isTTY });
|
||||||
|
|
||||||
// Parse channel flags (--channel/--all-*/--next=/--pin) once. Warnings
|
// Parse channel flags (--channel/--all-*/--next=/--pin) once. Warnings
|
||||||
// are surfaced immediately so the user sees them before any git ops run.
|
// are surfaced immediately so the user sees them before any git ops run.
|
||||||
const channelOptions = parseChannelOptions(options);
|
const channelOptions = parseChannelOptions(options);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue