diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index c121858b..bb3f932c 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -11,7 +11,7 @@ { "name": "bmad-loop", "source": "./src/bmad_loop/data/skills", - "description": "Automation-mode skills driven by the bmad-loop orchestrator: interactive escalation resolution (bmad-loop-resolve) and deferred-work sweep triage (bmad-loop-sweep) — the inner dev primitive (which self-reviews and commits) is the upstream bmad-dev-auto skill", + "description": "Automation-mode skills driven by the bmad-loop orchestrator: project bootstrap (bmad-loop-setup), interactive escalation resolution (bmad-loop-resolve) and deferred-work sweep triage (bmad-loop-sweep) — the inner dev primitive (which self-reviews and commits) is the upstream bmad-dev-auto skill", "version": "0.9.0", "author": { "name": "pinkyd" diff --git a/CHANGELOG.md b/CHANGELOG.md index acf146b7..dcad42f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,21 @@ breaking changes may land in a minor release. ### Changed +- **`bmad-loop-setup` stops registering BMAD config; the installer owns it (#258).** The skill + wrote `_bmad/config.yaml`, `_bmad/config.user.yaml` and a root `_bmad/module-help.csv` — the + pre-v6.10 layout, which BMAD's own resolver never reads (it merges four TOML layers, and + `/bmad-help` reads a catalog assembled from per-module `_bmad//module-help.csv`). Since + v6.10.0 bmad-loop is an installer-installed module, so the BMAD installer already stages + `_bmad/bmad-loop/`, writes the manifests, and rebuilds the help catalog on every run — and + regenerates the central `config.toml` wholesale, discarding anything written there from outside. + The three PEP 723 scripts (`merge-config.py`, `merge-help-csv.py`, `cleanup-legacy.py`) are + **removed**; setup now writes exactly one file under `_bmad/`, the per-module + `_bmad/bmad-loop/module-help.csv`, and otherwise does only what the installer cannot: install + or upgrade the orchestrator tool, run `bmad-loop init`, and preflight with `validate`. It reads + the user's name and language through BMAD's own `resolve_config.py` instead of collecting them. + Dropping the scripts also closes the PEP 723 invocation bug (#259) — no inline-dependency script + is invoked with bare `python3` any more, because none ships. + - **Ctrl+C outside a run now exits `130` cleanly (#241).** A `KeyboardInterrupt` escaping `main()` outside `engine.run()` (config load, engine construction) now prints a one-line `interrupted` to stderr and returns the new `ExitCode.INTERRUPTED` (`130` = 128 + SIGINT). @@ -65,6 +80,14 @@ breaking changes may land in a minor release. `subprocess.returncode` sees `130` rather than `-2`). A Ctrl+C _during_ a run is unchanged: the engine still finalizes it as a resumable `stopped` run (rc `0`). +### Removed + +- **The `bmad-auto` → `bmad-loop` rename compatibility is gone.** The rename shipped in 0.8.0 and + no pre-rename installs remain in the wild, so `init` no longer strips `bmad_auto`-marked hooks, + deletes `bmad-auto-*` skill dirs, carries `.automator/policy.toml` over to `.bmad-loop/`, or + prints the leftover-`.automator/` note. `bmad-loop-setup` drops its migration section with them. + A project still on `bmad-auto` should migrate on 0.9.0 before upgrading past it. + ### Fixed - **`validate` requires the review skills your `bmad-dev-auto` actually invokes (#260).** The diff --git a/README.md b/README.md index 40e0e249..28e4ec5c 100644 --- a/README.md +++ b/README.md @@ -253,7 +253,7 @@ The orchestrator drives the upstream `bmad-dev-auto` skill as its inner dev prim | `bmad-review` | merged lens-based reviewer, supersedes the hunters (**upstream** — bmm prereq, not bundled) | | `bmad-loop-resolve` | interactive CRITICAL-escalation resolution (`/bmad-loop-resolve `) | | `bmad-loop-sweep` | deferred-work ledger triage (automation-only) | -| `bmad-loop-setup` | registers the module in `_bmad/` config + help | +| `bmad-loop-setup` | installs the orchestrator tool from Git, then runs `bmad-loop init` + `validate` | `bmad-loop validate` preflights `bmad-dev-auto` (always) plus the review skills that copy of the skill will actually invoke — read from its `customize.toml` review layers, or from `step-04-review.md` on releases that name their reviewers inline. So a merged-`bmad-review` install needs only `bmad-review`, a v6.10.0 install needs the two hunters it names, and a tree whose configured layers reference a skill it does not have is reported instead of failing on every dev run. Missing skills (or a `bmad-dev-auto` without its `customize.toml`) are reported with bmm-module remediation before any run starts. @@ -267,14 +267,14 @@ uv tool install "bmad-loop[tui] @ git+https://github.com/bmad-code-org/bmad-loop uv tool install "bmad-loop[tui] @ git+https://github.com/bmad-code-org/bmad-loop.git@v0.8.1" bmad-loop init --project /path/to/project --cli claude # add --cli codex/gemini as needed -claude "/bmad-loop-setup accept all defaults" # registers _bmad/ config + help +claude "/bmad-loop-setup accept all defaults" # installs the tool + wires the project ``` The `[tui]` extra pulls in the dashboard/settings UI (textual); drop it for a headless install. `bmad-loop --version` confirms what you've got. Existing skill dirs are left untouched (`--force-skills` to overwrite a stale copy, `--no-skills` to manage skills yourself). ### Upgrading -**Easiest — let the setup skill do it.** Re-running `/bmad-loop-setup` (or `/bmad-loop-setup upgrade`) on an already-installed project performs the two-step ritual for you: it detects the existing install, upgrades the tool with `--reinstall`, re-lays the per-project skills with `--force-skills`, and re-stamps config — then reports the before → after version. +**Easiest — let the setup skill do it.** Re-running `/bmad-loop-setup` (or `/bmad-loop-setup upgrade`) on an already-installed project performs the two-step ritual for you: it detects the existing install, upgrades the tool with `--reinstall`, and re-lays the per-project skills with `--force-skills` — then reports the before → after version. ```bash claude "/bmad-loop-setup upgrade" diff --git a/docs/setup-guide.md b/docs/setup-guide.md index be405152..2871265c 100644 --- a/docs/setup-guide.md +++ b/docs/setup-guide.md @@ -12,8 +12,8 @@ an optional extra. There are two ways the skills land in a project. The orchestrator's wheel **bundles** the three skills, so the simplest path is **pip + `bmad-loop init`**, which installs them itself. Alternatively the **BMAD-method installer** copies them. Either way the -`/bmad-loop-setup` skill registers the `_bmad/` config, ensures the tool is installed, -picks which coding CLIs to drive, and bootstraps the project. For the one-page summary, +`/bmad-loop-setup` skill installs the orchestrator tool, picks which coding CLIs to +drive, and bootstraps the project. For the one-page summary, see the [Installing the skill module](../README.md#installing-the-skill-module) section of the README. @@ -62,19 +62,19 @@ claude "/bmad-loop-setup accept all defaults" `/bmad-loop-setup` handles both first-time setup and later upgrades — re-run it any time. It: -1. Merges the module's config into `_bmad/config.yaml` (+ personal settings into the - gitignored `_bmad/config.user.yaml`) and registers its help entries in - `_bmad/module-help.csv`. -2. Installs **or upgrades** the `bmad-loop` tool from Git (see +1. Installs **or upgrades** the `bmad-loop` tool from Git (see [Installing the tool and TUI](#installing-the-tool-and-tui)). On an upgrade it runs `uv tool upgrade bmad-loop --reinstall`. -3. Asks **which coding CLI(s)** the orchestrator should drive, then runs `bmad-loop init` +2. Asks **which coding CLI(s)** the orchestrator should drive, then runs `bmad-loop init` to install the `bmad-loop-*` skills + register hooks + write the `.bmad-loop/policy.toml` template + add gitignore entries (including policy.toml itself — policy is per-machine; repos initialized before this run `git rm --cached .bmad-loop/policy.toml` once if theirs is already committed) (see [Choosing which CLIs to drive](#choosing-which-clis-to-drive) and [Initializing CLIs other than claude](#initializing-clis-other-than-claude)). On an upgrade it passes `--force-skills` so the per-project skill copies are refreshed. -4. Runs `bmad-loop validate` as a preflight (see [Verify](#verify)). -5. Cleans up the legacy installer package directories under `_bmad/`, leaving only config. +3. Runs `bmad-loop validate` as a preflight (see [Verify](#verify)). +4. Refreshes `_bmad/bmad-loop/module-help.csv`, the module's help entries. That is the + only file it writes under `_bmad/` — module registration, the central `config.toml`, + and the `/bmad-help` catalog are owned by the BMAD installer, which regenerates them + on every run. Run `/bmad-loop-setup` with plain prompts if you want to choose interactively — e.g. `claude "/bmad-loop-setup cli: claude, codex"` to preselect the CLIs. @@ -88,7 +88,7 @@ If you are working from a clone of this repo, sync the project env and let ```bash uv sync --extra tui # the orchestrator tool + TUI uv run bmad-loop init --project /path/to/project --cli claude # installs skills + hooks + policy -claude "/bmad-loop-setup accept all defaults" # register _bmad/ config + help +claude "/bmad-loop-setup accept all defaults" # install the tool + wire the project ``` Add `--cli codex --cli gemini` to also populate `.agents/skills/`. `init` always @@ -307,8 +307,8 @@ lines), then uninstall the tool. There is no `bmad-loop uninstall` command — t are the documented manual procedure. Work **inside the project root**, and reclaim disk **before** deleting state so no worktrees or archives are orphaned. -Two paths overlap: every project does steps 1–5 and 7; only projects set up through the -**BMAD-method installer** (i.e. that ran `/bmad-loop-setup`) also need step 6. +Two paths overlap: every project does steps 1–5 and 7; only projects with a `_bmad/` tree +also need step 6. ### 1. Reclaim run disk first @@ -370,15 +370,16 @@ entries to strip; leave every other hook in place. you had run `git rm --cached .bmad-loop/policy.toml` to stop sharing the per-machine policy, the file is untracked — re-add it (`git add .bmad-loop/policy.toml`) only if you want it back in version control. -### 6. Unregister from `_bmad/` (BMAD-installer projects only) +### 6. Remove the BMAD module (BMAD-installer projects only) -If you ran `/bmad-loop-setup`, it registered the module in your BMAD config. Remove the -bmad-loop (`bmad-loop`) entries from: +There is nothing to hand-unregister: bmad-loop writes no BMAD config. If you installed the +module through the BMAD installer, remove it there — the installer owns `_bmad/bmad-loop/`, +the central `config.toml`, and the `/bmad-help` catalog, and regenerates all three on its +next run. -- `_bmad/config.yaml` and `_bmad/config.user.yaml` — drop the bmad-loop module config block -- `_bmad/module-help.csv` — drop the bmad-loop help rows - -uv + `init`-only projects never write to `_bmad/` and can skip this step. +If you only ever ran `/bmad-loop-setup`, delete `_bmad/bmad-loop/module-help.csv` (the one +file it refreshes) — or the whole `_bmad/bmad-loop/` directory if the installer never +created it. uv + `init`-only projects that have no `_bmad/` can skip this step. ### 7. Uninstall the tool diff --git a/src/bmad_loop/data/skills/README.md b/src/bmad_loop/data/skills/README.md index 623566db..6f296bad 100644 --- a/src/bmad_loop/data/skills/README.md +++ b/src/bmad_loop/data/skills/README.md @@ -15,7 +15,7 @@ the table below). | `bmad-loop` | — (this repo, Git) | the orchestrator: ralph-loop, hooks, tmux adapters, TUI. CLI `bmad-loop`. Installed by `bmad-loop-setup` from Git. | | `bmad-loop-resolve` | — (bmad-loop-native) | interactive CRITICAL-escalation resolution: a human disambiguates a frozen spec so a paused story can be re-driven (`/bmad-loop-resolve `) | | `bmad-loop-sweep` | — (bmad-loop-native) | read-only deferred-work ledger triage; owns the canonical `deferred-work-format.md` | -| `bmad-loop-setup` | — (scaffolded) | registers the module in `_bmad/config.yaml` + `module-help.csv`, **installs the orchestrator tool from Git**, runs `bmad-loop init` + `validate` | +| `bmad-loop-setup` | — (scaffolded) | **installs the orchestrator tool from Git**, runs `bmad-loop init` + `validate`, refreshes `_bmad/bmad-loop/module-help.csv` | The **inner dev primitive is the upstream `bmad-dev-auto` skill** (BMAD-METHOD's generic unattended dev session). It is **not** owned or bundled here — the @@ -35,16 +35,19 @@ down for you: ```bash uv tool install "bmad-loop[tui] @ git+https://github.com/bmad-code-org/bmad-loop.git" bmad-loop init --project /path/to/project --cli claude # add --cli codex/gemini as needed -claude "/bmad-loop-setup accept all defaults" # registers _bmad/ config + help +claude "/bmad-loop-setup accept all defaults" # installs the tool + wires the project ``` `bmad-loop init` installs the `bmad-loop-*` skills into `.claude/skills/` (claude) and/or `.agents/skills/` (codex/gemini), registers hooks, writes `.bmad-loop/policy.toml`, and gitignores the runs dir. Existing skill dirs are left untouched (`--force-skills` to overwrite, `--no-skills` to skip). -`bmad-loop-setup` is one-shot for the BMAD-side wiring: it merges config + help -entries, ensures the tool is installed, then runs `bmad-loop init` and -`bmad-loop validate` (preflight). +`bmad-loop-setup` is one-shot for the bootstrap the BMAD installer cannot do: it +ensures the orchestrator tool is installed, then runs `bmad-loop init` and +`bmad-loop validate` (preflight). Module registration — `_bmad/bmad-loop/`, the +central `config.toml`, the `/bmad-help` catalog — belongs to the BMAD installer, +which regenerates it on every run; the only file the skill writes there is +`_bmad/bmad-loop/module-help.csv`. The skills must be installed **together**: `bmad-loop-sweep` owns the canonical `deferred-work-format.md` that the ledger normalizes to, and the upstream diff --git a/src/bmad_loop/data/skills/bmad-loop-setup/SKILL.md b/src/bmad_loop/data/skills/bmad-loop-setup/SKILL.md index 1bc29476..c4192f44 100644 --- a/src/bmad_loop/data/skills/bmad-loop-setup/SKILL.md +++ b/src/bmad_loop/data/skills/bmad-loop-setup/SKILL.md @@ -7,104 +7,64 @@ description: Sets up BMAD Loop Skills module in a project. Use when the user req ## Overview -Installs, configures, **and upgrades** a BMad module in a project. This module is special: alongside the four automation skills it relies on the **bmad-loop orchestrator tool** (the Python program that drives the loop), installed as the `bmad-loop` package from its public Git repository. So setup does two jobs — (1) register module config + help entries, and (2) install **or upgrade** the orchestrator tool and bootstrap the project so it is ready to run. +Installs, configures, **and upgrades** the bmad-loop module in a project. -The same skill handles both first-time setup and **upgrades**. When it detects an existing bmad-loop install (or you ask it to `upgrade`), it upgrades the orchestrator tool, refreshes the per-project `bmad-loop-*` skill copies, and re-stamps config — the two-step upgrade ritual, run for you. A plain re-run on an already-installed project is treated as an upgrade. +This module is unusual: alongside its automation skills it relies on the **bmad-loop orchestrator tool** — the Python program that actually drives the loop — installed as the `bmad-loop` package from its public Git repository. The skills do nothing on their own. -Module identity (name, code, version) comes from `./assets/module.yaml`. Collects user preferences and writes them to three files: +**The BMAD installer owns `_bmad/` registration; this skill does not.** When bmad-loop is installed through the BMAD installer (`npx bmad-method install`), the installer stages the skills under `_bmad/bmad-loop/`, writes that module's own config and help files, records it in `_bmad/_config/` (manifest, skill manifest, files manifest), and rebuilds the `/bmad-help` catalog. It regenerates the central `_bmad/config.toml` on every run, so nothing outside the installer should write there. -- **`{project-root}/_bmad/config.yaml`** — shared project config: core settings at root (e.g. `output_folder`, `document_output_language`) plus a section per module with metadata and module-specific values. User-only keys (`user_name`, `communication_language`) are **never** written here. -- **`{project-root}/_bmad/config.user.yaml`** — personal settings intended to be gitignored: `user_name`, `communication_language`, and any module variable marked `user_setting: true` in `./assets/module.yaml`. These values live exclusively here. -- **`{project-root}/_bmad/module-help.csv`** — registers module capabilities for the help system. +So this skill's job is the part the installer structurally **cannot** do: -Both config scripts use an anti-zombie pattern — existing entries for this module are removed before writing fresh ones, so stale values never persist. +1. Install **or upgrade** the orchestrator tool (the installer copies skill directories only — it cannot carry a Python package). +2. Run `bmad-loop init` to register the per-CLI hooks, lay down the bundled skills, and write `.bmad-loop/policy.toml` + gitignore entries. +3. Preflight with `bmad-loop validate` and point the user at per-role adapter config. -`{project-root}` is a **literal token** in config _values_ (the data written into the files above) — never substitute it there. It signals to the consuming LLM that the value is relative to the project root, not the skill root. **This does not apply to the filesystem path _arguments_ passed to the scripts below** (the `--*-path`, `--*-dir`, and `--target` arguments): those are real paths, so you **must** resolve `{project-root}` to the actual project root before running, or the scripts will write to a literal `{project-root}/` directory under the skill folder. The scripts reject an unresolved token with an error. +It also refreshes one file — `_bmad/bmad-loop/module-help.csv` — so the module's help entries are present even on a project that installed the tool directly rather than through the BMAD installer. Nothing else under `_bmad/` is written or deleted. -## On Activation - -1. Read `./assets/module.yaml` for module metadata and variable definitions (the `code` field is the module identifier) -2. Check if `{project-root}/_bmad/config.yaml` exists — if a section matching the module's code is already present, inform the user this is an update -3. Check for per-module configuration at `{project-root}/_bmad/bmad-loop/config.yaml` and `{project-root}/_bmad/core/config.yaml`. If either file exists: - - If `{project-root}/_bmad/config.yaml` does **not** yet have a section for this module: this is a **fresh install**. Inform the user that installer config was detected and values will be consolidated into the new format. - - If `{project-root}/_bmad/config.yaml` **already** has a section for this module: this is a **legacy migration**. Inform the user that legacy per-module config was found alongside existing config, and legacy values will be used as fallback defaults. - - In both cases, legacy per-module config is consolidated into the new format. Live config files are **preserved** (never deleted); only genuinely redundant skill-payload directories, if any, are cleaned up after setup. - -**Decide fresh-install vs upgrade.** This drives whether the tool is upgraded and whether the per-project skills are refreshed (see "Install the Orchestrator Tool" below). Treat it as an **upgrade** when **any** of these hold: - -- The user asked for one in their arguments — `upgrade`, `update`, `upgrade tool and skills`, or similar. -- `{project-root}/_bmad/config.yaml` already has a `bmad-loop` section (step 2 above). -- The orchestrator tool is already installed under uv: run `uv tool list` and look for a `bmad-loop` entry. (A bare `bmad-loop --version` is **not** sufficient on its own — it can be satisfied by a source checkout or unrelated virtualenv; see step 1 of "Install the Orchestrator Tool".) -- **A pre-rename install is present** — the project (or the machine) is on the old `bmad-auto`. Two tells: `{project-root}/_bmad/config.yaml` has a legacy `bauto` section, or `uv tool list` shows a `bmad-auto` entry (the tool's former name). Treat this as an **upgrade from bmad-auto (renamed to bmad-loop)** and follow "Migrating from bmad-auto (renamed)" below in addition to the normal upgrade path. - -Otherwise it is a **fresh install**. State the decision to the user before proceeding — e.g. "Detected an existing bmad-loop install — running an upgrade: tool + skills + config" or "No existing install detected — running a fresh setup". When the signals conflict (e.g. config has a `bmad-loop` section but the tool isn't uv-managed), prefer the upgrade path for whatever **is** present and call out what's missing. - -If the user provides arguments (e.g. `accept all defaults`, `--headless`, `upgrade`, or inline values like `user name is BMad, I speak Swahili`), map any provided values to config keys, use defaults for the rest, and skip interactive prompting. Still display the full confirmation summary at the end. - -## Migrating from bmad-auto (renamed) - -Only when you detected a **pre-rename install** ("Decide fresh-install vs upgrade" above). The project was set up under the tool's former name, `bmad-auto`. The rename was a clean break — module code `bauto` → `bmad-loop`, tool package `bmad-auto` → `bmad-loop`, state dir `.automator/` → `.bmad-loop/` — with no compatibility shims, so a few steps below need explicit handling. Do these as part of the normal upgrade: - -1. **Carry old config defaults over.** `merge-config.py --legacy-dir` reads `_bmad/bmad-loop/` (the new code), so it won't find the old `bauto` config on its own. Before collecting values, read the legacy `bauto:` section from `{project-root}/_bmad/config.yaml` (and, if present, the old installer file `{project-root}/_bmad/bauto/config.yaml`) and fold any keys that still match the current `module.yaml` schema into your defaults. Priority: existing `bmad-loop` values > legacy `bauto` section > legacy `_bmad/bauto/config.yaml` > `module.yaml` defaults. -2. **Reinstall the tool under its new name** — uv can't rename a package in place, so this is an uninstall + install, not an upgrade. See the renamed-tool note in "Install the Orchestrator Tool" step 2. -3. **Run `bmad-loop init`** as usual. It handles the on-disk migration for you: strips the old `.automator/` Stop hook from each CLI's settings, removes the `bmad-auto-*` skill dirs, and carries `.automator/policy.toml` over to `.bmad-loop/policy.toml` — leaving the rest of `.automator/` (runs, archives, profiles, plugins) in place for you to delete once satisfied. -4. **Post-merge cleanup** — the anti-zombie merges key on the **new** names, so old rows and sections survive and need explicit removal: - - Delete the leftover `bauto:` section from `{project-root}/_bmad/config.yaml`, plus any `bauto`-marked keys in `{project-root}/_bmad/config.user.yaml`. - - Delete rows from `{project-root}/_bmad/module-help.csv` whose module column (column 1) reads `BMAD Automator Skills` — `merge-help-csv.py`'s anti-zombie filter keys on the new `BMAD Loop Skills`, so it leaves the old-named rows behind. - - The legacy installer dir `_bmad/bauto/` is cleaned by the bauto cleanup step (see "Cleanup Legacy Directories") **only if** it holds redundant `bmad-auto-*` skill copies; if it holds only stale config it is preserved (harmless — remove it manually once satisfied). +Module identity (name, code, version) comes from `./assets/module.yaml`. -Then continue with the normal flow below. +`{project-root}` is a **literal token** in BMAD config _values_ — never substitute it there. **This does not apply to the filesystem path _arguments_ in the commands below**: those are real paths, so resolve `{project-root}` to the actual project root before running. -## Collect Configuration +## On Activation -Ask the user for values. Show defaults in brackets. Present all values together so the user can respond once with only the values they want to change (e.g. "change language to Swahili, rest are fine"). Never tell the user to "press enter" or "leave blank" — in a chat interface they must type something to respond. +1. Read `./assets/module.yaml` for module metadata (the `code` field is the module identifier). +2. Check whether `{project-root}/_bmad/` exists. If it does not, this project has no BMAD install — say so, and note that the automation skills expect one. Setup can still install the orchestrator tool. +3. Check whether `{project-root}/_bmad/bmad-loop/` exists. If it does, the module was installed by the BMAD installer and is already registered. If it does not, tell the user the module is not registered with BMAD, and that installing it via `npx bmad-method install` (selecting **bmad-loop**) adds it to `/bmad-help`. Do **not** gate setup on this — continue either way. -**Default priority** (highest wins): existing new config values > legacy config values > `./assets/module.yaml` defaults. When legacy configs exist, read them and use matching values as defaults instead of `module.yaml` defaults. Only keys that match the current schema are carried forward — changed or removed keys are ignored. On a **rename-upgrade from bmad-auto**, the old `bauto` section (in `_bmad/config.yaml`) and any `_bmad/bauto/config.yaml` installer file slot in below existing `bmad-loop` values and above `module.yaml` defaults — you read them yourself (see "Migrating from bmad-auto (renamed)"), since merge-config's `--legacy-dir` only looks under the new code. +**Decide fresh-install vs upgrade.** This drives whether the tool is upgraded and whether the per-project skills are refreshed. Treat it as an **upgrade** when either holds: -**Core config** (only if no core keys exist yet): `user_name` (default: BMad), `communication_language` and `document_output_language` (default: English — ask as a single language question, both keys get the same answer), `output_folder` (default: `{project-root}/_bmad-output`). Of these, `user_name` and `communication_language` are written exclusively to `config.user.yaml`. The rest go to `config.yaml` at root and are shared across all modules. +- The user asked for one in their arguments — `upgrade`, `update`, `upgrade tool and skills`, or similar. +- The orchestrator tool is already installed under uv: run `uv tool list` and look for a `bmad-loop` entry. (A bare `bmad-loop --version` is **not** sufficient on its own — it can be satisfied by a source checkout or unrelated virtualenv; see step 1 below.) -**Module config**: Read each variable in `./assets/module.yaml` that has a `prompt` field. Ask using that prompt with its default value (or legacy value if available). +Otherwise it is a **fresh install**. State the decision to the user before proceeding — e.g. "Detected an existing bmad-loop install — running an upgrade: tool + skills" or "No existing install detected — running a fresh setup". -## Write Files +If the user provides arguments (e.g. `accept all defaults`, `--headless`, `upgrade`), use them and skip interactive prompting. Still display the full confirmation summary at the end. -Write a temp JSON file with the collected answers structured as `{"core": {...}, "module": {...}}` (omit `core` if it already exists). Values inside this JSON keep the literal `{project-root}` token. Then run both scripts — they can run in parallel since they write to different files. +## Register Help Entries -In the commands below, replace `{project-root}` in every path argument with the actual project root (e.g. `/home/me/myapp`) before running — these are filesystem paths, not config values. +Refresh the module's help entries. This is the only file this skill writes under `_bmad/`, and it is exactly what the BMAD installer itself places there — so it is a no-op on an installer-installed project and idempotent on every re-run. ```bash -python3 ./scripts/merge-config.py --config-path "{project-root}/_bmad/config.yaml" --user-config-path "{project-root}/_bmad/config.user.yaml" --module-yaml ./assets/module.yaml --answers {temp-file} --legacy-dir "{project-root}/_bmad" -python3 ./scripts/merge-help-csv.py --target "{project-root}/_bmad/module-help.csv" --source ./assets/module-help.csv --legacy-dir "{project-root}/_bmad" --module-code bmad-loop +mkdir -p "{project-root}/_bmad/bmad-loop" +cp ./assets/module-help.csv "{project-root}/_bmad/bmad-loop/module-help.csv" ``` -Both scripts output JSON to stdout with results. If either exits non-zero, surface the error and stop. The scripts automatically read legacy config values as fallback defaults. Legacy config files are **preserved, never deleted** — on BMAD v6 the per-module and core `config.yaml` / `module-help.csv` are live, manifest-tracked files (so `legacy_configs_deleted` / `legacy_csvs_deleted` are always empty). - -Run `./scripts/merge-config.py --help` or `./scripts/merge-help-csv.py --help` for full usage. +`/bmad-help` reads the assembled catalog at `_bmad/_config/bmad-help.csv`, which the BMAD installer rebuilds from every `_bmad//module-help.csv`. So on a project that never ran the BMAD installer, these entries become visible the next time it runs — not immediately. Say that plainly in the Confirm step rather than claiming the help system was updated. -## Create Output Directories - -After writing config, create any output directories that were configured. For filesystem operations only (such as creating directories), resolve the `{project-root}` token to the actual project root and create each path-type value from `config.yaml` that does not yet exist — this includes `output_folder` and any module variable whose value starts with `{project-root}/`. The paths stored in the config files must continue to use the literal `{project-root}` token; only the directories on disk should use the resolved paths. Use `mkdir -p` or equivalent to create the full path. +Skip this step entirely if `{project-root}/_bmad/` does not exist. ## Install the Orchestrator Tool -This module ships the **bmad-loop orchestrator** — the Python program that actually drives the loop — as the `bmad-loop` Python package, installed from its public Git repository. The skills do nothing on their own: the orchestrator is what spawns fresh coding CLI sessions through the selected adapter(s) to invoke `bmad-dev-auto` (the upstream dev primitive) for the dev pass — then re-invokes it on the `done` spec for the follow-up review pass — and `bmad-loop-sweep`, watches their hook signals, and verifies their artifacts. Installing the tool is therefore part of setup, not an optional extra. +The orchestrator is what spawns fresh coding CLI sessions through the selected adapter(s) to invoke `bmad-dev-auto` (the upstream dev primitive) for the dev pass — then re-invokes it on the `done` spec for the follow-up review pass — and `bmad-loop-sweep`, watches their hook signals, and verifies their artifacts. Installing it is therefore part of setup, not an optional extra. -> **Why is the tool installed from Git?** The BMAD installer copies only the skill directories into a project — it does **not** carry sibling files, so the tool can't ride along in the skill folder; it's installed from Git instead. The canonical source is . (The reverse holds, though: the tool's wheel **bundles** the skills, so `bmad-loop init` lays them down into a project's skill trees on its own — see step 3.) +> **Why from Git?** The BMAD installer copies only skill directories into a project — it does not carry sibling files, so the tool can't ride along in the skill folder. The canonical source is . (The reverse holds, though: the tool's wheel **bundles** the skills, so `bmad-loop init` lays them down into a project's skill trees on its own — see step 3.) -Unless the user explicitly asked to skip it (e.g. `skills only` / `--no-tool`), install **or upgrade** and bootstrap now. Which branch you take in step 2 follows the fresh-install-vs-upgrade decision from "On Activation". In the commands below, resolve `{project-root}` to the real project path before running. +Unless the user explicitly asked to skip it (e.g. `skills only` / `--no-tool`), install **or upgrade** and bootstrap now. Resolve `{project-root}` to the real project path before running. -1. **Check what's already on PATH:** run `bmad-loop --version`. A version printing here does **not** mean this project is set up — it only means _some_ `bmad-loop` is importable in the current environment. Before trusting it, run `uv tool list` and look for `bmad-loop`: if it's absent (the on-PATH copy comes from a source checkout or an unrelated virtualenv), warn the user that the active environment is shadowing a clean install and that the project would be relying on that checkout. Unless the user explicitly declines, install/upgrade from the canonical source below so the project doesn't depend on an incidental dev environment. Only skip the install if the user confirms the on-PATH copy is the one they want this project to use. +1. **Check what's already on PATH:** run `bmad-loop --version`. A version printing here does **not** mean this project is set up — it only means _some_ `bmad-loop` is importable in the current environment. Before trusting it, run `uv tool list` and look for `bmad-loop`: if it's absent (the on-PATH copy comes from a source checkout or an unrelated virtualenv), warn the user that the active environment is shadowing a clean install and that the project would be relying on that checkout. Unless the user explicitly declines, install/upgrade from the canonical source below so the project doesn't depend on an incidental dev environment. 2. **Install or upgrade from the Git repository** (the `[tui]` extra pulls in the Textual dashboard so `bmad-loop tui` works). `uv tool install` puts `bmad-loop` in uv's own managed environment, so there's no PEP 668 externally-managed conflict and no need for `--user`, an activated virtualenv, or `--break-system-packages`. - - **Renamed from bmad-auto** (`uv tool list` shows `bmad-auto`, not `bmad-loop`): uv can't rename a package in place, so `uv tool upgrade` won't move you across the rename. Record `bmad-auto --version` first (for the delta), then uninstall the old tool and install the new one fresh: - - ```bash - uv tool uninstall bmad-auto - uv tool install "bmad-loop[tui] @ git+https://github.com/bmad-code-org/bmad-loop.git" - ``` - - After this the machine is on `bmad-loop`; later runs follow the plain upgrade path below. - - **Fresh install** (no uv-managed `bmad-loop`): ```bash @@ -113,7 +73,7 @@ Unless the user explicitly asked to skip it (e.g. `skills only` / `--no-tool`), Pin a release tag for reproducibility by appending `@v` to the Git URL. - - **Upgrade** (uv already manages `bmad-loop`, per the "On Activation" decision): + - **Upgrade** (uv already manages `bmad-loop`): 1. Record the current version first so you can report the delta: `bmad-loop --version`. 2. Default — follow `main` (or the currently pinned tag): @@ -175,37 +135,27 @@ Unless the user explicitly asked to skip it (e.g. `skills only` / `--no-tool`), Tell the user where the file is and that any CLI named in `policy.toml` must also have been registered with `--cli` in step 3 (re-run `bmad-loop init --cli ` to add one later). Leave `policy.toml` untouched if they only use a single CLI — the default is correct. -## Cleanup Legacy Directories - -After both merge scripts complete successfully, remove any **redundant skill-payload** directories an older installer may have staged under `_bmad/` (past layouts duplicated skills that already live in the CLI's skill tree, e.g. `.claude/skills/`). On a modern BMAD v6 install nothing redundant is staged, so this step is a **safe no-op** — it never touches `_bmad/core/`, per-module config, or the `_bmad/_config/` manifest. - -As with the merge scripts, replace `{project-root}` in the `--bmad-dir` and `--skills-dir` path arguments with the actual project root before running. For `--skills-dir`, use the skill tree of a CLI registered in step 3: `.claude/skills` for `claude`, `.agents/skills` for `codex`/`gemini`/`copilot`/`antigravity`. `init` lays the bundled skills into **every** selected CLI's tree, so any registered CLI's tree verifies correctly — the command below shows the `claude` tree; substitute `.agents/skills` if only non-Claude CLIs were registered. Do **not** pass `--also-remove _config` (or any shared config/manifest dir): `_config/` is live BMAD infrastructure and the script refuses to remove it regardless. - -```bash -python3 ./scripts/cleanup-legacy.py --bmad-dir "{project-root}/_bmad" --module-code bmad-loop --skills-dir "{project-root}/.claude/skills" -``` - -A directory is removed only when it is a **verified-redundant skill payload**: it contains a `SKILL.md`, carries no live config/manifest files anywhere in its tree (marker-named files inside a staged skill payload don't count — skills legitimately ship e.g. `assets/module-help.csv`), and (with `--skills-dir`) its skills are verified installed under the `--skills-dir` tree. Live config directories (per-module and nested config/manifests) are protected and reported under `directories_protected`; `core/` and `_config/` are protected by name regardless of contents. If the script exits non-zero, surface the error and stop. Missing directories (already cleaned by a prior run) are not errors — the script is idempotent. - -**On a rename-upgrade from bmad-auto**, if the old `_bmad/bauto/` directory holds pre-rename `bmad-auto-*` skill copies (an older payload layout), run this cleanup **without** `--skills-dir` to skip installed-skill verification (their removal is the point). It is a no-op when `_bmad/bauto/` holds only config or isn't present: +## Confirm -```bash -python3 ./scripts/cleanup-legacy.py --bmad-dir "{project-root}/_bmad" --module-code bauto -``` +Report: -Check `directories_removed`, `directories_protected`, and `files_removed_count` in the JSON output for the confirmation step. Run `./scripts/cleanup-legacy.py --help` for full usage. +- **Fresh install:** the installed `bmad-loop --version`, that `bmad-loop init` registered hooks, installed the `bmad-loop-*` skills, and wrote policy/gitignore for the selected coding CLI(s) (name each one — e.g. "hooks + skills installed for claude, codex"). +- **Upgrade:** the before → after `bmad-loop --version` (e.g. "upgraded 0.3.1 → 0.3.2", or "already current at 0.3.2"), and that the `bmad-loop-*` skills were **refreshed** (not skipped) with `--force-skills` in each CLI tree. -## Confirm +Also report: -Use the script JSON output to display what was written — config values set (written to `config.yaml` at root for core, module section for module values; the `bmad-loop` section's `version` is re-stamped from `./assets/module.yaml` on every run via the anti-zombie merge), user settings written to `config.user.yaml` (`user_keys` in result), help entries added, fresh install vs upgrade. +- The `bmad-loop validate` preflight result (pass, or the readiness checklist of what's still missing). +- That `_bmad/bmad-loop/module-help.csv` was refreshed. If `_bmad/bmad-loop/` did not exist before this run (i.e. the BMAD installer never installed the module), say that the help entries will appear in `/bmad-help` after the BMAD installer next runs, and that installing the module via `npx bmad-method install` registers it properly. +- That this skill wrote nothing else under `_bmad/` — module registration, `config.toml`, and the help catalog are owned by the BMAD installer. -Report the **tool** result according to the branch taken: +Then display the `module_greeting` from `./assets/module.yaml` to the user. -- **Fresh install:** the installed `bmad-loop --version`, that `bmad-loop init` registered hooks, installed the `bmad-loop-*` skills, and wrote policy/gitignore for the selected coding CLI(s) (name each one — e.g. "hooks + skills installed for claude, codex"). -- **Upgrade:** the before → after `bmad-loop --version` (e.g. "upgraded 0.3.1 → 0.3.2", or "already current at 0.3.2"), that the `bmad-loop-*` skills were **refreshed** (not skipped) with `--force-skills` in each CLI tree, and the re-stamped config version. +## Outcome -Also report the `bmad-loop validate` preflight result (pass, or the readiness checklist of what's still missing). If legacy config was consolidated, mention the migration (live config files are preserved, not deleted). If any redundant skill-payload directories were removed, report the count and list from `directories_removed` (e.g. "Cleaned up a redundant skill-payload directory — skills are installed in the CLI's skill tree"); on a v6 install this is typically empty and `directories_protected` confirms `core/` and `_config/` were left intact. Then display the `module_greeting` from `./assets/module.yaml` to the user. +Use the user's configured name and language for the remainder of the session. Read them from BMAD's central config via its own resolver (four-layer TOML merge; needs BMAD v6.10+ and Python 3.11+): -## Outcome +```bash +uv run "{project-root}/_bmad/scripts/resolve_config.py" --project-root "{project-root}" --key core +``` -Once the user's `user_name` and `communication_language` are known (from collected input, arguments, or existing config), use them consistently for the remainder of the session: address the user by their configured name and communicate in their configured `communication_language`. +Take `user_name` and `communication_language` from the `core` table. If the script is absent or exits non-zero, fall back to addressing the user neutrally in English — do not write or repair any config file. diff --git a/src/bmad_loop/data/skills/bmad-loop-setup/scripts/cleanup-legacy.py b/src/bmad_loop/data/skills/bmad-loop-setup/scripts/cleanup-legacy.py deleted file mode 100755 index df109bce..00000000 --- a/src/bmad_loop/data/skills/bmad-loop-setup/scripts/cleanup-legacy.py +++ /dev/null @@ -1,355 +0,0 @@ -#!/usr/bin/env python3 -# /// script -# requires-python = ">=3.9" -# dependencies = [] -# /// -"""Remove redundant legacy skill-payload directories from _bmad/ after config migration. - -Older BMAD installers could stage a module's skill payload *and* its config into -_bmad// (and _bmad/core/), duplicating skills that also live in the CLI's -installed-skills tree (e.g. .claude/skills/ or .agents/skills/). This script -removes only those *redundant skill-payload* trees. - -A directory is removed ONLY when it is a verified-redundant skill payload: - * it contains >=1 SKILL.md, AND - * it carries no live config/manifest files anywhere in its tree (config.yaml, - module-help.csv, installer manifests, or a _config/ dir) — marker-named files - inside a staged skill payload (under a SKILL.md-bearing dir) don't count, AND - * when --skills-dir is given, every skill in it is verified installed there. - -Everything else is left untouched. On a modern BMAD v6 install the per-module and -core directories hold only live config (no staged SKILL.md), and _bmad/_config/ is -the live installer manifest — so this script is a safe no-op there and never deletes -shared BMAD state. 'core' and '_config' are never removed: both are protected by -name regardless of contents. Every other removal decision is driven entirely by -directory contents, so no version check is needed. - -Exit codes: 0=success (including nothing to remove), 1=validation error, 2=runtime error -""" - -import argparse -import json -import shutil -import sys -from pathlib import Path - - -def parse_args(): - parser = argparse.ArgumentParser( - description="Remove legacy module directories from _bmad/ after config migration." - ) - parser.add_argument( - "--bmad-dir", - required=True, - help="Path to the _bmad/ directory", - ) - parser.add_argument( - "--module-code", - required=True, - help="Module code being cleaned up (e.g. 'bmb')", - ) - parser.add_argument( - "--also-remove", - action="append", - default=[], - help="Additional directory names under _bmad/ to remove (repeatable)", - ) - parser.add_argument( - "--skills-dir", - help="Path to the CLI's installed-skills tree (.claude/skills/ for claude, " - ".agents/skills/ for codex/gemini/copilot/antigravity) — enables safety " - "verification that skills are installed before removing legacy copies", - ) - parser.add_argument( - "--verbose", - action="store_true", - help="Print detailed progress to stderr", - ) - return parser.parse_args() - - -def find_skill_dirs(base_path: str) -> list: - """Find directories that contain a SKILL.md file. - - Walks the directory tree and returns the leaf directory name for each - directory containing a SKILL.md. These are considered skill directories. - - Returns: - List of skill directory names (e.g. ['bmad-agent-builder', 'bmad-builder-setup']) - """ - skills = [] - root = Path(base_path) - if not root.exists(): - return skills - for skill_md in root.rglob("SKILL.md"): - skills.append(skill_md.parent.name) - return sorted(set(skills)) - - -# Markers that mean a directory holds LIVE BMAD config or installer-manifest -# state — never a disposable skill payload. Their presence protects the directory. -_CONFIG_MARKERS = ("config.yaml", "config.user.yaml", "module-help.csv", "manifest.yaml") - - -def _inside_skill_payload(item: Path, root: Path) -> bool: - """True if item sits inside a staged skill-payload subtree of root. - - A dir at or above item — strictly below root — containing a SKILL.md marks a - staged skill payload; marker-named files under it (e.g. a skill's own - assets/module-help.csv) are payload data, not live state. root itself is - excluded so markers at the candidate's top level always stay protective. - """ - d = item.parent - while d != root: - if (d / "SKILL.md").exists(): - return True - d = d.parent - return False - - -def _is_config_marker(item: Path) -> bool: - return ( - item.name in _CONFIG_MARKERS - or item.name.endswith("-manifest.csv") - or item.name == "bmad-help.csv" - ) - - -def is_config_bearing(path: Path) -> bool: - """True if the directory holds live BMAD config or installer-manifest state. - - Such a directory (e.g. _bmad/core/, _bmad//, _bmad/_config/) is never a - redundant skill payload and must not be removed. 'core' and '_config' are - protected by name regardless of contents. Otherwise the whole tree is scanned - for per-module/core config.yaml + module-help.csv, installer manifests - (*-manifest.csv, manifest.yaml, bmad-help.csv), and nested _config/ dirs — - skipping markers that belong to a staged skill payload (see - _inside_skill_payload), which are disposable copies rather than live state. - """ - if path.name in ("_config", "core"): - return True - for item in path.rglob("*"): - if item.is_dir() and item.name == "_config": - if not _inside_skill_payload(item, path): - return True - elif item.is_file() and _is_config_marker(item): - if not _inside_skill_payload(item, path): - return True - return False - - -def classify_dirs( - bmad_dir: str, dirs_to_check: list, skills_dir: str, verbose: bool = False -) -> tuple: - """Partition requested directories into removable payloads vs protected. - - A directory is removable ONLY if it is a verified-redundant skill payload: it - contains >=1 SKILL.md, carries no live config/manifest files, and — when - skills_dir is given — every skill in it is verified installed there. - - Returns (removable, protected, not_found, verified_skills) where `protected` is a - list of {"dir": name, "reason": ...}. Raises SystemExit(1) if a payload dir's - skills are missing from skills_dir (the original safety contract). - """ - removable: list = [] - protected: list = [] - not_found: list = [] - verified: list = [] - missing: list = [] - - for dirname in dirs_to_check: - target = Path(bmad_dir) / dirname - if not target.exists() or not target.is_dir(): - not_found.append(dirname) - if verbose: - print(f"Not found (skipping): {target}", file=sys.stderr) - continue - - if target.name in ("_config", "core"): - protected.append({"dir": dirname, "reason": "live BMAD dir (protected by name)"}) - if verbose: - print(f"Protected by name, not removing: {target}", file=sys.stderr) - continue - - if is_config_bearing(target): - protected.append({"dir": dirname, "reason": "holds live config/manifest"}) - if verbose: - print(f"Protected (live config/manifest), not removing: {target}", file=sys.stderr) - continue - - skill_names = find_skill_dirs(str(target)) - if not skill_names: - protected.append({"dir": dirname, "reason": "no skill payload"}) - if verbose: - print(f"No skill payload, not removing: {target}", file=sys.stderr) - continue - - if skills_dir: - dir_missing = [s for s in skill_names if not (Path(skills_dir) / s).is_dir()] - if dir_missing: - missing.extend(dir_missing) - if verbose: - for s in dir_missing: - print(f"MISSING: {s} not found under {skills_dir}", file=sys.stderr) - continue - verified.extend(skill_names) - - removable.append(dirname) - - if missing: - error_result = { - "status": "error", - "error": "Skills not found at installed location", - "missing_skills": sorted(set(missing)), - "skills_dir": str(Path(skills_dir).resolve()) if skills_dir else None, - } - print(json.dumps(error_result, indent=2)) - sys.exit(1) - - return removable, protected, not_found, sorted(set(verified)) - - -def count_files(path: Path) -> int: - """Count all files recursively in a directory.""" - count = 0 - for item in path.rglob("*"): - if item.is_file(): - count += 1 - return count - - -def cleanup_directories(bmad_dir: str, dirs_to_remove: list, verbose: bool = False) -> tuple: - """Remove specified directories under bmad_dir. - - Returns: - (removed, not_found, total_files_removed) tuple - """ - removed = [] - not_found = [] - total_files = 0 - - for dirname in dirs_to_remove: - target = Path(bmad_dir) / dirname - if not target.exists(): - not_found.append(dirname) - if verbose: - print(f"Not found (skipping): {target}", file=sys.stderr) - continue - - if not target.is_dir(): - if verbose: - print(f"Not a directory (skipping): {target}", file=sys.stderr) - not_found.append(dirname) - continue - - file_count = count_files(target) - if verbose: - print( - f"Removing {target} ({file_count} files)", - file=sys.stderr, - ) - - try: - shutil.rmtree(target) - except OSError as e: - error_result = { - "status": "error", - "error": f"Failed to remove {target}: {e}", - "directories_removed": removed, - "directories_failed": dirname, - } - print(json.dumps(error_result, indent=2)) - sys.exit(2) - - removed.append(dirname) - total_files += file_count - - return removed, not_found, total_files - - -def reject_unresolved_paths(named_paths: list[tuple[str, str]]) -> None: - """Exit with a clear error if any path argument still contains the literal - ``{project-root}`` token. That token is meaningful only inside config - values; filesystem path arguments must be resolved by the caller. Failing - loudly here prevents silently operating on a junk ``{project-root}/`` directory. - """ - for name, value in named_paths: - if value and "{project-root}" in value: - print( - json.dumps( - { - "status": "error", - "error": ( - f"Unresolved '{{project-root}}' token in {name} path: {value!r}. " - "Resolve '{project-root}' to the actual project root before running " - "this script — it is a filesystem path, not a config value." - ), - }, - indent=2, - ) - ) - sys.exit(1) - - -def main(): - args = parse_args() - - reject_unresolved_paths([("--bmad-dir", args.bmad_dir), ("--skills-dir", args.skills_dir)]) - - bmad_dir = args.bmad_dir - module_code = args.module_code - - # Candidate directories. 'core' is NEVER hardcoded here — it holds live core - # config on BMAD v6, not a disposable payload. Only the module's own dir plus - # any explicit --also-remove targets are considered, and each is still gated by - # classify_dirs (verified-redundant skill payload, never config-bearing). - dirs_to_check = [module_code] + args.also_remove - # Deduplicate while preserving order - seen = set() - unique_dirs = [] - for d in dirs_to_check: - if d not in seen: - seen.add(d) - unique_dirs.append(d) - dirs_to_check = unique_dirs - - if args.verbose: - print(f"Candidate directories: {dirs_to_check}", file=sys.stderr) - - # Classify: only verified-redundant skill payloads are removable; live - # config/manifest dirs (core/, / config, _config/) are protected. - removable, protected, not_found, verified_skills = classify_dirs( - bmad_dir, dirs_to_check, args.skills_dir, args.verbose - ) - - # Remove only the verified-redundant payload directories. A removable dir can - # vanish between classify_dirs() and here (TOCTOU) or a nested --also-remove - # target can be removed with its parent earlier in this loop; surface those in - # directories_not_found rather than silently dropping them. - removed, removal_not_found, total_files = cleanup_directories(bmad_dir, removable, args.verbose) - not_found = not_found + removal_not_found - - # Build result - result = { - "status": "success", - "bmad_dir": str(Path(bmad_dir).resolve()), - "directories_removed": removed, - "directories_protected": protected, - "directories_not_found": not_found, - "files_removed_count": total_files, - } - - if args.skills_dir: - result["safety_checks"] = { - "skills_verified": True, - "skills_dir": str(Path(args.skills_dir).resolve()), - "verified_skills": verified_skills, - } - else: - result["safety_checks"] = None - - print(json.dumps(result, indent=2)) - - -if __name__ == "__main__": - main() diff --git a/src/bmad_loop/data/skills/bmad-loop-setup/scripts/merge-config.py b/src/bmad_loop/data/skills/bmad-loop-setup/scripts/merge-config.py deleted file mode 100755 index 3aa621ae..00000000 --- a/src/bmad_loop/data/skills/bmad-loop-setup/scripts/merge-config.py +++ /dev/null @@ -1,441 +0,0 @@ -#!/usr/bin/env python3 -# /// script -# requires-python = ">=3.9" -# dependencies = ["pyyaml"] -# /// -"""Merge module configuration into shared _bmad/config.yaml and config.user.yaml. - -Reads a module.yaml definition and a JSON answers file, then writes or updates -the shared config.yaml (core values at root + module section) and config.user.yaml -(user_name, communication_language, plus any module variable with user_setting: true). -Uses an anti-zombie pattern for the module section in config.yaml. - -Legacy migration: when --legacy-dir is provided, reads old per-module config files -from {legacy-dir}/{module-code}/config.yaml and {legacy-dir}/core/config.yaml. -Matching values serve as fallback defaults (answers override them). These legacy -files are READ ONLY and never deleted — on BMAD v6 the per-module and core -config.yaml are live, manifest-tracked files, and the consolidated _bmad/config.yaml -wins on read regardless. Other modules' config is never touched. - -Exit codes: 0=success, 1=validation error, 2=runtime error -""" - -import argparse -import json -import sys -from pathlib import Path - -try: - import yaml -except ImportError: - print("Error: pyyaml is required (PEP 723 dependency)", file=sys.stderr) - sys.exit(2) - - -def parse_args(): - parser = argparse.ArgumentParser( - description="Merge module config into shared _bmad/config.yaml with anti-zombie pattern." - ) - parser.add_argument( - "--config-path", - required=True, - help="Path to the target _bmad/config.yaml file", - ) - parser.add_argument( - "--module-yaml", - required=True, - help="Path to the module.yaml definition file", - ) - parser.add_argument( - "--answers", - required=True, - help="Path to JSON file with collected answers", - ) - parser.add_argument( - "--user-config-path", - required=True, - help="Path to the target _bmad/config.user.yaml file", - ) - parser.add_argument( - "--legacy-dir", - help="Path to _bmad/ directory to check for legacy per-module config files. " - "Matching values are used as fallback defaults; the legacy files are read " - "only and never deleted (they are live, manifest-tracked config on BMAD v6).", - ) - parser.add_argument( - "--verbose", - action="store_true", - help="Print detailed progress to stderr", - ) - return parser.parse_args() - - -def load_yaml_file(path: str) -> dict: - """Load a YAML file, returning empty dict if file doesn't exist.""" - file_path = Path(path) - if not file_path.exists(): - return {} - with open(file_path, "r", encoding="utf-8") as f: - content = yaml.safe_load(f) - return content if content else {} - - -def load_json_file(path: str) -> dict: - """Load a JSON file.""" - with open(path, "r", encoding="utf-8") as f: - return json.load(f) - - -# Keys that live at config root (shared across all modules) -_CORE_KEYS = frozenset( - {"user_name", "communication_language", "document_output_language", "output_folder"} -) - - -def load_legacy_values( - legacy_dir: str, module_code: str, module_yaml: dict, verbose: bool = False -) -> tuple[dict, dict, list]: - """Read legacy per-module config files and return core/module value dicts. - - Reads {legacy_dir}/core/config.yaml and {legacy_dir}/{module_code}/config.yaml. - Only returns values whose keys match the current schema (core keys or module.yaml - variable definitions). Other modules' directories are not touched. - - Returns: - (legacy_core, legacy_module, files_found) where files_found lists paths read. - """ - legacy_core: dict = {} - legacy_module: dict = {} - files_found: list = [] - - # Read core legacy config - core_path = Path(legacy_dir) / "core" / "config.yaml" - if core_path.exists(): - core_data = load_yaml_file(str(core_path)) - files_found.append(str(core_path)) - for k, v in core_data.items(): - if k in _CORE_KEYS: - legacy_core[k] = v - if verbose: - print(f"Legacy core config: {list(legacy_core.keys())}", file=sys.stderr) - - # Read module legacy config - mod_path = Path(legacy_dir) / module_code / "config.yaml" - if mod_path.exists(): - mod_data = load_yaml_file(str(mod_path)) - files_found.append(str(mod_path)) - for k, v in mod_data.items(): - if k in _CORE_KEYS: - # Core keys duplicated in module config — only use if not already set - if k not in legacy_core: - legacy_core[k] = v - elif k in module_yaml and isinstance(module_yaml[k], dict): - # Module-specific key that matches a current variable definition - legacy_module[k] = v - if verbose: - print(f"Legacy module config: {list(legacy_module.keys())}", file=sys.stderr) - - return legacy_core, legacy_module, files_found - - -def apply_legacy_defaults(answers: dict, legacy_core: dict, legacy_module: dict) -> dict: - """Apply legacy values as fallback defaults under the answers. - - Legacy values fill in any key not already present in answers. - Explicit answers always win. - """ - merged = dict(answers) - - if legacy_core: - core = merged.get("core", {}) - filled_core = dict(legacy_core) # legacy as base - filled_core.update(core) # answers override - merged["core"] = filled_core - - if legacy_module: - mod = merged.get("module", {}) - filled_mod = dict(legacy_module) # legacy as base - filled_mod.update(mod) # answers override - merged["module"] = filled_mod - - return merged - - -def cleanup_legacy_configs(legacy_dir: str, module_code: str, verbose: bool = False) -> list: - """Intentionally does NOT delete any legacy config files (returns an empty list). - - Legacy per-module (_bmad//config.yaml) and core (_bmad/core/config.yaml) - configs are read as fallback defaults (see load_legacy_values) but never deleted: - on BMAD v6 both are LIVE, manifest-tracked files, so removing core/config.yaml - destroys shared core config and removing /config.yaml desyncs - _bmad/_config/files-manifest.csv. The consolidated _bmad/config.yaml always wins - on read, so leaving the legacy files in place is harmless. Kept as a function so - callers and the result JSON stay stable. - """ - if verbose: - print( - "Preserving legacy config files (live BMAD v6 config is never deleted)", - file=sys.stderr, - ) - return [] - - -def extract_module_metadata(module_yaml: dict) -> dict: - """Extract non-variable metadata fields from module.yaml.""" - meta = {} - for k in ("name", "description"): - if k in module_yaml: - meta[k] = module_yaml[k] - meta["version"] = module_yaml.get("module_version") # null if absent - if "default_selected" in module_yaml: - meta["default_selected"] = module_yaml["default_selected"] - return meta - - -def apply_result_templates(module_yaml: dict, module_answers: dict, verbose: bool = False) -> dict: - """Apply result templates from module.yaml to transform raw answer values. - - For each answer, if the corresponding variable definition in module.yaml has - a 'result' field, replaces {value} in that template with the answer. Skips - the template if the answer already contains '{project-root}' to prevent - double-prefixing. - """ - transformed = {} - for key, value in module_answers.items(): - var_def = module_yaml.get(key) - if isinstance(var_def, dict) and "result" in var_def and "{project-root}" not in str(value): - template = var_def["result"] - transformed[key] = template.replace("{value}", str(value)) - if verbose: - print( - f"Applied result template for '{key}': {value} → {transformed[key]}", - file=sys.stderr, - ) - else: - transformed[key] = value - return transformed - - -def merge_config( - existing_config: dict, - module_yaml: dict, - answers: dict, - verbose: bool = False, -) -> dict: - """Merge answers into config, applying anti-zombie pattern. - - Args: - existing_config: Current config.yaml contents (may be empty) - module_yaml: The module definition - answers: JSON with 'core' and/or 'module' keys - verbose: Print progress to stderr - - Returns: - Updated config dict ready to write - """ - config = dict(existing_config) - module_code = module_yaml.get("code") - - if not module_code: - print("Error: module.yaml must have a 'code' field", file=sys.stderr) - sys.exit(1) - - # Migrate legacy core: section to root - if "core" in config and isinstance(config["core"], dict): - if verbose: - print("Migrating legacy 'core' section to root", file=sys.stderr) - config.update(config.pop("core")) - - # Strip user-only keys from config — they belong exclusively in config.user.yaml - for key in _CORE_USER_KEYS: - if key in config: - if verbose: - print( - f"Removing user-only key '{key}' from config (belongs in config.user.yaml)", - file=sys.stderr, - ) - del config[key] - - # Write core values at root (global properties, not nested under "core") - # Exclude user-only keys — those belong exclusively in config.user.yaml - core_answers = answers.get("core") - if core_answers: - shared_core = {k: v for k, v in core_answers.items() if k not in _CORE_USER_KEYS} - if shared_core: - if verbose: - print( - f"Writing core config at root: {list(shared_core.keys())}", - file=sys.stderr, - ) - config.update(shared_core) - - # Anti-zombie: remove existing module section - if module_code in config: - if verbose: - print( - f"Removing existing '{module_code}' section (anti-zombie)", - file=sys.stderr, - ) - del config[module_code] - - # Build module section: metadata + variable values - module_section = extract_module_metadata(module_yaml) - module_answers = apply_result_templates(module_yaml, answers.get("module", {}), verbose) - module_section.update(module_answers) - - if verbose: - print( - f"Writing '{module_code}' section with keys: {list(module_section.keys())}", - file=sys.stderr, - ) - - config[module_code] = module_section - - return config - - -# Core keys that are always written to config.user.yaml -_CORE_USER_KEYS = ("user_name", "communication_language") - - -def extract_user_settings(module_yaml: dict, answers: dict) -> dict: - """Collect settings that belong in config.user.yaml. - - Includes user_name and communication_language from core answers, plus any - module variable whose definition contains user_setting: true. - """ - user_settings = {} - - core_answers = answers.get("core", {}) - for key in _CORE_USER_KEYS: - if key in core_answers: - user_settings[key] = core_answers[key] - - module_answers = answers.get("module", {}) - for var_name, var_def in module_yaml.items(): - if isinstance(var_def, dict) and var_def.get("user_setting") is True: - if var_name in module_answers: - user_settings[var_name] = module_answers[var_name] - - return user_settings - - -def write_config(config: dict, config_path: str, verbose: bool = False) -> None: - """Write config dict to YAML file, creating parent dirs as needed.""" - path = Path(config_path) - path.parent.mkdir(parents=True, exist_ok=True) - - if verbose: - print(f"Writing config to {path}", file=sys.stderr) - - with open(path, "w", encoding="utf-8") as f: - yaml.dump( - config, - f, - default_flow_style=False, - allow_unicode=True, - sort_keys=False, - ) - - -def reject_unresolved_paths(named_paths: list[tuple[str, str]]) -> None: - """Exit with a clear error if any path argument still contains the literal - ``{project-root}`` token. That token is meaningful only inside config - values; filesystem path arguments must be resolved by the caller. Failing - loudly here prevents silently creating a junk ``{project-root}/`` directory. - """ - for name, value in named_paths: - if value and "{project-root}" in value: - print( - json.dumps( - { - "status": "error", - "error": ( - f"Unresolved '{{project-root}}' token in {name} path: {value!r}. " - "Resolve '{project-root}' to the actual project root before running " - "this script — it is a filesystem path, not a config value." - ), - }, - indent=2, - ), - file=sys.stderr, - ) - sys.exit(1) - - -def main(): - args = parse_args() - - reject_unresolved_paths( - [ - ("--config-path", args.config_path), - ("--user-config-path", args.user_config_path), - ("--legacy-dir", args.legacy_dir), - ] - ) - - # Load inputs - module_yaml = load_yaml_file(args.module_yaml) - if not module_yaml: - print( - f"Error: Could not load module.yaml from {args.module_yaml}", - file=sys.stderr, - ) - sys.exit(1) - - answers = load_json_file(args.answers) - existing_config = load_yaml_file(args.config_path) - - if args.verbose: - exists = Path(args.config_path).exists() - print(f"Config file exists: {exists}", file=sys.stderr) - if exists: - print(f"Existing sections: {list(existing_config.keys())}", file=sys.stderr) - - # Legacy migration: read old per-module configs as fallback defaults - legacy_files_found = [] - if args.legacy_dir: - module_code = module_yaml.get("code", "") - legacy_core, legacy_module, legacy_files_found = load_legacy_values( - args.legacy_dir, module_code, module_yaml, args.verbose - ) - if legacy_core or legacy_module: - answers = apply_legacy_defaults(answers, legacy_core, legacy_module) - if args.verbose: - print("Applied legacy values as fallback defaults", file=sys.stderr) - - # Merge and write config.yaml - updated_config = merge_config(existing_config, module_yaml, answers, args.verbose) - write_config(updated_config, args.config_path, args.verbose) - - # Merge and write config.user.yaml - user_settings = extract_user_settings(module_yaml, answers) - existing_user_config = load_yaml_file(args.user_config_path) - updated_user_config = dict(existing_user_config) - updated_user_config.update(user_settings) - if user_settings: - write_config(updated_user_config, args.user_config_path, args.verbose) - - # Legacy cleanup: delete old per-module config files - legacy_deleted = [] - if args.legacy_dir: - legacy_deleted = cleanup_legacy_configs(args.legacy_dir, module_yaml["code"], args.verbose) - - # Output result summary as JSON - module_code = module_yaml["code"] - result = { - "status": "success", - "config_path": str(Path(args.config_path).resolve()), - "user_config_path": str(Path(args.user_config_path).resolve()), - "module_code": module_code, - "core_updated": bool(answers.get("core")), - "module_keys": list(updated_config.get(module_code, {}).keys()), - "user_keys": list(user_settings.keys()), - "legacy_configs_found": legacy_files_found, - "legacy_configs_deleted": legacy_deleted, - } - print(json.dumps(result, indent=2)) - - -if __name__ == "__main__": - main() diff --git a/src/bmad_loop/data/skills/bmad-loop-setup/scripts/merge-help-csv.py b/src/bmad_loop/data/skills/bmad-loop-setup/scripts/merge-help-csv.py deleted file mode 100755 index 784b9436..00000000 --- a/src/bmad_loop/data/skills/bmad-loop-setup/scripts/merge-help-csv.py +++ /dev/null @@ -1,242 +0,0 @@ -#!/usr/bin/env python3 -# /// script -# requires-python = ">=3.9" -# dependencies = [] -# /// -"""Merge module help entries into shared _bmad/module-help.csv. - -Reads a source CSV with module help entries and merges them into a target CSV. -Uses an anti-zombie pattern: all existing rows matching the source module code -are removed before appending fresh rows. - -Legacy cleanup: DISABLED. Old per-module module-help.csv files (including -_bmad/core/module-help.csv) are never deleted — on BMAD v6 they are live, -manifest-tracked files. The --legacy-dir / --module-code flags are accepted for -backward compatibility but no longer remove anything. - -Exit codes: 0=success, 1=validation error, 2=runtime error -""" - -import argparse -import csv -import json -import sys -from io import StringIO -from pathlib import Path - -# CSV header for module-help.csv -HEADER = [ - "module", - "skill", - "display-name", - "menu-code", - "description", - "action", - "args", - "phase", - "after", - "before", - "required", - "output-location", - "outputs", -] - - -def parse_args(): - parser = argparse.ArgumentParser( - description="Merge module help entries into shared _bmad/module-help.csv with anti-zombie pattern." - ) - parser.add_argument( - "--target", - required=True, - help="Path to the target _bmad/module-help.csv file", - ) - parser.add_argument( - "--source", - required=True, - help="Path to the source module-help.csv with entries to merge", - ) - parser.add_argument( - "--legacy-dir", - help="Path to _bmad/ directory to check for legacy per-module CSV files.", - ) - parser.add_argument( - "--module-code", - help="Module code (required with --legacy-dir for scoping cleanup).", - ) - parser.add_argument( - "--verbose", - action="store_true", - help="Print detailed progress to stderr", - ) - return parser.parse_args() - - -def read_csv_rows(path: str) -> tuple[list[str], list[list[str]]]: - """Read CSV file returning (header, data_rows). - - Returns empty header and rows if file doesn't exist. - """ - file_path = Path(path) - if not file_path.exists(): - return [], [] - - with open(file_path, "r", encoding="utf-8", newline="") as f: - content = f.read() - - reader = csv.reader(StringIO(content)) - rows = list(reader) - - if not rows: - return [], [] - - return rows[0], rows[1:] - - -def extract_module_codes(rows: list[list[str]]) -> set[str]: - """Extract unique module codes from data rows.""" - codes = set() - for row in rows: - if row and row[0].strip(): - codes.add(row[0].strip()) - return codes - - -def filter_rows(rows: list[list[str]], module_code: str) -> list[list[str]]: - """Remove all rows matching the given module code.""" - return [row for row in rows if not row or row[0].strip() != module_code] - - -def write_csv(path: str, header: list[str], rows: list[list[str]], verbose: bool = False) -> None: - """Write header + rows to CSV file, creating parent dirs as needed.""" - file_path = Path(path) - file_path.parent.mkdir(parents=True, exist_ok=True) - - if verbose: - print(f"Writing {len(rows)} data rows to {path}", file=sys.stderr) - - with open(file_path, "w", encoding="utf-8", newline="") as f: - writer = csv.writer(f) - writer.writerow(header) - for row in rows: - writer.writerow(row) - - -def cleanup_legacy_csvs(legacy_dir: str, module_code: str, verbose: bool = False) -> list: - """Intentionally does NOT delete any legacy CSV files (returns an empty list). - - Old per-module module-help.csv files — including _bmad/core/module-help.csv — are - live, manifest-tracked config on BMAD v6, so removing them destroys shared BMAD - state or desyncs _bmad/_config/files-manifest.csv. The anti-zombie merge into the - shared _bmad/module-help.csv already supersedes their entries, so leaving them in - place is harmless. Kept as a function so callers and the result JSON stay stable. - """ - if verbose: - print( - "Preserving legacy module-help.csv files (live BMAD v6 config is never deleted)", - file=sys.stderr, - ) - return [] - - -def reject_unresolved_paths(named_paths: list[tuple[str, str]]) -> None: - """Exit with a clear error if any path argument still contains the literal - ``{project-root}`` token. That token is meaningful only inside config - values; filesystem path arguments must be resolved by the caller. Failing - loudly here prevents silently creating a junk ``{project-root}/`` directory. - """ - for name, value in named_paths: - if value and "{project-root}" in value: - print( - json.dumps( - { - "status": "error", - "error": ( - f"Unresolved '{{project-root}}' token in {name} path: {value!r}. " - "Resolve '{project-root}' to the actual project root before running " - "this script — it is a filesystem path, not a config value." - ), - }, - indent=2, - ) - ) - sys.exit(1) - - -def main(): - args = parse_args() - - reject_unresolved_paths([("--target", args.target), ("--legacy-dir", args.legacy_dir)]) - - # Read source entries - source_header, source_rows = read_csv_rows(args.source) - if not source_rows: - print(f"Error: No data rows found in source {args.source}", file=sys.stderr) - sys.exit(1) - - # Determine module codes being merged - source_codes = extract_module_codes(source_rows) - if not source_codes: - print("Error: Could not determine module code from source rows", file=sys.stderr) - sys.exit(1) - - if args.verbose: - print(f"Source module codes: {source_codes}", file=sys.stderr) - print(f"Source rows: {len(source_rows)}", file=sys.stderr) - - # Read existing target (may not exist) - target_header, target_rows = read_csv_rows(args.target) - target_existed = Path(args.target).exists() - - if args.verbose: - print(f"Target exists: {target_existed}", file=sys.stderr) - if target_existed: - print(f"Existing target rows: {len(target_rows)}", file=sys.stderr) - - # Use source header if target doesn't exist or has no header - header = target_header if target_header else (source_header if source_header else HEADER) - - # Anti-zombie: remove all rows for each source module code - filtered_rows = target_rows - removed_count = 0 - for code in source_codes: - before_count = len(filtered_rows) - filtered_rows = filter_rows(filtered_rows, code) - removed_count += before_count - len(filtered_rows) - - if args.verbose and removed_count > 0: - print(f"Removed {removed_count} existing rows (anti-zombie)", file=sys.stderr) - - # Append source rows - merged_rows = filtered_rows + source_rows - - # Write result - write_csv(args.target, header, merged_rows, args.verbose) - - # Legacy cleanup: delete old per-module CSV files - legacy_deleted = [] - if args.legacy_dir: - if not args.module_code: - print( - "Error: --module-code is required when --legacy-dir is provided", - file=sys.stderr, - ) - sys.exit(1) - legacy_deleted = cleanup_legacy_csvs(args.legacy_dir, args.module_code, args.verbose) - - # Output result summary as JSON - result = { - "status": "success", - "target_path": str(Path(args.target).resolve()), - "target_existed": target_existed, - "module_codes": sorted(source_codes), - "rows_removed": removed_count, - "rows_added": len(source_rows), - "total_rows": len(merged_rows), - "legacy_csvs_deleted": legacy_deleted, - } - print(json.dumps(result, indent=2)) - - -if __name__ == "__main__": - main() diff --git a/src/bmad_loop/install.py b/src/bmad_loop/install.py index d03f2905..16884b71 100644 --- a/src/bmad_loop/install.py +++ b/src/bmad_loop/install.py @@ -43,11 +43,6 @@ # registration. Disjoint from RELAY_MARKER: "bmad_loop_probe_hook" does not # contain the substring "bmad_loop_hook". PROBE_MARKER = "bmad_loop_probe_hook" -# Pre-rename marker: the old relay/probe hooks lived under .automator/ and carried -# `bmad_auto` in their command. `bmad-loop init` strips them on upgrade so a project -# renamed from bmad-auto isn't left double-signalling. Underscore form, so it never -# matches the hyphenated upstream `bmad-dev-auto` skill. -LEGACY_HOOK_MARKER = "bmad_auto" GEMINI_HOOK_TIMEOUT_MS = 60_000 COPILOT_HOOK_TIMEOUT_SEC = 60 ANTIGRAVITY_HOOK_TIMEOUT_SEC = 60 # agy hook timeouts are seconds (default 30) @@ -64,16 +59,6 @@ "bmad-loop-setup", ) -# Pre-rename skill dirs (bmad-auto-*). `bmad-loop init` removes them from each CLI -# skill tree on upgrade so a renamed project isn't left with both the old and new -# forks side by side. Guarded on a SKILL.md inside, so only a real skill dir we own -# is ever deleted. -LEGACY_MODULE_SKILLS = ( - "bmad-auto-resolve", - "bmad-auto-sweep", - "bmad-auto-setup", -) - # Upstream skills the orchestrator invokes but does NOT bundle in the wheel — the # BMad Method (bmm) module installs them. Each must exist in every active CLI skill # tree and carry its marker files (a half-installed or pre-automation skill is @@ -673,60 +658,6 @@ def merge_hooks(config: dict, registrations: dict[str, str], dialect: str) -> tu return config, changed -def strip_legacy_hooks(config: dict) -> tuple[dict, int]: - """Remove hook handlers carrying the pre-rename `bmad_auto` marker. - - Mirrors merge_hooks' dialect shapes: copilot stores the handler dict directly - in the event list; claude/codex/gemini nest handlers under ``entry["hooks"]``. - Emptied matcher entries — and event lists left empty — are dropped so a - re-registered `bmad_loop` hook doesn't share space with a dead `bmad_auto` one. - Returns (config, removed_count). The hyphenated upstream `bmad-dev-auto` skill - never matches the underscore marker. - """ - hooks = config.get("hooks") - if not isinstance(hooks, dict): - return config, 0 - removed = 0 - for native_event in list(hooks): - matchers = hooks.get(native_event) - if not isinstance(matchers, list): - continue - kept: list = [] - for entry in matchers: - if not isinstance(entry, dict): - kept.append(entry) - continue - # copilot: the handler dict is the entry itself. Guard the command - # against a non-string (present-but-null) value so a malformed - # hand-edited config can't crash the strip with a TypeError. - if isinstance(cmd := entry.get("command"), str) and LEGACY_HOOK_MARKER in cmd: - removed += 1 - continue - # claude/codex/gemini: handlers nest under "hooks" - nested = entry.get("hooks") - if isinstance(nested, list): - pruned = [ - h - for h in nested - if not ( - isinstance(h, dict) - and isinstance(cmd := h.get("command"), str) - and LEGACY_HOOK_MARKER in cmd - ) - ] - if len(pruned) != len(nested): - removed += len(nested) - len(pruned) - if not pruned: - continue # emptied matcher entry -> drop it - entry["hooks"] = pruned - kept.append(entry) - if kept: - hooks[native_event] = kept - else: - del hooks[native_event] # emptied event -> drop it - return config, removed - - def _register_hooks(project: Path, profile: CLIProfile) -> int: if profile.hookless: print(f" no hooks needed ({profile.name}): HTTP/SSE transport") @@ -744,15 +675,11 @@ def _register_hooks(project: Path, profile: CLIProfile) -> int: native: _hook_command(project, profile, canonical) for native, canonical in profile.hooks.events.items() } - config, removed = strip_legacy_hooks(config) config, changed = merge_hooks(config, registrations, profile.hooks.dialect) - if removed: - print(f" removed {removed} legacy bmad-auto hook(s) ({profile.name})") - if changed or removed: + if changed: config_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8") - if changed: - print(f" hooks registered ({profile.name}): {config_path}") - elif not removed: + print(f" hooks registered ({profile.name}): {config_path}") + else: print(f" hooks already registered ({profile.name})") return 0 @@ -865,20 +792,6 @@ def _copy_skills(project: Path, trees: Sequence[str], force: bool) -> bool: return skipped_any -def _remove_legacy_skills(project: Path, trees: Sequence[str]) -> None: - """Delete the pre-rename bmad-auto-* skill dirs from each project skill tree. - - Guarded on a SKILL.md inside, so an unrelated same-named folder is never touched. - Idempotent (a missing dir is a no-op); prints one line per removal. - """ - for tree in dict.fromkeys(trees): - for skill in LEGACY_MODULE_SKILLS: - dst = project / tree / skill - if dst.is_dir() and (dst / "SKILL.md").is_file(): - shutil.rmtree(dst) - print(f" removed legacy skill: {tree}/{skill}") - - def _warn_if_policy_tracked(project: Path) -> None: """One-time migration hint: a .gitignore entry does not untrack an already-committed policy.toml, so repos initialized before the file was @@ -946,18 +859,12 @@ def install_into( skills_skipped = False if skills: trees = list(dict.fromkeys(p.skill_tree for p in profiles)) - _remove_legacy_skills(project, trees) skills_skipped = _copy_skills(project, trees, force_skills) - # 4. policy template — on an upgrade from bmad-auto, carry the old policy over - # (its contents are unchanged by the rename) rather than resetting to default. + # 4. policy template policy_path = bmad_loop_dir / "policy.toml" - legacy_policy = project / ".automator" / "policy.toml" if policy_path.is_file(): print(" policy exists, leaving untouched") - elif legacy_policy.is_file(): - policy_path.write_text(legacy_policy.read_text(encoding="utf-8"), encoding="utf-8") - print(f" migrated policy: {legacy_policy} -> {policy_path}") else: policy_path.write_text(POLICY_TEMPLATE, encoding="utf-8") print(f" policy written: {policy_path}") @@ -987,16 +894,6 @@ def install_into( if skills_skipped: print(" some skills already present; re-run with --force-skills to overwrite") - # 6. legacy state left in place: init never deletes the old .automator/ tree - # (runs/archives/profiles/plugins) or its stale .automator/* gitignore lines. - # Policy was carried over above; everything else is yours to keep or remove. - if (project / ".automator").is_dir(): - print( - " note: legacy .automator/ left in place (runs, archives, profiles, " - "plugins, and any stale .automator/* gitignore lines). Delete it or " - "hand-move state once you've confirmed the migration." - ) - print( "init complete. One-time setup before `bmad-loop run` — spawned " "sessions cannot answer first-run dialogs, and a pending dialog reads " diff --git a/tests/test_install.py b/tests/test_install.py index ed1530f3..47a08df8 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -11,14 +11,12 @@ from bmad_loop.install import ( BASE_SKILLS, DEV_BASE_SKILLS, - LEGACY_MODULE_SKILLS, MODULE_SKILLS, _copy_traversable, install_into, merge_hooks, missing_base_skills, provision_worktree, - strip_legacy_hooks, ) @@ -223,172 +221,17 @@ def test_merge_hooks_antigravity_preserves_other_groups(): assert settings["bmad-loop"]["Stop"][0]["command"].endswith("bmad_loop_hook.py Stop") -# ----------------------------------------------------------------- legacy migration (rename) - -LEGACY_CMD = "python3 /x/.automator/bmad_auto_hook.py Stop" - - -def test_strip_legacy_hooks_claude_shape(): - # claude/codex nest handlers under "hooks"; an emptied event is dropped entirely - config = {"hooks": {"Stop": [{"hooks": [{"type": "command", "command": LEGACY_CMD}]}]}} - config, removed = strip_legacy_hooks(config) - assert removed == 1 - assert "Stop" not in config["hooks"] - - -def test_strip_legacy_hooks_gemini_shape(): - config = {"hooks": {"AfterAgent": [{"matcher": "", "hooks": [{"command": LEGACY_CMD}]}]}} - config, removed = strip_legacy_hooks(config) - assert removed == 1 - assert "AfterAgent" not in config["hooks"] - - -def test_strip_legacy_hooks_copilot_bare_shape(): - # copilot stores the handler directly in the event list (no "hooks" wrapper) - config = {"version": 1, "hooks": {"agentStop": [{"type": "command", "command": LEGACY_CMD}]}} - config, removed = strip_legacy_hooks(config) - assert removed == 1 - assert "agentStop" not in config["hooks"] - assert config["version"] == 1 # untouched - - -def test_strip_legacy_hooks_preserves_foreign_and_new(): - # a foreign user hook and a current bmad_loop hook survive; only bmad_auto goes - config = { - "hooks": { - "Stop": [ - {"hooks": [{"type": "command", "command": LEGACY_CMD}]}, - {"hooks": [{"type": "command", "command": "echo hi"}]}, - { - "hooks": [ - {"type": "command", "command": "python3 .bmad-loop/bmad_loop_hook.py Stop"} - ] - }, - ] - } - } - config, removed = strip_legacy_hooks(config) - assert removed == 1 - commands = [h["command"] for m in config["hooks"]["Stop"] for h in m["hooks"]] - assert commands == ["echo hi", "python3 .bmad-loop/bmad_loop_hook.py Stop"] - - -def test_strip_legacy_hooks_prunes_within_matcher(): - # legacy + new share one matcher's nested list -> prune just the legacy handler - config = { - "hooks": { - "Stop": [ - { - "hooks": [ - {"type": "command", "command": LEGACY_CMD}, - {"type": "command", "command": "python3 .bmad-loop/bmad_loop_hook.py Stop"}, - ] - } - ] - } - } - config, removed = strip_legacy_hooks(config) - assert removed == 1 - handlers = config["hooks"]["Stop"][0]["hooks"] - assert [h["command"] for h in handlers] == ["python3 .bmad-loop/bmad_loop_hook.py Stop"] - - -def test_strip_legacy_hooks_tolerates_non_string_command(): - # a pre-existing handler whose "command" is a non-string (e.g. null) must not - # crash the legacy strip — it just isn't a bmad_auto hook, so it's kept. - # Guarded at both walks: the flat (copilot) entry and the nested handler. - flat = {"hooks": {"agentStop": [{"type": "command", "command": None}]}} - config, removed = strip_legacy_hooks(flat) - assert removed == 0 - assert config["hooks"]["agentStop"] == [{"type": "command", "command": None}] - - nested = { - "hooks": { - "Stop": [ - { - "hooks": [ - {"type": "command", "command": None}, - {"type": "command", "command": LEGACY_CMD}, - ] - } - ] - } - } - config, removed = strip_legacy_hooks(nested) - assert removed == 1 # only the legacy handler is pruned; the null one survives - handlers = config["hooks"]["Stop"][0]["hooks"] - assert handlers == [{"type": "command", "command": None}] - - -def test_strip_legacy_hooks_noop_without_hooks(): - assert strip_legacy_hooks({}) == ({}, 0) - assert strip_legacy_hooks({"hooks": {}})[1] == 0 - # the hyphenated upstream skill must never be mistaken for the legacy relay - config = {"hooks": {"Stop": [{"hooks": [{"command": "/bmad-dev-auto 1-2-a"}]}]}} - assert strip_legacy_hooks(config)[1] == 0 - - -def test_install_migrates_from_legacy_bmad_auto(tmp_path): - """A project that was `bmad-auto init`-ed: init strips the old hook, removes the - old skill dirs, and carries the old policy over — leaving .automator/ in place.""" - # pre-seed a legacy claude install - settings = tmp_path / ".claude" / "settings.json" - settings.parent.mkdir(parents=True) - settings.write_text( - json.dumps({"hooks": {"Stop": [{"hooks": [{"type": "command", "command": LEGACY_CMD}]}]}}), - encoding="utf-8", - ) - legacy_skill = tmp_path / ".claude" / "skills" / "bmad-auto-sweep" - legacy_skill.mkdir(parents=True) - (legacy_skill / "SKILL.md").write_text("# old\n", encoding="utf-8") - legacy_policy = tmp_path / ".automator" / "policy.toml" - legacy_policy.parent.mkdir(parents=True) - legacy_policy.write_text('[scm]\nisolation = "worktree"\n', encoding="utf-8") - - assert install_into(tmp_path) == 0 - - # legacy hook stripped, current bmad_loop hook registered in its place - result = json.loads(settings.read_text()) - cmds = [h["command"] for m in result["hooks"]["Stop"] for h in m["hooks"]] - assert not any("bmad_auto" in c for c in cmds) - assert any("bmad_loop_hook" in c for c in cmds) - # legacy skill dir removed; new forks installed - assert not legacy_skill.exists() - for skill in MODULE_SKILLS: - assert (tmp_path / ".claude" / "skills" / skill / "SKILL.md").is_file() - # old policy carried over verbatim; .automator/ left in place - migrated = (tmp_path / ".bmad-loop" / "policy.toml").read_text() - assert migrated == '[scm]\nisolation = "worktree"\n' - assert (tmp_path / ".automator").is_dir() - - # idempotent: re-run doesn't duplicate hooks or re-create the legacy skill - assert install_into(tmp_path) == 0 - result = json.loads(settings.read_text()) - assert len(result["hooks"]["Stop"]) == 1 - assert not legacy_skill.exists() - - -def test_install_does_not_clobber_existing_policy_over_legacy(tmp_path): - """When .bmad-loop/policy.toml already exists, a legacy .automator/policy.toml - must not overwrite it.""" +def test_install_does_not_clobber_existing_policy(tmp_path): + """An existing .bmad-loop/policy.toml is per-machine state: init must leave it + alone rather than resetting it to the template.""" current = tmp_path / ".bmad-loop" / "policy.toml" current.parent.mkdir(parents=True) current.write_text("CURRENT", encoding="utf-8") - legacy = tmp_path / ".automator" / "policy.toml" - legacy.parent.mkdir(parents=True) - legacy.write_text("LEGACY", encoding="utf-8") assert install_into(tmp_path) == 0 assert current.read_text() == "CURRENT" -def test_install_legacy_skills_constant_matches_module_skills(): - # the legacy names are exactly the current ones with the old prefix - assert LEGACY_MODULE_SKILLS == tuple( - s.replace("bmad-loop-", "bmad-auto-") for s in MODULE_SKILLS - ) - - def test_copilot_profile_render_prompt(): # {skill} must expand plainly (no codex-style $ prefix) into the SKILL.md path profile = get_profile("copilot") diff --git a/tests/test_setup_cleanup_scripts.py b/tests/test_setup_cleanup_scripts.py deleted file mode 100644 index 2fa2bae4..00000000 --- a/tests/test_setup_cleanup_scripts.py +++ /dev/null @@ -1,338 +0,0 @@ -"""Regression guard for bmad-loop#64: the bmad-loop-setup cleanup scripts must -never delete live, manifest-tracked BMAD config/state. - -On a BMAD v6 install ``_bmad/core/``, ``_bmad//`` and ``_bmad/_config/`` -hold live config + installer manifests (no staged ``SKILL.md``). The setup scripts -used to hardcode ``core`` and ``--also-remove _config`` into their delete lists, -destroying that shared state. These tests run the real scripts as the SKILL.md -documents and assert only genuine redundant skill-payload dirs are ever removed. - -Root cause is shared with upstream ``bmad-code-org/bmad-builder#96``. -""" - -from __future__ import annotations - -import json -import subprocess -import sys -from pathlib import Path - -REPO = Path(__file__).resolve().parent.parent -SCRIPTS = REPO / "src" / "bmad_loop" / "data" / "skills" / "bmad-loop-setup" / "scripts" -ASSETS = REPO / "src" / "bmad_loop" / "data" / "skills" / "bmad-loop-setup" / "assets" - - -def _run(script: str, *args: str) -> subprocess.CompletedProcess: - return subprocess.run( - [sys.executable, str(SCRIPTS / script), *args], - capture_output=True, - text=True, - ) - - -def _run_json(script: str, *args: str) -> dict: - proc = _run(script, *args) - assert ( - proc.returncode == 0 - ), f"{script} exit {proc.returncode}\nOUT:{proc.stdout}\nERR:{proc.stderr}" - return json.loads(proc.stdout) - - -def _v6_bmad(tmp_path: Path) -> Path: - """A realistic BMAD v6 `_bmad/` tree: live config + manifest, no staged skills.""" - bmad = tmp_path / "_bmad" - (bmad / "core").mkdir(parents=True) - (bmad / "core" / "config.yaml").write_text("user_name: BMad\noutput_folder: out\n") - (bmad / "core" / "module-help.csv").write_text("module,skill\ncore,x\n") - (bmad / "bmm").mkdir() - (bmad / "bmm" / "config.yaml").write_text("dev_story_location: docs\n") - (bmad / "bmad-loop").mkdir() - (bmad / "bmad-loop" / "config.yaml").write_text("cadence: fast\n") - cfg = bmad / "_config" - cfg.mkdir() - (cfg / "manifest.yaml").write_text("installation:\n version: 6.10.0\n") - (cfg / "files-manifest.csv").write_text("path,hash\n_bmad/core/config.yaml,abc\n") - (cfg / "bmad-help.csv").write_text("module,skill\ncore,x\n") - return bmad - - -def _skills_dir(tmp_path: Path, *installed: str) -> Path: - skills = tmp_path / ".claude" / "skills" - skills.mkdir(parents=True) - for name in installed: - d = skills / name - d.mkdir() - (d / "SKILL.md").write_text(f"# {name}\n") - return skills - - -# ---------------------------------------------------------------- cleanup-legacy - - -def test_cleanup_preserves_core_and_config_on_v6(tmp_path): - """The documented invocation must be a no-op on a v6 install.""" - bmad = _v6_bmad(tmp_path) - skills = _skills_dir(tmp_path) # nothing installed — bmad-loop dir is config-bearing - - result = _run_json( - "cleanup-legacy.py", - "--bmad-dir", - str(bmad), - "--module-code", - "bmad-loop", - "--skills-dir", - str(skills), - ) - - assert result["directories_removed"] == [] - assert (bmad / "bmad-loop" / "config.yaml").exists() - assert (bmad / "core" / "config.yaml").exists() - assert (bmad / "_config" / "manifest.yaml").exists() - protected = {p["dir"] for p in result["directories_protected"]} - assert "bmad-loop" in protected - - -def test_cleanup_refuses_explicit_core_and_config(tmp_path): - """Even an explicit --module-code core / --also-remove _config must not delete them.""" - bmad = _v6_bmad(tmp_path) - skills = _skills_dir(tmp_path) - - result = _run_json( - "cleanup-legacy.py", - "--bmad-dir", - str(bmad), - "--module-code", - "core", - "--also-remove", - "_config", - "--skills-dir", - str(skills), - ) - - assert result["directories_removed"] == [] - assert (bmad / "core" / "config.yaml").exists() - assert (bmad / "_config" / "manifest.yaml").exists() - assert (bmad / "_config" / "files-manifest.csv").exists() - - -def test_cleanup_removes_genuine_redundant_payload(tmp_path): - """A dir with an installed SKILL.md payload and no config IS cleaned.""" - bmad = _v6_bmad(tmp_path) - payload = bmad / "legacy-mod" - payload.mkdir() - (payload / "SKILL.md").write_text("# legacy-mod\n") # skill name == 'legacy-mod' - skills = _skills_dir(tmp_path, "legacy-mod") - - result = _run_json( - "cleanup-legacy.py", - "--bmad-dir", - str(bmad), - "--module-code", - "legacy-mod", - "--skills-dir", - str(skills), - ) - - assert result["directories_removed"] == ["legacy-mod"] - assert not payload.exists() - # protecting v6 state is unaffected - assert (bmad / "core" / "config.yaml").exists() - - -def test_cleanup_errors_when_payload_skill_not_installed(tmp_path): - """A payload dir whose skill is missing from skills-dir is an error, not a delete.""" - bmad = _v6_bmad(tmp_path) - payload = bmad / "legacy-mod" - payload.mkdir() - (payload / "SKILL.md").write_text("# legacy-mod\n") - skills = _skills_dir(tmp_path) # legacy-mod NOT installed - - proc = _run( - "cleanup-legacy.py", - "--bmad-dir", - str(bmad), - "--module-code", - "legacy-mod", - "--skills-dir", - str(skills), - ) - assert proc.returncode == 1 - assert payload.exists() - err = json.loads(proc.stdout) - assert err["status"] == "error" - assert "legacy-mod" in err["missing_skills"] - - -def test_cleanup_reports_removable_dir_absent_at_removal_time(tmp_path): - """A removable dir gone by removal time must surface in directories_not_found. - - Regression for bmad-loop#73 review: cleanup_directories()'s not_found used to be - discarded, so such a dir silently vanished from every JSON list. Exercised - deterministically via a nested --also-remove target whose parent is removed first. - """ - bmad = _v6_bmad(tmp_path) - # 'legacy/' has no SKILL.md of its own; its only payload is 'legacy/child'. - (bmad / "legacy" / "child").mkdir(parents=True) - (bmad / "legacy" / "child" / "SKILL.md").write_text("# child\n") - skills = _skills_dir(tmp_path, "child") - - result = _run_json( - "cleanup-legacy.py", - "--bmad-dir", - str(bmad), - "--module-code", - "legacy", - "--also-remove", - "legacy/child", # removed together with its parent 'legacy' earlier in the run - "--skills-dir", - str(skills), - ) - - # Parent is removed; the nested child is already gone by its turn but must still - # be reported rather than silently dropped from all output lists. - assert result["directories_removed"] == ["legacy"] - assert "legacy/child" in result["directories_not_found"] - assert not (bmad / "legacy").exists() - - -def test_cleanup_protects_core_by_name_even_as_pure_payload(tmp_path): - """core/ is never removed — even holding only a SKILL.md payload and no config. - - Regression for bmad-loop#73 review: the docstring promised 'core' is never - removed, but only _config was protected by name; a marker-less core payload - slipped through the config-bearing check. - """ - bmad = tmp_path / "_bmad" - core = bmad / "core" - core.mkdir(parents=True) - (core / "SKILL.md").write_text("# core\n") # no config markers at all - skills = _skills_dir(tmp_path, "core") - - result = _run_json( - "cleanup-legacy.py", - "--bmad-dir", - str(bmad), - "--module-code", - "core", - "--skills-dir", - str(skills), - ) - - assert result["directories_removed"] == [] - assert (core / "SKILL.md").exists() - protected = {p["dir"] for p in result["directories_protected"]} - assert "core" in protected - - -def test_cleanup_protects_dir_with_nested_live_config(tmp_path): - """A config marker nested below the top level still protects the whole dir. - - Regression for bmad-loop#73 review: is_config_bearing() only looked at the - candidate's top level while skill discovery was recursive, so a dir with a - SKILL.md and deeper live config was misclassified as a removable payload. - """ - bmad = _v6_bmad(tmp_path) - legacy = bmad / "legacy-mod" - (legacy / "sub").mkdir(parents=True) - (legacy / "SKILL.md").write_text("# legacy-mod\n") - (legacy / "sub" / "config.yaml").write_text("live: true\n") # nested live state - skills = _skills_dir(tmp_path, "legacy-mod") - - result = _run_json( - "cleanup-legacy.py", - "--bmad-dir", - str(bmad), - "--module-code", - "legacy-mod", - "--skills-dir", - str(skills), - ) - - assert result["directories_removed"] == [] - assert (legacy / "sub" / "config.yaml").exists() - protected = {p["dir"] for p in result["directories_protected"]} - assert "legacy-mod" in protected - - -def test_cleanup_removes_payload_whose_skill_ships_marker_named_assets(tmp_path): - """Marker-named files inside a SKILL.md-bearing subtree are payload, not live state. - - Staged skill payloads legitimately ship files like assets/module-help.csv - (bmad-loop-setup itself does); the recursive config scan must not - false-protect such a dir or the documented bauto rename-cleanup would no-op. - """ - bmad = _v6_bmad(tmp_path) - legacy = bmad / "legacy-mod" - foo = legacy / "skills" / "foo" - (foo / "assets").mkdir(parents=True) - (foo / "SKILL.md").write_text("# foo\n") - (foo / "assets" / "module-help.csv").write_text("module,skill\nfoo,x\n") - skills = _skills_dir(tmp_path, "foo") - - result = _run_json( - "cleanup-legacy.py", - "--bmad-dir", - str(bmad), - "--module-code", - "legacy-mod", - "--skills-dir", - str(skills), - ) - - assert result["directories_removed"] == ["legacy-mod"] - assert not legacy.exists() - # live v6 state is untouched - assert (bmad / "core" / "config.yaml").exists() - - -# ---------------------------------------------------------------- merge-config - - -def test_merge_config_preserves_legacy_configs(tmp_path): - bmad = _v6_bmad(tmp_path) - answers = tmp_path / "answers.json" - answers.write_text("{}") - - result = _run_json( - "merge-config.py", - "--config-path", - str(bmad / "config.yaml"), - "--user-config-path", - str(bmad / "config.user.yaml"), - "--module-yaml", - str(ASSETS / "module.yaml"), - "--answers", - str(answers), - "--legacy-dir", - str(bmad), - ) - - assert result["legacy_configs_deleted"] == [] - # live per-module + core config survive - assert (bmad / "core" / "config.yaml").exists() - assert (bmad / "bmad-loop" / "config.yaml").exists() - # and the consolidated config was still written - assert (bmad / "config.yaml").exists() - - -# ---------------------------------------------------------------- merge-help-csv - - -def test_merge_help_csv_preserves_legacy_csvs(tmp_path): - bmad = _v6_bmad(tmp_path) - - result = _run_json( - "merge-help-csv.py", - "--target", - str(bmad / "module-help.csv"), - "--source", - str(ASSETS / "module-help.csv"), - "--legacy-dir", - str(bmad), - "--module-code", - "bmad-loop", - ) - - assert result["legacy_csvs_deleted"] == [] - assert (bmad / "core" / "module-help.csv").exists() - assert (bmad / "module-help.csv").exists() diff --git a/tests/test_setup_skill_contract.py b/tests/test_setup_skill_contract.py new file mode 100644 index 00000000..4e25d049 --- /dev/null +++ b/tests/test_setup_skill_contract.py @@ -0,0 +1,97 @@ +"""Contract guards for the shipped `bmad-loop-setup` skill. + +Two independent contracts meet in this one directory, and both are easy to break +silently: + +1. **The BMAD installer's resolver contract.** bmad-loop is registered in + BMAD-METHOD's `bmad-modules.yaml` as a `marketplace-plugin`, and the installer + resolves it through `plugin-resolver.js` strategy 2 — "a skill whose directory + name ends in `-setup`, carrying `assets/module.yaml` **and** + `assets/module-help.csv`". If any of those three go missing, resolution falls + through and `official-modules.js` *throws*: the module stops installing. + Nothing in this repo reads those asset files at runtime, so only a test keeps + them honest. + +2. **No writes to the legacy BMAD config layout** (#258). Setup used to write + `_bmad/config.yaml`, `_bmad/config.user.yaml` and a root + `_bmad/module-help.csv` — files BMAD v6.10 never reads. The BMAD installer owns + module registration; the skill's only `_bmad/` write is the per-module help CSV. + These are prose assertions because the skill *is* prose — the instructions are + the executable. +""" + +import csv + +import pytest + +from bmad_loop.install import MODULE_SKILLS + +SKILL_DIR = "bmad-loop-setup" + + +@pytest.fixture(scope="module") +def skill_root(): + from importlib import resources + + return resources.files("bmad_loop.data").joinpath("skills").joinpath(SKILL_DIR) + + +@pytest.fixture(scope="module") +def skill_md(skill_root): + return skill_root.joinpath("SKILL.md").read_text(encoding="utf-8") + + +def test_setup_skill_is_bundled(): + # the installer's strategy-2 match keys off the trailing "-setup" + assert SKILL_DIR in MODULE_SKILLS + assert SKILL_DIR.endswith("-setup") + + +@pytest.mark.parametrize("asset", ["module.yaml", "module-help.csv"]) +def test_installer_required_assets_present(skill_root, asset): + # plugin-resolver.js strategy 2 needs BOTH or bmad-loop stops resolving + assert skill_root.joinpath("assets").joinpath(asset).is_file() + + +def test_module_help_csv_shape(skill_root): + """`mergeModuleHelpCatalogs` parses this positionally and keys rows off column 0.""" + raw = skill_root.joinpath("assets").joinpath("module-help.csv").read_text(encoding="utf-8") + rows = list(csv.reader(raw.splitlines())) + header, data = rows[0], rows[1:] + assert header[0] == "module" + assert len(header) == 13, f"help CSV column count changed: {header}" + assert data, "module-help.csv must carry at least one skill row" + for row in data: + assert len(row) == len(header), f"ragged row: {row}" + assert row[0] == "BMAD Loop Skills", f"unexpected module column: {row[0]!r}" + + +# The legacy layout (#258) and the pre-rename module code: both are gone, and the +# skill must not reintroduce either. Substring match on purpose — the failure mode +# is prose drifting back, not an exact string reappearing. +@pytest.mark.parametrize( + "forbidden", + [ + "config.yaml", + "config.user.yaml", + "merge-config.py", + "merge-help-csv.py", + "cleanup-legacy.py", + "bauto", + "bmad-auto", + ".automator", + ], +) +def test_skill_md_drops_legacy_layout(skill_md, forbidden): + assert forbidden not in skill_md + + +def test_skill_md_registers_help_at_the_path_v610_reads(skill_md): + # the per-module CSV is the only path the installer's catalog merge scans; + # a root _bmad/module-help.csv is never read. + assert "_bmad/bmad-loop/module-help.csv" in skill_md + + +def test_setup_skill_ships_no_scripts(skill_root): + # all three PEP 723 scripts were retired with #258/#259; nothing left to invoke + assert not skill_root.joinpath("scripts").is_dir()