The original implementation tried to integrate `--set` with the prompt /
result-template / schema-strict-partition system: pre-seeding answers,
filtering questions, evaluating function defaults, tracking override
keys for partition exemption, mirroring carry-forward in two collection
helpers, threading state through Config + ui.js + collection helpers +
manifest writer. ~900 lines spawned across 4 review rounds, with bugs
the bots kept finding because every change touched a different layer.
The simpler model: `--set` is a post-install patch. The installer runs
its normal flow untouched, then `applySetOverrides` upserts each value
into `_bmad/config.toml` (team scope) or `_bmad/config.user.toml` (user
scope) AND into `_bmad/<module>/config.yaml` so declared keys carry
forward via the existingValue path on the next install.
What gets ripped out
- All `setOverrides` plumbing through OfficialModules (constructor
field, applyOverridesAfterSeeding, _trackUnknownKeysAsOverrides,
declaredResultKeys, override classification + pre-write +
question-filter + two-pass function-defaults + carry-forward in
collectModuleConfig, _trackUnknownKeysAsOverrides calls in
collectModuleConfigQuick, headless-branch additions in
Installer.build). official-modules.js reset to its pre-#1663 baseline
(commit 48a7ec8b).
- `setOverrideKeys` field on Config, threading from ui.js, partition
exemption parameter on `manifest-generator.writeCentralConfig`.
- The "ignored under quick-update" warning in install.js — `--set` is
now a uniform post-install patch, so it works the same way for
quick-update as for a regular install.
What stays
- `tools/installer/set-overrides.js` parser with the prototype-pollution
guard, prefixed by the new `applySetOverrides` / `upsertTomlKey` /
`tomlString` / `tomlHasKey` helpers.
- `tools/installer/list-options.js` — small standalone discovery
helper, untouched.
- The `--set` and `--list-options` CLI flag registration in
`commands/install.js`.
- ui.js `collectModuleConfigs` retains the early-feedback warning for
overrides targeting modules not in the install set (and now also
filters them out of `setOverrides` before threading).
Routing rules (post-install patch)
- If `_bmad/config.user.toml` already has `[section] key`, update it
there (so user-scope keys like `core.user_name` and
`bmm.user_skill_level` keep their proper file).
- Otherwise update `_bmad/config.toml` (team scope, default).
- A module without `_bmad/<module>/config.yaml` (i.e. not installed)
is skipped silently — no orphan `[modules.notamodule]` sections.
Tradeoffs documented in `docs/how-to/install-bmad.md`
- Values are written verbatim — no `result:` template rendering. Pass
`--set bmm.project_knowledge='{project-root}/research'` if you want
the rendered form.
- Carry-forward is automatic for declared schema keys (per-module yaml
→ existingValue → prompt default → accepted under --yes). For keys
outside any module's schema, the value lands in `config.toml` for
the current install but won't be re-emitted on the next install.
Re-pass `--set` if you need it sticky.
- No "key not in schema" validation — whatever you assert is written.
Tests: Suite 44 rewritten. 355 passing (was 351). Coverage now focused
on what matters: parser + pollution guard, tomlString escaping,
upsertTomlKey across insert/replace/missing-section/empty-file/
preserved-newline cases, applySetOverrides happy path + uninstalled-
module skip + missing-user-toml-creation + empty-input no-op,
discoverOfficialModuleYamls / formatOptionsList sanity.
E2E smoke verified across all 6 scenarios:
1. fresh install with mixed declared + undeclared --set → correct files
2. quick-update no --set → declared keys persist via per-module yaml
3. quick-update WITH --set → applies (used to be warned + dropped)
4. --set for unselected module → warned, no orphan section
5. prototype pollution → exit 1
6. --list-options bmm exit 0, --list-options nope exit 1
Net: -158 lines vs HEAD. The complex integration was load-bearing for
edge cases nobody actually needed; the simple post-install patch
covers the real use case (script a config value from CI) without the
schema gymnastics.
Found via end-to-end smoke test, not flagged by either bot review:
`--set bmm.future_thing=x` was persisted to config.toml on install #1
but silently dropped on the next quick-update reinstall, even though
the per-module _bmad/bmm/config.yaml retained it. The central
manifest's schema-strict partition stripped it because
collectModuleConfigQuick (the quick-update helper) never populated
setOverrideKeys for carried-forward unknown keys, and quickUpdate's
installConfig didn't thread setOverrideKeys into the install call.
This is the same bug class as the round-1 fix to collectModuleConfig
(CodeRabbit major #3155145084) but for the quick-update code path,
which has a separate collection helper.
Fix:
- Add OfficialModules._trackUnknownKeysAsOverrides(moduleName, schema)
helper that walks collectedConfig[moduleName] and adds any non-schema
key to setOverrideKeys[moduleName]. Without a schema, every key is
treated as unknown (safe fallback for modules with no module.yaml).
- Call it from all four return paths in collectModuleConfigQuick:
no-schema, parse-failed, hasNoConfig+subheader, silent+no-new-keys,
and the regular end-of-method.
- Mirror ui.collectModuleConfigs's setOverrideKeys conversion in
installer.quickUpdate so the Set→array round-trip lands in
Config.build, and writeCentralConfig sees the exemption list.
Tests: +4 cases — collectModuleConfigQuick carry-forward of unknown
key, declared-key non-tracking under quick-update, and
_trackUnknownKeysAsOverrides no-schema fallback. Total 351 passing.
E2E smoke verified: --set <unknown>=x survives install→quick-update,
install→regular-update, and install→quick-update→regular-update with
a new --set added.
(1) Use process.exitCode instead of process.exit() after --list-options
write (CodeRabbit major). process.exit() forces immediate termination
even with pending I/O, which can truncate buffered writes when stdout
is piped or captured by CI. Await the write callback, set exitCode,
and return so the event loop drains naturally.
(2) Thread setOverrides through Config → OfficialModules.build for
headless callers (CodeRabbit major). Non-UI entry points (direct
installer.install({...}) without going through ui.collectModuleConfigs)
previously got an empty override map. Config now carries setOverrides
and the headless branch of OfficialModules.build also runs
loadExistingConfig + applyOverridesAfterSeeding('core') to mirror the
UI path's semantics. The UI path is unaffected because it takes the
moduleConfigs early-return.
(3) Evaluate function defaults under skipPrompts and accept-defaults
paths (CodeRabbit major). Both branches were dropping function defaults
silently, so any same-module dynamic default (`{other_key}` placeholder
in default:) disappeared under --yes. Two-pass: write non-function
defaults first so the answer bag is populated, then call function
defaults with that bag. Try/catch around the call surfaces resolution
failures as warnings instead of crashing the install.
(4) Track result-only schema keys as declared (Augment medium). A
schema entry with `result:` and no `prompt:` was being classified as
"unknown" when targeted by --set, producing a wrong warning and
overwriting the computed template output with the raw value. Added
declaredResultKeys parallel to declaredPromptKeys; an override on
either is now seeded as the answer so the result template still
renders ({value} substitution preserved). Carry-forward block
refactored to consume the same set.
(5) Diagnose non-object module.yaml under --list-options (Augment low).
The non-object branch silently flipped moduleScopedFailure with no
output. Now emits "module.yaml is not a valid object (got <type>)"
mirroring the catch branch, and the type guard also catches arrays
which typeof reports as 'object'.
(6) Reword --list-options doc cache scope (CodeRabbit minor).
"Installed at least once on this machine" → "currently cached official
modules" with a note that cache can be cleared or absent on ephemeral
CI workers — accurately reflects what the command can discover.
Tests: +4 cases — Config.build setOverrides threading and default,
formatOptionsList non-object yaml diagnostic and ok:false. Total 347
passing.
(J) Prototype pollution guard (CodeRabbit major).
`--set __proto__.x=1` previously mutated Object.prototype because
`overrides.__proto__` returned Object.prototype on a plain object,
and assigning `[key]=value` polluted every plain object in the process.
Verified the attack reproduces on f1c9e12 and is now blocked: parser
rejects __proto__/prototype/constructor segments, and the maps are
Object.create(null) for defense-in-depth.
(I) Non-zero exit when --list-options <module>'s yaml is unparseable
(CodeRabbit major). formatOptionsList tracks moduleScopedFailure and
returns ok:false in that case; install.js exits 1.
(F) Dynamic defaults can now see --set sibling values (Augment medium).
buildQuestion's function default falls back to
`this.collectedConfig[mod][otherKey]`, but overrides were only in
`allAnswers` (local) at default-evaluation time. Pre-write override
raw values to collectedConfig before the prompt batch so the
fallback resolves. Post-prompt template processing overwrites with
the rendered version.
(E) applyOverridesAfterSeeding no longer bypasses carry-forward when
the schema can't be loaded (Augment low). Restructured: schema-load
is now best-effort; without schema, declaredKeys is an empty Set, so
all overrides are flagged as "unknown" and carry-forward runs against
every prior key. Comment now matches behavior.
(G) Flag placeholder --set <spec> instead of <module.key=value>
(Augment low) — angle brackets in the placeholder were misleading;
the description spells out the spec format.
(H) README wording: "every available key" → "locally-known official
keys (built-in modules plus any external officials cached on this
machine)" (CodeRabbit minor) — accurately reflects scope.
Tests: +2 cases for prototype-pollution rejection. Total 343 passing.
(A) Carry forward unknown core keys in applyOverridesAfterSeeding
(CodeRabbit major). Mirrors collectModuleConfig's carry-forward so
the skip-collection path used by core (when seeded by --yes / legacy
shortcuts) doesn't drop unknown keys on subsequent installs. Without
this, `--set core.future=x` on run #1 would silently disappear on
the next install.
(B) --list-options now exits non-zero on a single-module miss
(CodeRabbit major). formatOptionsList returns { text, ok }; install.js
exits 1 with text on stderr when ok=false, 0 with text on stdout
otherwise. CI scripts catch typos like `--list-options bmn`.
(C) Hermetic Suite 44 discovery tests (CodeRabbit minor). Point
BMAD_EXTERNAL_MODULES_CACHE at a temp dir and restore in a finally
block so test results don't depend on the developer / CI cache state.
(D) Case-insensitive --list-options filter (Augment). Discovery
already dedupes case-insensitively; the filter now matches the same
way, so `--list-options BMM` and `--list-options bmm` both find the
bmm built-in.
Tests: +7 cases (uppercase listing, ok flag, core carry-forward).
Total 340 passing.
Carry forward unknown --set keys across upgrades (CodeRabbit major).
Without this, an unknown key like --set bmm.future_thing=hello landed in
config.toml on run #1 but was silently dropped on the next install
because collectModuleConfig rebuilds collectedConfig from prompt answers
only. collectModuleConfig now copies any non-declared keys from
_existingConfig into collectedConfig and tracks them in setOverrideKeys
so the manifest writer's schema-strict partition keeps them.
Guard single-select rendering with Array.isArray (CodeRabbit major):
a malformed truthy non-array would have aborted --list-options.
Unify core override handling: move the inline post-collection block
from ui.js into OfficialModules.applyOverridesAfterSeeding so core and
non-core take a single validated path. Removes duplicated schema-load
logic and inline requires from ui.js.
Remove dead code: findOfficialModuleYaml and readDeclaredKeys in
set-overrides.js were exported but never imported. Drop them and
their path/fs/yaml/project-root imports — the module is now pure
string-parsing with zero deps.
Doc fix: change "silently ignored" to "ignored with a warning" for
the --action quick-update note (Augment + CodeRabbit).
Polish: clearer flag placeholder (--set <module.key=value> instead of
the misleading <key=value>), trim-asymmetry rationale comment in
parseSetEntry, dedupe rationale in list-options.
Tests: +6 cases — collectModuleConfig --set application end-to-end
(prompt-skip with template rendering), and carry-forward of unknown
keys from _existingConfig. Total 333 passing.
`--set <module>.<key>=<value>` (repeatable) sets any module config option
non-interactively. Scales to every module without growing the CLI surface
per option, and persists into _bmad/config.toml so values survive upgrades.
`--list-options [module]` prints every available --set key for built-in
and locally-cached official modules (community/custom users read their own
module.yaml). Pass a module code to scope the listing.
Validation rules, all non-fatal:
- Module not in --modules → warn and drop the value.
- Key not declared in module.yaml → warn but persist (forward-compat).
The manifest writer's schema-strict partition exempts these so they
survive into config.toml even though the schema doesn't know them.
- Malformed --set syntax → exit non-zero up front.
The legacy core shortcuts (--user-name, --output-folder, etc.) remain
supported as aliases for `--set core.<key>=<value>`. --set with
--action quick-update is ignored with a warning since quick-update
preserves the existing answers by design.
Files:
- tools/installer/set-overrides.js (new): parser
- tools/installer/list-options.js (new): discovery + formatter
- tools/installer/commands/install.js: flags + early validation
- tools/installer/ui.js: parse, warn-on-unselected, thread to OfficialModules
- tools/installer/modules/official-modules.js: pre-fill answers, persist unknowns
- tools/installer/core/config.js + installer.js: carry setOverrideKeys through
- tools/installer/core/manifest-generator.js: partition exempts override keys
- test/test-installation-components.js: +15 cases (Suite 44)
- docs/how-to/install-bmad.md, README.md: --set as preferred non-interactive path
Closes#1663
* fix(config): promote project_name to core, fixes#2279
project_name was a bmm-specific prompt despite being a universal
project-level concept used by every module — including core skills like
bmad-brainstorming, which loads from _bmad/core/config.yaml and was
silently broken because project_name lived under bmm. Users without bmm
installed could not run brainstorming at all.
Move:
- src/core-skills/module.yaml: declare project_name with prompt
"What is your project called?" and default {directory_name}, matching
what bmm previously had.
- src/bmm-skills/module.yaml: remove the bmm definition; add project_name
to the "Variables from Core Config inserted" header comment so
contributors can see what's inherited.
Migration for existing installs:
- tools/installer/modules/official-modules.js: after loadExistingConfig
reads each per-module config.yaml, hoist any keys that are now declared
in core but appear under non-core modules. Without this, the partition
logic in writeCentralConfig (which strips core keys from non-core
buckets) would silently drop the user's prior project_name on the next
quick-update. Generic — handles project_name today and any future
module→core promotions.
- The hoist preserves precedence: an existing core value beats a stale
module-side copy.
--yes seed:
- tools/installer/ui.js: add project_name to the hardcoded core seed
(using path.basename(directory) to match the {directory_name} default)
so non-interactive fresh installs populate it. Without this the seed
silently omits project_name and core skills fall back to literals.
Tests:
- test/test-installation-components.js Suite 43 (9 assertions) covers
the schema move, the loadExistingConfig hoist, and the precedence rule.
- Suite 35 fixture updated: project_name moved from bmm bucket to core,
with a stale bmm copy left in place to verify it gets stripped.
Verified manually:
- Fresh install -y: project_name lands in [core] of config.toml.
- Existing install with project_name in bmm/config.yaml: quick-update
hoists it to [core] and strips it from [modules.bmm].
* fix(installer): harden config-load against malformed config.yaml
Per augment review on #2348: loadExistingConfig stored any truthy
yaml.parse result (including scalars like '42'), which would later crash
_hoistCoreKeysFromLegacyModuleConfigs at \`key in cfg\` with
"Cannot use 'in' operator to search for ... in 42".
- loadExistingConfig: only keep parses that are plain objects (not
scalars or arrays). A corrupt config.yaml is now treated the same as
a parse error — skipped, not crashed-on.
- _hoistCoreKeysFromLegacyModuleConfigs: belt-and-suspenders type guards
on _existingConfig.core (in case it's populated by some other path)
and on each module cfg in the loop.
- Test Suite 43 adds 2 assertions covering a scalar core/config.yaml:
loadExistingConfig must not crash, and bmm.project_name must still
hoist into a clean core bucket.
* fix: use full update path when --custom-source is passed with --yes
When --yes is used on an existing install, the installer auto-selects
quick-update. However, quick-update never re-clones custom module repos
— it only reads whatever is already in the cache. This means
--custom-source with a new version tag (e.g. @1.1.0) is silently
ignored and the previously cached version (e.g. 1.0.1) is reported as
"already up to date".
Default to the full update path when --custom-source is present, so the
custom repo gets re-cloned at the requested version. Also ensure all
installed modules are included in the selection when --yes is combined
with --custom-source, preventing previously installed modules from being
removed.
* fix: address review feedback on choices.find() and comment clarity
* style: prettier fix for empty-body methods in custom-module-manager
---------
Co-authored-by: Brian <bmadcode@gmail.com>
* fix(installer): preserve http protocol in custom module clone URLs
Previously, parseSource() hardcoded 'https://' when building cloneUrl,
forcing http:// Git URLs (e.g., internal LAN hosts) to upgrade to https.
This broke cloning for self-hosted Git servers that only serve over HTTP.
- Capture the protocol from the regex match instead of discarding it
- Update JSDoc and inline comments to document HTTP support
- Update install-custom-modules docs (EN, ZH, VN) to list HTTP URL type
Fixes the --custom-source flag for http:// addresses.
* docs(installer): update JSDoc to mention HTTP support in cloneRepo
Add HTTP to the cloneRepo method's JSDoc param description.
Also fixes minor spacing in empty arrow functions (formatting).
* docs(installer): fix JSDoc annotation for cloneRepo param
Correct @param backtick escaping in cloneRepo JSDoc.
Also documents HTTP as a supported protocol alongside HTTPS and SSH.
---------
Co-authored-by: 关惠民 <9155544@qq.com>
* fix(installer): route community installs through PluginResolver when marketplace.json ships
Community-catalog installs ignored .claude-plugin/marketplace.json, so modules
that nest module.yaml inside a setup skill's assets/ directory (e.g. Strategy 2
in PluginResolver) ended up half-installed: only module-help.csv and the
generated config.yaml landed in _bmad/<code>/, while the actual skill source
trees and module.yaml never got copied. The install would silently emit
"could not locate module.yaml" warnings and leave .agents/skills/ without
the module's skills.
The fix wires the existing PluginResolver onto the community path:
- CommunityModuleManager.cloneModule now detects marketplace.json after the
clone+ref-checkout completes and runs PluginResolver. The resolution is
stamped with channel/sha/registryApprovedTag/registryApprovedSha and cached
in _pluginResolutions, mirroring the existing _resolutions cache.
- OfficialModules.install consults the community plugin resolution and
delegates to installFromResolution (the same code path custom-source
installs already use). installFromResolution branches on communitySource
to write source: 'community' with the registry's approved tag/sha and
channel.
- resolveInstalledModuleYaml now searches the community-modules cache root
in addition to the external-modules cache, and the BMB setup-skill detector
walks src/skills/ and skills/ (not just the repo root) so collectAgents
FromModuleYaml and writeCentralConfig can find module.yaml in nested
marketplace-plugin layouts.
Backward compatibility: repos without marketplace.json (e.g. WDS, which
declares module_definition: src/module.yaml at the root) continue through
the legacy findModuleSource path with no behavior change. Verified against
the live zarlor/suno-band-manager community module and a 23-check fixture
suite covering Suno-shape, WDS-shape, and bare-repo layouts.
* fix(installer): harden community marketplace.json resolution path
Address review feedback on the community marketplace.json install path:
- Wrap PluginResolver.resolve() in try/catch so a malformed plugin entry
falls through to the legacy install path with a warn instead of
crashing cloneModule.
- Stop mutating the resolver's return object; shallow-clone before
stamping community provenance so install state cannot leak back into
resolver-owned objects.
- Warn when _selectPluginForModule lands on the single-plugin fallback
with a name that doesn't match the registry code or module_definition
hint, so a misconfigured marketplace.json can't silently install the
wrong plugin.
- Add CommunityModuleManager.resolveFromCache() and call it from
OfficialModules.install() when the in-process plugin cache is empty,
so callers that reach install() without pre-cloning still get the
marketplace-aware path. Reuses an existing channel resolution when
present, otherwise synthesizes a stable-channel stub from the registry
entry plus the cached repo's HEAD.
- Align installFromResolution()'s returned versionInfo.version with
manifestEntry.version precedence (communityVersion || cloneRef || ...)
so downstream summaries match what was written to the manifest.
Tests: lint, format:check, lint:md, test:install (290), test:channels
(83), test:refs (7) all green.
* feat(installer): channel-based version resolution for external modules
Adds stable/next/pinned channel resolution so external/community modules
install at released git tags by default instead of tracking main HEAD.
Manifest now records channel, resolved version, and SHA per module for
reproducible installs.
CLI flags: --channel, --all-stable, --all-next, --next=CODE (repeatable),
--pin CODE=TAG (repeatable). Precedence: pin > next > channel > registry
default > stable. --yes accepts patch/minor upgrades but refuses majors.
Interactive "Ready to install (all stable)?" gate with a per-module
picker (stable/next/pin) when declined. Re-install prompts classify tag
diffs as patch/minor/major with semver-class-dependent defaults.
Legacy version:null manifests get a one-time migration prompt.
Custom modules gain an optional @<ref> URL suffix for pinning (https,
ssh, /tree/<ref>/subdir forms supported; local paths rejected).
Community modules honor --next/--pin overrides with a curator-bypass
warning; default path still enforces the approved SHA.
Quick-update now reads the manifest's recorded channel per module so
pinned installs don't silently roll forward.
* feat(installer): interactive channel switch, upgrade refusal, unified docs
Builds on the channel-resolution foundation. The installer now lets users
flip a module between stable, next, and pinned after install — either
interactively via a "Review channel assignments?" gate, or by flag. Quick
and modify re-installs classify stable upgrades; under non-interactive
flows, patches and minors apply automatically but majors are refused with
a pointer to --pin.
Fallback behavior for GitHub rate-limit / network failures is now cache-
aware: re-installs reuse the recorded ref silently; fresh installs abort
with actionable guidance (set GITHUB_TOKEN or use --next/--pin). Bundled
modules (core, bmm) warn when targeted by --pin or --next so users aren't
left wondering why the flag had no effect.
Install summary labels no longer mangle "main" into "vmain"; next-channel
entries render as "main @ <short-sha>" instead. Bundled modules are now
correctly skipped from all channel prompts and tag-API lookups.
Docs consolidated into a single how-to. install-bmad.md now covers the
interactive flow, the channel model (stable/next/pinned plus the npm
dist-tag axis for core/bmm), the re-install upgrade prompts, the full
flag reference, copy-paste recipes, and troubleshooting. The old
non-interactive-installation.md is reduced to a redirect stub.
* fix(installer): review fixes + unit tests for channel resolution
- ui.js: import parseGitHubRepo; fixes ReferenceError in the
interactive channel picker's stable-tag pre-resolve path.
- community-manager: pinned modules now fetch+checkout the pin tag
on cache refresh instead of resetting to origin/HEAD (was silently
drifting to main on re-install).
- channel-plan: parseChannelOptions returns acceptBypass so --yes
auto-confirms the curator-bypass prompt; headless --next/--pin
installs of community modules no longer hang.
- community-manager: simplify recordedVersion (dead ternary branch).
- custom-module-manager: drop "or sha" from the @<ref> comment
(git clone --branch rejects raw SHAs); update-path fetches
origin <ref> so /tree/<branch>/ URLs work too.
- install-bmad.md: rename "Headless / CI installs" to "Headless CI
installs" so the stub's #headless-ci-installs anchor resolves.
- test/test-installer-channels.js: 83 unit tests for channel-plan
and channel-resolver pure modules; wired into npm test as
test:channels.
* fix(installer): address CodeRabbit review findings
- ui.js: skip stable-channel upgrade classification when the user has
already declared intent via --pin/--next=/--channel or the review
gate. Prevents the decline / major-refused / fetch-error branches
from silently overwriting an explicit pin with prev.version.
- external-manager.js: short-circuit cloneExternalModule when the
requested plan matches an existing in-process resolution and the
cache is valid. Avoids redundant resolveChannel() + git fetch on
every same-plan lookup in a single install.
- installer.js: fall back to CommunityModuleManager.getResolution()
when no external resolution exists, so community module result
rows carry newChannel/newSha instead of null under --next/--pin.
- installer.js: don't label a module as "no change" when its version
string is 'main'/'HEAD' — the SHA may have moved and preVersions
doesn't track the prior SHA. Show "(refreshed)" instead.
- official-modules.js: match versionInfo.version to the manifest's
cloneRef || (hasGitClone ? 'main' : version) expression so summary
lines report the cloned ref for git-backed custom installs.
- install-bmad.md: clarify that sha is only written for git-backed
modules and that rerunning the same --modules on another machine
does not reproduce stable-channel installs — convert recorded tags
into explicit --pin flags for cross-machine reproducibility.
* feat(skills): TOML-based agent customization with stdlib Python resolver
Re-applies PR #2282's three-layer customization model (skill defaults →
team → user) but swaps YAML for TOML and uv for stdlib tomllib. Users
no longer need uv, pip, or a virtualenv — plain python3 (3.11+) is
sufficient, since tomllib shipped in the standard library.
## Schema changes vs PR #2282
- Flat agent schema: fields live directly under [agent], no nested
metadata/persona sub-tables. Easier to author, less indentation.
- Non-configurable identity: name and title are declared in
customize.toml as source-of-truth metadata (for future skill-manifest
generation) but SKILL.md ignores overrides there — identity is
hardcoded to preserve brand recognition.
- role redefined: now describes what the skill does for the user
within its module phase, not a restatement of the title.
- persistent_facts replaces the activation-time file-context load AND
the old memories concept. Entries can be literal sentences or
file: prefixed paths/globs; avoids collision with the upcoming
runtime memory sidecar.
- activation_steps_prepend / activation_steps_append harmonized across
agents and workflows (replaces agent-specific critical_actions).
- [workflow] namespace mirrors [agent] for workflow customization.
Same four structural rules, same field vocabulary.
## Resolver (src/scripts/resolve_customization.py)
Four purely structural merge rules, zero field-name hardcoding:
- Scalars: override wins
- Tables: deep merge
- Arrays of tables where every item has `code` or `id`: merge by
that key (matching keys replace, new keys append)
- Any other array: append
No removal mechanism — overrides cannot delete base items. Fork the
skill or override by code with a no-op value to suppress defaults.
## Agents ported (6)
All six BMad agents now ship customize.toml + rewritten SKILL.md:
analyst (Mary), tech-writer (Paige), pm (John), ux-designer (Sally),
architect (Winston), dev (Amelia). Each uses the same 8-step
activation template: resolve → execute prepend → adopt persona →
load persistent facts → load config → greet (with {agent.icon}) →
execute append → dispatch or present menu.
Step 8 supports fast-path invocation: "hey Mary, let's brainstorm"
dispatches the matching menu item directly after greeting, skipping
the menu render when intent is clear. Chat, clarifying questions,
and bmad-help remain available when nothing on the menu fits.
## Installer + tooling
- _bmad/scripts/ provisioned on install (copies src/scripts/)
- _bmad/custom/ seeded with .gitignore for *.user.toml on fresh install
- Non-module-dir filter extended to skip _memory, memory, docs,
scripts, and custom when scanning for modules
- Dead _config/agents/ directory no longer created
- metadata.capabilities removed from agent-manifest.csv and schema
- eslint config extended to cover src/scripts/**
- validate-file-refs.js knows about custom/ as install-only
## Deferred for follow-up
- bmad-product-brief workflow port (the pilot that demonstrates
[workflow] + on_complete)
- Translated docs (cs/fr/vi-vn/zh-cn) — regenerate from English
* feat(skills): port bmad-product-brief to TOML workflow customization
Completes the customization surface rollout by giving the product-brief
workflow the same override model as the six BMad agents, under the
[workflow] namespace instead of [agent].
## customize.toml
Mirrors the agent shape under [workflow] with:
- activation_steps_prepend / activation_steps_append (harmonized across
agents and workflows — same field names, same append semantics)
- persistent_facts with the file: convention, seeded with
file:{project-root}/**/project-context.md
- on_complete scalar (renamed from PR #2282's skill_end for clarity —
reads cleaner as "what runs when the workflow completes")
## SKILL.md
7-step workflow activation:
1. Resolve workflow block
2. Execute prepend steps
3. Load persistent facts (file: or literal)
4. Load config
5. Greet if not already
6. Execute append steps
7. Stage 1 — Understand Intent
python3 + stdlib tomllib invocation; no uv required.
## Prompt file changes
- Path normalization: ../agents/ → agents/, ../resources/ → resources/,
bare foo.md → prompts/foo.md. All references now resolve from the
skill root (matches the convention documented in SKILL.md).
- Paths: meta-line added to each of the 4 prompt files that reference
other files, reinforcing "bare paths resolve from skill root" so the
LLM doesn't lose the convention when operating two hops into a
prompt chain.
- finalize.md terminal stage now calls the resolver for
workflow.on_complete — non-empty values run as the final step.
## Validation
- Resolver output verified: 4 workflow fields returned cleanly.
- validate-file-refs.js: 254 files scanned, 139 refs checked, 0 broken.
- test:refs: passing.
* docs(skills): enterprise customization recipes + workflow template variable
Three independent improvements bundled because they share the same
surface (workflow/agent customization) and landed from the same design
discussion:
## Fallback sentence disambiguated (7 SKILL.md files)
The "if the script fails" fallback used to say `{project-root}/_bmad/
custom/{skill-name}.toml` for the team override and then just `{skill-
name}.user.toml` for the user override, leaving the user file's
location implicit. LLMs could reasonably guess skill root or project
root instead. Replaced with an unambiguous numbered list that spells
out the full path for every file in the merge chain.
## Product-brief: stage promotion + brief_template variable
- Promoted `## Stage 1: Understand Intent` from a nested step inside
"On Activation" to a top-level section. The previous "Step 7: Stage
1 — Understand Intent → Proceed to Stage 1 below" was mechanical
numbering pretending to be a step. Activation now ends cleanly at
Step 6; Stage 1 is a peer section.
- Added `brief_template` as a workflow-level scalar customization
defaulting to `resources/brief-template.md`. Stage 4 reads
`{workflow.brief_template}` instead of the hardcoded path, so orgs
can point at their own template under `{project-root}/...` without
forking the skill.
## New doc: docs/how-to/extend-bmad-for-your-org.md
Four worked recipes that together cover most enterprise scenarios:
1. Shape an agent across every workflow it dispatches (dev agent +
Context7 MCP + Linear search — the highest-leverage pattern)
2. Enforce org conventions inside a specific workflow (product-brief
+ compliance-field persistent_facts)
3. Publish completed outputs to external systems (product-brief +
Confluence + Jira via MCP, gated on user confirmation for Jira)
4. Swap in your own output template (product-brief + brief_template
variable swap)
Opens with the two-layer mental model (agent spans workflows,
workflow is local) so readers pick the right granularity before
reading any recipe. Closes with a "Combining Recipes" section
showing all four composed. Cross-linked from customize-bmad.md.
## Validation
- Resolver: workflow.brief_template returns the default cleanly.
- validate-file-refs.js: 254 files scanned, 146 refs checked
(+7 from this commit), 0 broken.
* docs(skills): encourage CLAUDE.md/AGENTS.md reinforcement of critical rules
Added a "Reinforce Global Rules in Your IDE's Session File" section to
extend-bmad-for-your-org.md. BMad customizations only load when a
skill activates, but IDE session files (CLAUDE.md, AGENTS.md, cursor
rules, copilot-instructions) load every turn — worth restating the
most critical rules there too so they survive ad-hoc chat outside a
BMad skill.
Includes a one-line example reinforcing the Recipe 1 Context7 rule,
plus a scope table that clarifies what each layer is for:
- IDE session file: universal, every session, keep succinct
- Agent customization: persona-specific, every dispatched workflow
- Workflow customization: one workflow run
Emphasizes brevity — noise in the session file crowds out signal.
* docs(skills): add Named Agents explanation doc
New docs/explanation/named-agents.md walking through the three-legged
stool (skills + named agents + customization) with the "Hey Mary,
let's brainstorm" activation flow as the narrative thread.
Covers:
- Why named agents vs menu-driven or prompt-driven alternatives
- The 8-step activation flow and what each step contributes
- How customization scales the model beyond a single developer
- Cross-links to the how-to docs for implementation details
Sits alongside brainstorming.md, quick-dev.md, party-mode.md in the
explanation folder — feature narratives for users who want to
understand why BMad is designed the way it is, not just how to use it.
* docs(skills): clarify that keyed-merge requires a single identifier key per array
Review feedback (PR #2284) flagged that the merge-rules wording was
ambiguous: "every item has a `code` or `id` field" could reasonably
be read as "each item individually has at least one of the two",
allowing arrays to mix `code` and `id` across items.
The resolver has always required all items share the *same* identifier
key (all `code`, or all `id`). Mixed arrays fall through to append —
intentional, because mixing identifier keys within one array is a
schema smell and any guess about which key should merge creates a
worse trap than the append-fallback.
Clarified in three places:
- Merge-rules table in customize-bmad.md: "every item shares the
**same** identifier field"
- `code`/`id` convention paragraph: "pick **one** convention ... and
stick with it across the whole array"
- Resolver docstring and `_detect_keyed_merge_field` docstring:
explicit note that mixed arrays fall through with rationale
No behavior change.
* docs(skills): address CodeRabbit review — fallback rules, OS claim, headless greeting
Three fixes from PR #2284 review feedback:
## 1. Fallback merge wording (7 SKILL.md files)
Every SKILL.md told the LLM to merge the three customization files
"in priority order (later wins)" when the resolver fails. That reads
as shallow last-write-wins — but the resolver does structural merge
(scalars override, tables deep-merge, code/id-keyed arrays merge by
key, other arrays append). Following the old wording manually would
have silently stripped base `principles`, `persistent_facts`, and
`menu` items whenever a team override was present.
Expanded the fallback sentence to restate the four structural rules
explicitly, matching the resolver's behavior.
Applied to all 6 agents + bmad-product-brief workflow.
## 2. Python 3.11 / OS shipping claim (customize-bmad.md)
The docs claimed "macOS 13+, Ubuntu 22.04+, Debian 12+, Fedora 37+
all ship 3.11 or newer." Inaccurate — Ubuntu 22.04 defaults `python3`
to 3.10.6 (3.11 is a separate package), and macOS doesn't really
ship Python by default anymore.
Replaced with honest guidance: check `python3 --version` and note
that macOS without Homebrew and Ubuntu 22.04 default to 3.10 or
earlier.
## 3. Autonomous mode greeting gate (bmad-product-brief)
Product-brief's activation-mode detection documents autonomous mode
as "produce complete brief without interaction" — but Step 5 greeted
unconditionally, adding conversational output before the headless
artifact. Gated the greeting on `{mode}` != `autonomous`.
## Dismissed (replied on thread)
- `.gitignore` migration from *.user.yaml to *.user.toml: YAML
installer code was in reverted #2282, never released. No users
affected. Same rationale as Augment's earlier thread.
Validated: 254 files, 146 refs, 0 broken. test:refs 7/7,
test:install 242/242.
* docs: rename Extend to Expand throughout customization docs
Three-layer customization (skill defaults → team → user) for BMad agents
and any skill that opts in. Users edit `_bmad/custom/{skill-name}.yaml`
(team, committed) or `{skill-name}.user.yaml` (personal, gitignored);
customizations survive updates.
Resolver is a Python script using PEP 723 inline metadata, invoked via
`uv run` so deps auto-install into a cached isolated env on first call.
This aligns with Anthropic's Agent Skills spec and BMB conventions, and
keeps the dependency declared (scannable by pip-audit/Dependabot) rather
than vendored.
## Design choices
- **Agent identity is hardcoded** in SKILL.md (name, title, Overview prose)
so skills can be invoked reliably by role *or* default name. Brand
recognition is preserved; customization shapes behavior, not identity.
- **Luminary-anchored personas** (e.g. "Channels Martin Fowler's
pragmatism and Werner Vogels's cloud-scale realism") deliver ~55%
token savings per agent while preserving distinctive voice beats.
- **Universal per-field merge rules** with v6.1-compatible agent
semantics: metadata shallow-merge, persona replace, critical_actions
and memories append, menu merge-by-code, all else deep-merge.
- **Workflow customization** shares the same surface — `bmad-product-brief`
pilots `activation_steps_prepend`, `activation_steps_append`, and
`skill_end` hooks that any workflow-style skill can adopt.
## Infrastructure
- `_bmad/scripts/` houses shared Python scripts (resolver + future).
- `_bmad/custom/` is provisioned empty with a seeded `.gitignore` for
`*.user.yaml` on fresh installs.
- Installer filters ensure `scripts/`, `custom/`, and sidecar-generated
`memory/` directories are never treated as modules.
- Dead v6.1 code cleaned up: `_config/agents/` no longer created,
`metadata.capabilities` removed from schema and CSV manifest.
* feat(installer): use GitHub API as primary fetch with raw CDN fallback
Corporate proxies commonly block raw.githubusercontent.com while allowing
api.github.com. Add fetchGitHubFile() to RegistryClient that tries the
GitHub Contents API first, falling back to the raw CDN transparently.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(installer): cap redirect depth and preserve dual-fallback errors
Add maxRedirects parameter to fetch() and _fetchWithHeaders() to prevent
unbounded redirect recursion. Wrap CDN fallback in try/catch and throw
AggregateError with both API and CDN errors for better diagnostics.
Extract marketplace repo coordinates into named constants in
external-manager.
* chore(installer): drop unused fetchJson and fetchGitHubJson
Neither method has any callers. Also drop the corresponding test.
* refactor(test): fold registry tests into test-installation-components
No reason for RegistryClient tests to be a separate runner — the same
file already tests the registry consumers in Suite 33. Drop test:registry
from package.json scripts and quality gate.
* fix(installer): include URL, API message, and rate-limit info in HTTP errors
Non-2xx responses previously yielded bare `HTTP 403`. Now surface the
request URL, GitHub's JSON error message (or body snippet), X-RateLimit-Reset
when quota is exhausted, and Retry-After. Turns a mystery 403 into
'rate limit exhausted; resets at 2026-04-15T18:00:00Z' — the difference
between 'try GITHUB_TOKEN' and a wild goose chase.
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
fs-extra routes all operations through graceful-fs, which globally
monkey-patches node:fs with a deferred retry queue. During multi-module
installs (~500+ file ops), retried unlink operations from one module's
remove phase can fire after the next module's copy phase has written
files, silently deleting them non-deterministically.
Replace fs-extra with a thin fs-native.js wrapper over node:fs/promises
and node:fs. All 21 consumers now use native APIs with no global
monkey-patching, eliminating the retry-queue race condition entirely.
Closes#1779
* chore(installer): remove dead code across installer modules
Delete 3 entirely dead files (agent-command-generator, bmad-artifacts,
module-injections) and remove ~50 unused exports from manifest.js,
cli-utils.js, prompts.js, path-utils.js, official-modules.js,
external-manager.js, custom-module-manager.js, and registry-client.js.
Removes corresponding dead tests.
* fix(installer): restore currentProjectDir writes for placeholder expansion
The previous commit removed the three assignments to
OfficialModules.currentProjectDir as dead code, but buildQuestion()
still reads the property to resolve {directory_name} placeholders in
module config defaults during interactive collection. Without the
writes, any module default containing {directory_name} would surface
the literal placeholder to users.
* feat(installer): add plugin resolution strategies for custom URL installs
When installing from a custom GitHub URL, the installer now analyzes
marketplace.json plugin structures to determine how to locate module
registration files (module.yaml, module-help.csv). Five strategies
are tried in cascade:
1. Root module files at the common parent of listed skills
2. A -setup skill with registration files in its assets/
3. Single standalone skill with registration files in assets/
4. Multiple standalone skills, each with their own registration files
5. Fallback: synthesize registration from marketplace.json metadata
and SKILL.md frontmatter
Also changes the custom URL flow from confirm-all to multiselect,
letting users pick which plugins to install. Already-installed modules
are pre-checked for update; new modules are unchecked for opt-in.
New file: tools/installer/modules/plugin-resolver.js
Modified: custom-module-manager.js, official-modules.js, ui.js
* fix(installer): address PR review findings for plugin resolver
- Guard against path traversal in plugin-resolver.js: skill paths from
unverified marketplace.json are now constrained to the repo root using
path.resolve() + startsWith check
- Skip npm install during browsing phase: cloneRepo() accepts
skipInstall option, used in ui.js before user confirms selection,
preventing arbitrary lifecycle script execution from untrusted repos
- Add createModuleDirectories() call to installFromResolution() so
modules with declarative directory config are fully set up
- Fix ESLint: use replaceAll instead of replace with global regex
* fix(installer): pass version and repoUrl to manifest for custom plugins
installFromResolution was passing empty strings for version and repoUrl,
which the manifest stores as null. Now threads the repo URL from ui.js
through resolvePlugin into each ResolvedModule, and passes the plugin
version and URL to the manifest correctly.
* fix(installer): manifest-generator overwrites custom module version/repoUrl
ManifestGenerator rebuilds the entire manifest via getModuleVersionInfo
for every module. For custom modules, this returned null for version and
repoUrl because it only checked _readMarketplaceVersion (which searches
for marketplace.json on disk) and hardcoded repoUrl to null. Now checks
the resolution cache first to get the correct version and repo URL.
* fix(installer): resolve custom modules from disk cache on quick update
When the resolution cache is empty (fresh CLI process, e.g. quick
update), findModuleSourceByCode only matched plugin.name against the
module code. This failed for modules like "sam" and "dw" where the
code comes from module.yaml inside a setup/standalone skill, not from
the plugin name in marketplace.json.
Now runs the PluginResolver on cached repos when the direct name match
fails, finding the correct module source and re-populating the cache
for the install pipeline.
* feat(installer): universal source support for custom modules
Replace GitHub-only custom module installation with support for any Git
host (GitHub, GitLab, Bitbucket, self-hosted) and local file paths.
- Add parseSource() universal input parser (local paths, SSH, HTTPS with
deep path/subdir extraction for GitHub, GitLab, Gitea)
- Add resolveSource() coordinator: parse -> clone if URL -> detect
discovery vs direct mode (marketplace.json present or not)
- Clone-first approach eliminates host-specific raw URL fetching
- 3-level cache structure (host/owner/repo) with .bmad-source.json
metadata for URL reconstruction
- Local paths install directly without caching; localPath persisted in
manifest for quick-update source lookup
- Direct mode scans target directory for SKILL.md when no marketplace.json
- Fix version display bug where walk-up found parent repo marketplace.json
and reported wrong version for custom modules
* fix(installer): harden readMarketplaceJsonFromDisk and hoist require
- Add try/catch to readMarketplaceJsonFromDisk so malformed JSON returns
null instead of throwing an unhandled parse error
- Hoist CustomModuleManager require outside the per-module loop in
_installOfficialModules
* fix(installer): restore validateGitHubUrl strictness and fix prettier
- Restore original GitHub-only regex in deprecated validateGitHubUrl
wrapper so existing tests pass (rejects non-GitHub URLs, trailing
slashes)
- Run prettier to fix formatting in custom-module-manager.js
* feat(installer): add --custom-source CLI flag for non-interactive installs
Allows installing custom modules from Git URLs or local paths directly
from the command line without interactive prompts:
npx bmad-method install --custom-source /path/to/module
npx bmad-method install --custom-source https://gitlab.com/org/repo
npx bmad-method install --custom-source /path/one,https://host/org/repo
Works alongside --modules and --yes flags. All discovered modules from
each source are auto-selected.
* docs: add custom and community module installation guide
New how-to page covering community module browsing, custom sources (any
Git host, local paths), discovery vs direct mode, local development
workflow, and the --custom-source CLI flag. Clarifies that
.claude-plugin/ is a cross-tool convention, not Claude-specific.
Also updates non-interactive installation docs with the new flag and
examples, bumps sidebar ordering, and fixes --custom-source to install
only core + custom modules when --modules is not specified.
* feat(installer): add community module browser and custom URL support
Three-tier module selection: official, community (category drill-down
with featured/search), and custom GitHub URL.
- Add RegistryClient shared fetch utility
- Add CommunityModuleManager with SHA-pinned cloning (refuses install
if approved SHA cannot be reached; uses HEAD when no SHA set)
- Add CustomModuleManager for arbitrary GitHub repo installation
- Extend findModuleSource chain with community and custom fallthrough
- Extend manifest to detect community and custom source types
- Add Config.customModulesMeta for custom module metadata
* fix: resolve review findings for community/custom module support
- Remove redundant CommunityModuleManager instantiation in UI display
- Remove dead customModulesMeta field from Config (never populated)
- Add 35 unit tests for CustomModuleManager and CommunityModuleManager
pure functions: URL validation, normalization, search, featured, categories
* fix: preserve installed community/custom modules in modify flow
When a user does "Modify Installation" and declines to browse community
modules, previously installed community/custom modules are now auto-kept.
If the user does browse, their selections are trusted (they can deselect).
Also fix stale docs: class doc for SHA pinning, JSDoc return type.
* fix: include community and custom modules in quick update
Quick update now checks community registry and custom cache so installed
community/custom modules are updated instead of skipped.
* fix: use defaults for new config fields during quick update
When quick update encounters new config fields (e.g., from a newly
supported community module), use schema defaults silently instead of
prompting the user. Quick update should be non-interactive.
* test: add unit tests for SHA pinning, category filtering, and URL edge cases
Cover SHA normalization (set vs null/trusted), listByCategory,
getModuleByCode, and URL validation edge cases (HTTP, trailing slash,
SSH without .git). Total: 243 tests.
* refactor(installer): remove custom content installation feature
Remove the entire local filesystem custom content feature from the
installer to make way for marketplace-based plugin installation.
Deleted: custom-handler.js, custom-module-cache.js, custom-modules.js
Removed: --custom-content CLI flag, interactive custom content prompts,
custom module caching, manifest tracking, missing-source resolution,
and related test suites. Updated docs across all translations.
* fix: address review findings from Augment
Fix admonition syntax (remove accidental space in :::note) across 4
translated docs files, and update stale JSDoc on listAvailable().
* feat(installer): fetch module list from marketplace registry
Switch module list source of truth from bundled
external-official-modules.yaml to the remote marketplace registry
(registry/official.yaml) fetched via raw.githubusercontent.com.
- Rewrite ExternalModuleManager to fetch from GitHub with local fallback
- Simplify selectAllModules/getDefaultModules to use registry as single source
- Registry order controls display order; built_in flag prevents cloning
- Rename fallback file to registry-fallback.yaml in modules/
- Only show legacy migration message when legacy dirs actually exist
* refactor(installer): remove custom content installation feature
Remove the entire local filesystem custom content feature from the
installer to make way for marketplace-based plugin installation.
Deleted: custom-handler.js, custom-module-cache.js, custom-modules.js
Removed: --custom-content CLI flag, interactive custom content prompts,
custom module caching, manifest tracking, missing-source resolution,
and related test suites. Updated docs across all translations.
* fix: address review findings from Augment
Fix admonition syntax (remove accidental space in :::note) across 4
translated docs files, and update stale JSDoc on listAvailable().
* fix: preserve local custom module sources during quick update
Keep customModules in the generated main manifest so local custom
module source paths survive update runs. Load those preserved source
paths during stock quick update before falling back to the custom
cache directory.
This fixes the case where BMAD would drop customModules, lose the
original source path for a local module, and then skip the module or
try to re-cache from _bmad/_config/custom/<module>, which could fail
with ENOENT after the cache directory was removed.
Also adds an installation component regression test to verify
customModules and sourcePath are preserved in manifest generation.
Fixes#1582
* fix: ensure consistent formatting
* refactor: extract quick update custom source assembly
Move quick-update custom module source collection out of Installer and into
CustomModules as assembleQuickUpdateSources(). This keeps discoverPaths() focused on consuming prepared install inputs while
making the quick-update source assembly step explicit and easier to evolve.
Also:
- preserve customModules metadata in manifest regeneration for installed modules
- drop stale customModules entries when modules are no longer installed
- cover manifest preservation and manifest-backed quick-update sources in tests
The installer now finds module.yaml in both skills/ and src/ directories,
including one level deep in subfolders. Updates bmb module-definition to
skills/module.yaml to match its actual structure.
* refactor(installer): restructure installer with clean separation of concerns
Move tools/cli/ to tools/installer/ with major structural cleanup:
- InstallPaths async factory for path resolution and directory creation
- Config value object (frozen) replaces mutable config bag
- ExistingInstall value object replaces stateful Detector class
- OfficialModules + CustomModules + ExternalModuleManager replace monolithic ModuleManager
- install() is prompt-free; all user interaction in ui.js
- Update state returned explicitly instead of mutating customConfig
- Delete dead code: dependency-resolver, _base-ide, IdeConfigManager,
platform-codes helpers, npx wrapper, xml-utils
- Flatten directory structure: custom/handler → custom-handler,
tools/cli/ → tools/installer/, lib/ directories removed
- Update all path references in package.json, tests, CI, and docs
* fix(installer): guard ExistingInstall.version and surface module.yaml errors
Guard ExistingInstall.version access with .installed check in
uninstall.js, ui.js, and installer.js to prevent throwing on
empty/partial _bmad dirs. Surface invalid module.yaml parse errors
as warnings instead of silently returning empty results.