feat: design system overhaul — sidebar, AI chats, settings, brainstorm, color cleanup
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s

- Sidebar: dynamic brand-accent colors, brainstorm section restyled
- AI chat general: popup panel with expand/collapse, hides when contextual AI open
- AI chat contextual: tabs reordered (Actions first), X close button, height fix
- Settings: all tabs restyled, 6 new color presets (sage, terracotta, iron, etc.)
- Global color cleanup: emerald/orange hardcoded → brand-accent dynamic
- Brainstorm page: orange → brand-accent throughout
- PageEntry animation component added to key pages
- Floating AI button: bg-brand-accent instead of hardcoded black
- i18n: all 15 locales updated with new AI/billing keys
- Billing: freemium quota tracking, BYOK, stripe subscription scaffolding
- Admin: integrated into new design
- AGENTS.md + CLAUDE.md project rules added
This commit is contained in:
Antigravity
2026-05-16 12:59:30 +00:00
parent 1fcea6ed7d
commit bd495be965
2284 changed files with 395285 additions and 2327 deletions

View File

@@ -0,0 +1,114 @@
---
name: suno-setup
description: Sets up Suno Band Manager module in a project. Use when the user requests to 'install suno module', 'configure Suno Band Manager', or 'setup Suno Band Manager'.
---
# Module Setup
## Overview
Installs and configures a BMad module into a project. Module identity (name, code, version) comes from `./assets/module.yaml`. Collects user preferences and writes them to three files:
- **`{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.
- **`{project-root}/_bmad/core/config.yaml`** and **`{project-root}/_bmad/suno/config.yaml`** — per-module config files written automatically by `merge-config.py` so that `bmad-init` can load config at runtime. These bridge the shared config format with `bmad-init`'s expected per-module layout.
Both config scripts use an anti-zombie pattern — existing entries for this module are removed before writing fresh ones, so stale values never persist.
`{project-root}` is a **literal token** in config values — never substitute it with an actual path. It signals to the consuming LLM that the value is relative to the project root, not the skill root.
## On Activation
1. Read `./assets/module.yaml` for module metadata and variable definitions (the `code` field is the module identifier)
2. **Detect installation mode:**
- If `{project-root}/_bmad/config.yaml` exists with a section for this module → this is an **update**
- If `{project-root}/_bmad/` exists but no module section → this is a **fresh BMad install**
- If `{project-root}/_bmad/` does not exist → this is a **standalone install**. Create `_bmad/` and proceed with defaults. Inform the user: "Setting up standalone — no BMad Method detected, using direct configuration."
3. Check for per-module configuration at `{project-root}/_bmad/suno/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, per-module config files and directories will be cleaned up after setup.
If the user provides arguments (e.g. `accept all defaults`, `--headless`, 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.
## Collect Configuration
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.
**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.
**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.
**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).
## Write Files
Write a temp JSON file with the collected answers structured as `{"core": {...}, "module": {...}}` (omit `core` if it already exists). Then run both scripts — they can run in parallel since they write to different files:
```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 suno
```
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, then delete the legacy files after a successful merge. `merge-config.py` also writes per-module config files (`_bmad/core/config.yaml` and `_bmad/suno/config.yaml`) that `bmad-init` reads at runtime. Check `legacy_configs_deleted`, `legacy_csvs_deleted`, and `init_configs_written` in the output to confirm.
Run `./scripts/merge-config.py --help` or `./scripts/merge-help-csv.py --help` for full usage.
## 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.
## Cleanup Legacy Directories
After both merge scripts complete successfully, remove the installer's package directories. Skills and agents in these directories are already installed at `.claude/skills/` — the `_bmad/` directory should only contain config files.
```bash
python3 ./scripts/cleanup-legacy.py --bmad-dir "{project-root}/_bmad" --module-code suno --also-remove _config --skills-dir "{project-root}/.claude/skills"
```
The script verifies that every skill in the legacy directories exists at `.claude/skills/` before removing anything. Directories without skills (like `_config/`) are removed directly. The script preserves `config.yaml` files in directories being cleaned — `bmad-init` needs these per-module config files at runtime. 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.
Check `directories_removed` and `files_removed_count` in the JSON output for the confirmation step. Run `./scripts/cleanup-legacy.py --help` for full usage.
## Configure Pipeline Guard (Optional)
After config and cleanup are complete, offer to configure the pipeline guard. The guard enforces Mac's mandatory production pipeline — it prevents hand-building Suno packages without running the formal skill pipeline (Style Prompt Builder + Lyric Transformer).
Ask: "Want me to set up the pipeline guard? It ensures Mac always runs the production skills before presenting a Suno package. I can configure it for your coding tool."
If the user declines, skip to Confirm.
If the user accepts, configure both layers:
### Claude Code Stop Hook
If the project has a `.claude/` directory (indicating Claude Code usage), configure the deterministic Stop hook:
```bash
python3 ./scripts/configure-guard.py --settings-path "{project-root}/.claude/settings.local.json" --guard-script-path ".claude/skills/suno-agent-band-manager/scripts/pipeline-guard.py"
```
The script merges the hook into existing settings without overwriting other configuration. It's idempotent — skips if already configured. Check the JSON output for `status` ("configured", "already_configured", or "error").
**Path note:** The hook command uses `$CLAUDE_PROJECT_DIR` (a Claude Code environment variable) so it works regardless of where the project lives on disk.
### Standing Order (All Platforms)
Configure the cross-platform standing order in `AGENTS.md` — readable by Codex CLI, Cursor, GitHub Copilot, Windsurf, Amp, and Gemini CLI (when configured to read AGENTS.md):
```bash
python3 ./scripts/configure-guard.py --agents-md-path "{project-root}/AGENTS.md"
```
The script appends the standing order section to AGENTS.md (creates the file if it doesn't exist). Idempotent — skips if the section already exists.
**Both commands can run in parallel** since they write to different files. Report what was configured in the Confirm step.
## Confirm
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), user settings written to `config.user.yaml` (`user_keys` in result), init-compatible per-module configs written (`init_configs_written`), help entries added, fresh install vs update. If legacy files were deleted, mention the migration. If legacy directories were removed, report the count and list (e.g. "Cleaned up 106 installer package files from bmb/, core/, \_config/ — skills are installed at .claude/skills/"). Then display the `module_greeting` from `./assets/module.yaml` to the user.
## Outcome
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`.

View File

@@ -0,0 +1,13 @@
module,skill,display-name,menu-code,description,action,args,phase,after,before,required,output-location,outputs
Suno Band Manager,suno-setup,Setup Suno Module,SU,"Install or update Suno Band Manager module config and help entries.",configure,"{-H: headless mode}|{inline values: skip prompts with provided values}",anytime,,,false,{project-root}/_bmad,config.yaml and config.user.yaml
Suno Band Manager,suno-agent-band-manager,Create Song,CS,"Create a complete Suno-ready song package with style prompt + lyrics + parameters through guided creative conversation.",create-song,,anytime,suno-band-profile-manager:manage-profiles,,false,{songbook_folder},song package
Suno Band Manager,suno-agent-band-manager,Refine Song,RS,"Post-generation refinement: translate feedback into concrete Suno parameter adjustments.",refine-song,,anytime,suno-agent-band-manager:create-song,,false,{songbook_folder},refined song package
Suno Band Manager,suno-agent-band-manager,Browse Songbook,SB,"Browse past songs and successful prompts from your creative history.",browse-songbook,,anytime,suno-agent-band-manager:create-song,,false,{songbook_folder},
Suno Band Manager,suno-agent-band-manager,Save Memory,SM,"Save current session context to Mac's memory for next time.",save-memory,,anytime,,,false,,
Suno Band Manager,suno-band-profile-manager,Manage Bands,MB,"Create, edit, list, duplicate, or delete band identity profiles with genre, vocal direction, and writer voice.",manage-profiles,"{-H: headless mode}|{--headless:create|edit|load|delete|duplicate|validate}",anytime,,suno-style-prompt-builder:build-style-prompt,false,{band_profiles_folder},band profile YAML
Suno Band Manager,suno-band-profile-manager,Analyze Writer Voice,WV,"Extract writing voice patterns from samples and store in a band profile.",analyze-writer-voice,,anytime,,suno-lyric-transformer:transform-lyrics,false,{band_profiles_folder},writer voice analysis
Suno Band Manager,suno-band-profile-manager,Profile Health Check,HC,"Assess profile completeness and quality beyond structural validation.",health-check,,anytime,suno-band-profile-manager:manage-profiles,,false,,health assessment
Suno Band Manager,suno-style-prompt-builder,Build Style Prompt,SP,"Generate model-aware Suno style prompts with creativity modes, wild card variants, and exclusion prompts optimized for your chosen model tier.",build-style-prompt,"{-H: headless mode}|{--headless:from-profile|custom|refine|migrate}",anytime,suno-band-profile-manager:manage-profiles,,false,,style prompt package
Suno Band Manager,suno-lyric-transformer,Transform Lyrics,TL,"Transform poems and text into Suno-ready structured lyrics with metatags and cliche detection.",transform-lyrics,"{-H: headless mode}|{--headless:transform|refine}",anytime,suno-band-profile-manager:manage-profiles,,false,{songbook_folder},structured lyrics
Suno Band Manager,suno-lyric-transformer,Analyze Lyrics,AL,"Analyze raw text for song structure potential without transforming — returns structure analysis, syllable patterns, and character budget.",analyze-lyrics,"{-H: headless mode}",anytime,,,false,,structure analysis
Suno Band Manager,suno-feedback-elicitor,Feedback Loop,FL,"Guided post-generation feedback loop that translates subjective reactions into concrete parameter adjustments.",elicit-feedback,"{-H: headless mode}|{--headless:analyze|adjustments}",anytime,"suno-style-prompt-builder:build-style-prompt,suno-lyric-transformer:transform-lyrics",,false,,adjustment recommendations
1 module skill display-name menu-code description action args phase after before required output-location outputs
2 Suno Band Manager suno-setup Setup Suno Module SU Install or update Suno Band Manager module config and help entries. configure {-H: headless mode}|{inline values: skip prompts with provided values} anytime false {project-root}/_bmad config.yaml and config.user.yaml
3 Suno Band Manager suno-agent-band-manager Create Song CS Create a complete Suno-ready song package with style prompt + lyrics + parameters through guided creative conversation. create-song anytime suno-band-profile-manager:manage-profiles false {songbook_folder} song package
4 Suno Band Manager suno-agent-band-manager Refine Song RS Post-generation refinement: translate feedback into concrete Suno parameter adjustments. refine-song anytime suno-agent-band-manager:create-song false {songbook_folder} refined song package
5 Suno Band Manager suno-agent-band-manager Browse Songbook SB Browse past songs and successful prompts from your creative history. browse-songbook anytime suno-agent-band-manager:create-song false {songbook_folder}
6 Suno Band Manager suno-agent-band-manager Save Memory SM Save current session context to Mac's memory for next time. save-memory anytime false
7 Suno Band Manager suno-band-profile-manager Manage Bands MB Create, edit, list, duplicate, or delete band identity profiles with genre, vocal direction, and writer voice. manage-profiles {-H: headless mode}|{--headless:create|edit|load|delete|duplicate|validate} anytime suno-style-prompt-builder:build-style-prompt false {band_profiles_folder} band profile YAML
8 Suno Band Manager suno-band-profile-manager Analyze Writer Voice WV Extract writing voice patterns from samples and store in a band profile. analyze-writer-voice anytime suno-lyric-transformer:transform-lyrics false {band_profiles_folder} writer voice analysis
9 Suno Band Manager suno-band-profile-manager Profile Health Check HC Assess profile completeness and quality beyond structural validation. health-check anytime suno-band-profile-manager:manage-profiles false health assessment
10 Suno Band Manager suno-style-prompt-builder Build Style Prompt SP Generate model-aware Suno style prompts with creativity modes, wild card variants, and exclusion prompts optimized for your chosen model tier. build-style-prompt {-H: headless mode}|{--headless:from-profile|custom|refine|migrate} anytime suno-band-profile-manager:manage-profiles false style prompt package
11 Suno Band Manager suno-lyric-transformer Transform Lyrics TL Transform poems and text into Suno-ready structured lyrics with metatags and cliche detection. transform-lyrics {-H: headless mode}|{--headless:transform|refine} anytime suno-band-profile-manager:manage-profiles false {songbook_folder} structured lyrics
12 Suno Band Manager suno-lyric-transformer Analyze Lyrics AL Analyze raw text for song structure potential without transforming — returns structure analysis, syllable patterns, and character budget. analyze-lyrics {-H: headless mode} anytime false structure analysis
13 Suno Band Manager suno-feedback-elicitor Feedback Loop FL Guided post-generation feedback loop that translates subjective reactions into concrete parameter adjustments. elicit-feedback {-H: headless mode}|{--headless:analyze|adjustments} anytime suno-style-prompt-builder:build-style-prompt,suno-lyric-transformer:transform-lyrics false adjustment recommendations

View File

@@ -0,0 +1,62 @@
code: suno
name: "Suno Band Manager"
description: "AI-powered music production assistant for creating Suno-ready song packages with style prompts, lyrics, and band identity management"
module_version: 1.7.2
default_selected: false
module_greeting: >
Mac is tuned up and ready to jam! Your Suno Band Manager module is installed.
Run this setup again any time to reconfigure settings.
Get started by talking to Mac (your Band Manager) or jump straight into any skill:
Create a song, manage band profiles, build style prompts, transform lyrics, or refine your Suno output.
**Multi-machine workflow?** This module ships pack/unpack scripts for moving
your songbook, voice files, and WIP between machines without git. Run
`bash scripts/pack-portable.sh` (or `pack-portable.ps1` on Windows) when you
want to sync. Marketplace-install users may need to copy these from the
GitHub repo first — see INSTALLATION.md "Multi-Machine Sync".
# Variables from Core Config inserted:
## user_name
## communication_language
## document_output_language
## output_folder
suno_tier:
prompt: "What Suno plan are you on? This determines which models and features Mac can recommend."
default: "free"
result: "{value}"
single-select:
- value: "free"
label: "Free - v4.5-all model, 50 credits/day"
- value: "pro"
label: "Pro ($8/mo) - All models including v5, 2,500 credits/month"
- value: "premier"
label: "Premier ($24/mo) - All models + Studio, 10,000 credits/month"
default_mode:
prompt: "How do you prefer to work with Mac?"
default: "demo"
result: "{value}"
single-select:
- value: "demo"
label: "Demo - Quick and scrappy, minimal questions"
- value: "studio"
label: "Studio - Detailed, hands-on customization"
- value: "jam"
label: "Jam - Experimental, push boundaries"
band_profiles_folder:
prompt: "Where should band profiles be stored?"
default: "docs/band-profiles"
result: "{project-root}/{value}"
songbook_folder:
prompt: "Where should saved songs and lyrics be stored?"
default: "docs/songbook"
result: "{project-root}/{value}"
# Directories to create during installation
directories:
- "{band_profiles_folder}"
- "{songbook_folder}"

View File

@@ -0,0 +1,287 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = []
# ///
"""Remove legacy module directories from _bmad/ after config migration.
After merge-config.py and merge-help-csv.py have migrated config data and
deleted individual legacy files, this script removes the now-redundant
directory trees. These directories contain skill files that are already
installed at .claude/skills/ (or equivalent) — only the config files at
_bmad/ root need to persist.
When --skills-dir is provided, the script verifies that every skill found
in the legacy directories exists at the installed location before removing
anything. Directories without skills (like _config/) are removed directly.
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 .claude/skills/ — 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 installable skill directories under base_path.
Only considers SKILL.md files at recognized installable positions:
- Direct children: base_path/{name}/SKILL.md (legacy flat layout)
- Skills subfolder: base_path/skills/{name}/SKILL.md (current layout)
SKILL.md files nested deeper (e.g. in tasks/, assets/, or within a
skill's own subdirectories) are not installable skills and are skipped.
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"):
rel = skill_md.parent.relative_to(root)
parts = rel.parts
# Direct child: {name}/SKILL.md
if len(parts) == 1:
skills.append(parts[0])
# Skills subfolder: skills/{name}/SKILL.md
elif len(parts) == 2 and parts[0] == "skills":
skills.append(parts[1])
return sorted(set(skills))
def verify_skills_installed(
bmad_dir: str, dirs_to_check: list, skills_dir: str, verbose: bool = False
) -> list:
"""Verify that skills in legacy directories exist at the installed location.
Scans each directory in dirs_to_check for skill folders (containing SKILL.md),
then checks that a matching directory exists under skills_dir. Directories
that contain no skills (like _config/) are silently skipped.
Returns:
List of verified skill names.
Raises SystemExit(1) if any skills are missing from skills_dir.
"""
all_verified = []
missing = []
for dirname in dirs_to_check:
legacy_path = Path(bmad_dir) / dirname
if not legacy_path.exists():
continue
skill_names = find_skill_dirs(str(legacy_path))
if not skill_names:
if verbose:
print(
f"No skills found in {dirname}/ — skipping verification",
file=sys.stderr,
)
continue
for skill_name in skill_names:
installed_path = Path(skills_dir) / skill_name
if installed_path.is_dir():
all_verified.append(skill_name)
if verbose:
print(
f"Verified: {skill_name} exists at {installed_path}",
file=sys.stderr,
)
else:
missing.append(skill_name)
if verbose:
print(
f"MISSING: {skill_name} not found at {installed_path}",
file=sys.stderr,
)
if missing:
error_result = {
"status": "error",
"error": "Skills not found at installed location",
"missing_skills": missing,
"skills_dir": str(Path(skills_dir).resolve()),
}
print(json.dumps(error_result, indent=2))
sys.exit(1)
return sorted(set(all_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
# Preserve config.yaml if present (bmad-init needs per-module configs)
config_path = target / "config.yaml"
config_backup = None
if config_path.exists():
config_backup = config_path.read_bytes()
if verbose:
print(f"Preserving config.yaml in {dirname}/", file=sys.stderr)
file_count = count_files(target)
if config_backup:
file_count -= 1 # Don't count the preserved file
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)
# Restore preserved config.yaml
if config_backup:
target.mkdir(parents=True, exist_ok=True)
config_path.write_bytes(config_backup)
if verbose:
print(f"Restored config.yaml in {dirname}/", file=sys.stderr)
removed.append(dirname)
total_files += file_count
return removed, not_found, total_files
def main():
args = parse_args()
bmad_dir = args.bmad_dir
module_code = args.module_code
# Build the list of directories to remove
dirs_to_remove = [module_code, "core"] + args.also_remove
# Deduplicate while preserving order
seen = set()
unique_dirs = []
for d in dirs_to_remove:
if d not in seen:
seen.add(d)
unique_dirs.append(d)
dirs_to_remove = unique_dirs
if args.verbose:
print(f"Directories to remove: {dirs_to_remove}", file=sys.stderr)
# Safety check: verify skills are installed before removing
verified_skills = None
if args.skills_dir:
if args.verbose:
print(
f"Verifying skills installed at {args.skills_dir}",
file=sys.stderr,
)
verified_skills = verify_skills_installed(
bmad_dir, dirs_to_remove, args.skills_dir, args.verbose
)
# Remove directories
removed, not_found, total_files = cleanup_directories(
bmad_dir, dirs_to_remove, args.verbose
)
# Build result
result = {
"status": "success",
"bmad_dir": str(Path(bmad_dir).resolve()),
"directories_removed": removed,
"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()

View File

@@ -0,0 +1,132 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = []
# ///
"""Configure pipeline guard hook for Claude Code and standing order for all platforms.
Merges the pipeline guard Stop hook into .claude/settings.local.json
and/or appends the standing order to AGENTS.md. Idempotent — skips
if already configured.
Usage:
python3 scripts/configure-guard.py --settings-path PATH --guard-script-path PATH
python3 scripts/configure-guard.py --agents-md-path PATH
python3 scripts/configure-guard.py --settings-path PATH --agents-md-path PATH
Options:
--settings-path Path to .claude/settings.local.json
--guard-script-path Relative path from project root to pipeline-guard.py
--agents-md-path Path to AGENTS.md (or CLAUDE.md / GEMINI.md)
-o, --output Write JSON output to file instead of stdout
"""
import argparse
import json
import sys
from pathlib import Path
STANDING_ORDER = """
## Suno Pipeline Rule (MANDATORY)
When the suno-agent-band-manager skill is active, NEVER hand-build a Suno package. ALWAYS invoke suno-style-prompt-builder and suno-lyric-transformer via the Skill tool before presenting any style prompt + lyrics + settings package. This rule exists because the skill reference files contain critical guardrails (artist name detection, production descriptor checks, character budget validation, section tag validation) that cannot be replicated from conversation memory.
""".strip()
STANDING_ORDER_MARKER = "## Suno Pipeline Rule"
def configure_claude_hook(settings_path: Path, guard_script_path: str) -> dict:
"""Merge pipeline guard Stop hook into Claude Code settings."""
result = {"target": "claude_hook", "path": str(settings_path)}
# Load existing settings
if settings_path.is_file():
try:
existing = json.loads(settings_path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return {**result, "status": "error", "message": "Malformed JSON in settings file. Fix manually or delete to recreate."}
else:
existing = {}
# Ensure hooks.Stop structure exists
hooks = existing.setdefault("hooks", {})
stop_hooks = hooks.setdefault("Stop", [])
# Check if already configured
for entry in stop_hooks:
for hook in entry.get("hooks", []):
if "pipeline-guard" in hook.get("command", ""):
return {**result, "status": "already_configured"}
# Build the hook command
command = f'python3 "$CLAUDE_PROJECT_DIR"/{guard_script_path}'
# Append new entry
stop_hooks.append({
"hooks": [{
"type": "command",
"command": command,
"timeout": 10,
}]
})
# Write back
settings_path.parent.mkdir(parents=True, exist_ok=True)
settings_path.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8")
return {**result, "status": "configured"}
def configure_standing_order(md_path: Path) -> dict:
"""Append standing order to a markdown instruction file."""
result = {"target": "standing_order", "path": str(md_path)}
# Check if already present
if md_path.is_file():
content = md_path.read_text(encoding="utf-8")
if STANDING_ORDER_MARKER in content:
return {**result, "status": "already_configured"}
# Append with separator
if content and not content.endswith("\n\n"):
content = content.rstrip("\n") + "\n\n"
content += STANDING_ORDER + "\n"
else:
content = STANDING_ORDER + "\n"
md_path.write_text(content, encoding="utf-8")
return {**result, "status": "configured"}
def main():
parser = argparse.ArgumentParser(description="Configure pipeline guard")
parser.add_argument("--settings-path", help="Path to .claude/settings.local.json")
parser.add_argument("--guard-script-path", help="Relative path to pipeline-guard.py from project root")
parser.add_argument("--agents-md-path", help="Path to AGENTS.md (or CLAUDE.md / GEMINI.md)")
parser.add_argument("-o", "--output", help="Output file path")
args = parser.parse_args()
results = []
if args.settings_path and args.guard_script_path:
results.append(configure_claude_hook(
Path(args.settings_path),
args.guard_script_path,
))
if args.agents_md_path:
results.append(configure_standing_order(Path(args.agents_md_path)))
if not results:
results.append({"status": "error", "message": "No configuration targets specified. Use --settings-path and/or --agents-md-path."})
output = json.dumps({"results": results}, indent=2)
if args.output:
Path(args.output).write_text(output, encoding="utf-8")
print(f"Results written to {args.output}", file=sys.stderr)
else:
print(output)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,457 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = ["pyyaml>=6.0"]
# ///
"""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). After a
successful merge, the legacy config.yaml files are deleted. Only the current
module and core directories are touched — other module directories are left alone.
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, then legacy files are deleted.",
)
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:
"""Delete legacy config.yaml files for this module and core only.
Returns list of deleted file paths.
"""
deleted = []
for subdir in (module_code, "core"):
legacy_path = Path(legacy_dir) / subdir / "config.yaml"
if legacy_path.exists():
if verbose:
print(f"Deleting legacy config: {legacy_path}", file=sys.stderr)
legacy_path.unlink()
deleted.append(str(legacy_path))
return deleted
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 write_init_compatible_configs(config, user_config, module_code, bmad_dir, verbose=False):
"""Write per-module config files in the format bmad-init expects.
bmad-init reads:
- _bmad/core/config.yaml (core settings as flat YAML)
- _bmad/{module}/config.yaml (core + module settings as flat YAML)
This bridges the setup skill's shared config format with bmad-init's
per-module config format used at runtime by all skills.
"""
_META_KEYS = frozenset({"name", "description", "version", "default_selected"})
written = []
# Assemble core values from flat config root + user config
core_values = {}
for key in _CORE_KEYS:
if key in config:
core_values[key] = config[key]
for key in _CORE_USER_KEYS:
if key in user_config:
core_values[key] = user_config[key]
# Write _bmad/core/config.yaml
core_path = str(Path(bmad_dir) / "core" / "config.yaml")
write_config(core_values, core_path, verbose)
written.append(core_path)
# Assemble module values: core + module-specific (flat, no metadata)
module_section = config.get(module_code, {})
module_values = dict(core_values)
for key, value in module_section.items():
if key not in _META_KEYS:
module_values[key] = value
# Write _bmad/{module}/config.yaml
module_path = str(Path(bmad_dir) / module_code / "config.yaml")
write_config(module_values, module_path, verbose)
written.append(module_path)
return written
def main():
args = parse_args()
# 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
)
# Write init-compatible per-module configs for bmad-init runtime loading
bmad_dir = str(Path(args.config_path).parent)
init_configs = write_init_compatible_configs(
updated_config, updated_user_config, module_yaml["code"], bmad_dir, 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,
"init_configs_written": init_configs,
}
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,218 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# 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: when --legacy-dir and --module-code are provided, deletes old
per-module module-help.csv files from {legacy-dir}/{module-code}/ and
{legacy-dir}/core/. Only the current module and core are touched.
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:
"""Delete legacy per-module module-help.csv files for this module and core only.
Returns list of deleted file paths.
"""
deleted = []
for subdir in (module_code, "core"):
legacy_path = Path(legacy_dir) / subdir / "module-help.csv"
if legacy_path.exists():
if verbose:
print(f"Deleting legacy CSV: {legacy_path}", file=sys.stderr)
legacy_path.unlink()
deleted.append(str(legacy_path))
return deleted
def main():
args = parse_args()
# 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()