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,160 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = ["pyyaml>=6.0"]
# ///
"""Compare two band profile YAML files and return structured differences.
Takes an original and modified profile, compares field-by-field,
and returns a structured JSON diff showing changed, added, and
removed fields. Handles nested structures (vocal, sliders, etc.).
"""
import argparse
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
import yaml
def flatten_dict(d: dict, prefix: str = "") -> dict:
"""Flatten a nested dict into dot-notation keys."""
items = {}
for k, v in d.items():
key = f"{prefix}.{k}" if prefix else k
if isinstance(v, dict):
items.update(flatten_dict(v, key))
else:
items[key] = v
return items
def diff_profiles(original_path: Path, modified_path: Path) -> dict:
"""Compare two profile YAML files and return structured diff."""
script_name = "diff-profiles"
errors = []
for label, path in [("original", original_path), ("modified", modified_path)]:
if not path.exists():
errors.append(f"{label} file does not exist: {path}")
if errors:
return {
"script": script_name,
"version": "1.0.0",
"timestamp": datetime.now(timezone.utc).isoformat(),
"status": "fail",
"errors": errors,
}
try:
with open(original_path) as f:
original = yaml.safe_load(f) or {}
with open(modified_path) as f:
modified = yaml.safe_load(f) or {}
except yaml.YAMLError as e:
return {
"script": script_name,
"version": "1.0.0",
"timestamp": datetime.now(timezone.utc).isoformat(),
"status": "fail",
"errors": [f"YAML parse error: {e}"],
}
if not isinstance(original, dict) or not isinstance(modified, dict):
return {
"script": script_name,
"version": "1.0.0",
"timestamp": datetime.now(timezone.utc).isoformat(),
"status": "fail",
"errors": ["Both files must be YAML mappings"],
}
flat_orig = flatten_dict(original)
flat_mod = flatten_dict(modified)
all_keys = set(flat_orig.keys()) | set(flat_mod.keys())
changed = []
added = []
removed = []
for key in sorted(all_keys):
in_orig = key in flat_orig
in_mod = key in flat_mod
if in_orig and in_mod:
if flat_orig[key] != flat_mod[key]:
changed.append({
"field": key,
"old": flat_orig[key],
"new": flat_mod[key],
})
elif in_mod and not in_orig:
added.append({
"field": key,
"value": flat_mod[key],
})
elif in_orig and not in_mod:
removed.append({
"field": key,
"value": flat_orig[key],
})
has_changes = bool(changed or added or removed)
return {
"script": script_name,
"version": "1.0.0",
"original": str(original_path),
"modified": str(modified_path),
"timestamp": datetime.now(timezone.utc).isoformat(),
"status": "pass",
"has_changes": has_changes,
"changed": changed,
"added": added,
"removed": removed,
"summary": {
"total_changes": len(changed) + len(added) + len(removed),
"fields_changed": len(changed),
"fields_added": len(added),
"fields_removed": len(removed),
},
}
def main():
parser = argparse.ArgumentParser(
description="Compare two band profile YAML files and return a structured diff.",
epilog="Exit codes: 0=success, 1=fail"
)
parser.add_argument("original", help="Path to the original profile YAML file")
parser.add_argument("modified", help="Path to the modified profile YAML file")
parser.add_argument("-o", "--output", help="Output file (defaults to stdout)")
parser.add_argument("--verbose", action="store_true", help="Print diagnostics to stderr")
args = parser.parse_args()
original_path = Path(args.original)
modified_path = Path(args.modified)
if args.verbose:
print(f"Comparing: {original_path} -> {modified_path}", file=sys.stderr)
result = diff_profiles(original_path, modified_path)
output = json.dumps(result, indent=2)
if args.output:
Path(args.output).write_text(output)
if args.verbose:
print(f"Results written to {args.output}", file=sys.stderr)
else:
print(output)
sys.exit(0 if result["status"] == "pass" else 1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,167 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = ["pyyaml>=6.0"]
# ///
"""List all band profiles in docs/band-profiles/.
Scans the directory for YAML files, extracts key fields from each,
and returns a structured JSON summary. Supports --check for single
profile existence verification.
"""
import argparse
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
import yaml
def check_profile(profiles_dir: Path, profile_name: str) -> dict:
"""Check if a specific profile exists and return its metadata."""
script_name = "list-profiles"
# Try exact filename first, then derive from name
candidates = [
profiles_dir / profile_name,
profiles_dir / f"{profile_name}.yaml",
profiles_dir / f"{profile_name}.yml",
]
for candidate in candidates:
if candidate.exists() and candidate.is_file():
stat = candidate.stat()
try:
with open(candidate) as f:
data = yaml.safe_load(f)
name = data.get("name", candidate.stem) if isinstance(data, dict) else candidate.stem
except (yaml.YAMLError, OSError):
name = candidate.stem
return {
"script": script_name,
"version": "2.0.0",
"timestamp": datetime.now(timezone.utc).isoformat(),
"status": "pass",
"exists": True,
"file": candidate.name,
"path": str(candidate),
"name": name,
"size_bytes": stat.st_size,
"last_modified": datetime.fromtimestamp(
stat.st_mtime, tz=timezone.utc
).isoformat(),
}
return {
"script": script_name,
"version": "2.0.0",
"timestamp": datetime.now(timezone.utc).isoformat(),
"status": "pass",
"exists": False,
"query": profile_name,
"profiles_dir": str(profiles_dir),
}
def list_profiles(profiles_dir: Path) -> dict:
"""Scan profiles directory and return structured summary."""
script_name = "list-profiles"
if not profiles_dir.exists():
return {
"script": script_name,
"version": "2.0.0",
"profiles_dir": str(profiles_dir),
"timestamp": datetime.now(timezone.utc).isoformat(),
"status": "pass",
"profiles": [],
"count": 0,
"message": "No profiles directory found. No band profiles have been created yet."
}
yaml_files = sorted(profiles_dir.glob("*.yaml")) + sorted(profiles_dir.glob("*.yml"))
profiles = []
for yf in yaml_files:
try:
with open(yf) as f:
data = yaml.safe_load(f)
if not isinstance(data, dict):
continue
profiles.append({
"file": yf.name,
"name": data.get("name", yf.stem),
"genre": data.get("genre", "unknown"),
"mood": data.get("mood", ""),
"model_preference": data.get("model_preference", "unknown"),
"tier": data.get("tier", "unknown"),
"instrumental": data.get("instrumental", False),
"language": data.get("language", "English"),
"creativity_default": data.get("creativity_default", "balanced"),
"has_writer_voice": bool(data.get("writer_voice", {}).get("vocabulary")),
"has_generation_history": bool(data.get("generation_history")),
"version": data.get("version", 1)
})
except (yaml.YAMLError, OSError) as e:
print(f"Warning: Could not read {yf}: {e}", file=sys.stderr)
continue
return {
"script": script_name,
"version": "2.0.0",
"profiles_dir": str(profiles_dir),
"timestamp": datetime.now(timezone.utc).isoformat(),
"status": "pass",
"profiles": profiles,
"count": len(profiles)
}
def main():
parser = argparse.ArgumentParser(
description="List all band profiles in a directory.",
epilog="Exit codes: 0=success, 2=error"
)
parser.add_argument(
"profiles_dir",
nargs="?",
default="docs/band-profiles",
help="Path to band profiles directory (default: docs/band-profiles)"
)
parser.add_argument("-o", "--output", help="Output file (defaults to stdout)")
parser.add_argument("--verbose", action="store_true", help="Print diagnostics to stderr")
parser.add_argument(
"--check",
metavar="PROFILE_NAME",
help="Check if a specific profile exists and return its metadata"
)
args = parser.parse_args()
profiles_dir = Path(args.profiles_dir)
if args.verbose:
print(f"Scanning: {profiles_dir}", file=sys.stderr)
if args.check:
result = check_profile(profiles_dir, args.check)
else:
result = list_profiles(profiles_dir)
output = json.dumps(result, indent=2)
if args.output:
Path(args.output).write_text(output)
if args.verbose:
print(f"Results written to {args.output}", file=sys.stderr)
else:
print(output)
sys.exit(0)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,214 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = ["pyyaml>=6.0"]
# ///
"""Scaffold a per-band playlist YAML.
Each band in the project owns exactly one canonical
`docs/{band-slug}-playlist.yaml` that lists the tracks in their playlist
order with a name → audio-file mapping. This file is the authoritative
input to `playlist-sequencing-data.py` and the single source of truth for
sequencing decisions.
This script bootstraps that YAML for a band that doesn't yet have one. It
runs in two modes:
--empty (default)
Write a template with no tracks listed. The user fills in the order.
--from-songbook
Scan `docs/songbook/{band-slug}/` for published songbook entries and
pre-populate the tracks list with their titles. Audio file fields
are left as TODO comments — the user must fill in actual filenames
from `docs/audio/` because songbook frontmatter does not reliably
track the audio filename.
Usage:
scaffold-playlist.py <band-slug> [--from-songbook] [--project-root PATH]
Exit codes:
0 = playlist YAML written (or already existed and --force not passed)
1 = error (band-slug invalid, project root missing, etc.)
"""
import argparse
import json
import os
import re
import sys
from pathlib import Path
def _band_name_from_slug(slug: str) -> str:
"""Convert kebab-case slug to a Title-Cased album name as a default.
Users typically edit this after scaffolding."""
parts = slug.replace("_", "-").split("-")
return " ".join(p.capitalize() for p in parts if p)
def _extract_title_from_songbook(md_path: Path) -> str | None:
"""Read a songbook .md file's frontmatter and return its `title` field.
Returns None if the file lacks a frontmatter title."""
try:
with open(md_path, "r") as f:
content = f.read()
except OSError:
return None
if not content.startswith("---"):
return None
end = content.find("\n---", 3)
if end < 0:
return None
fm = content[3:end]
for line in fm.splitlines():
m = re.match(r'^\s*title\s*:\s*"?(.*?)"?\s*$', line)
if m:
return m.group(1).strip()
return None
def _is_published(md_path: Path) -> bool:
"""Heuristic: check frontmatter for `status: published` or similar."""
try:
with open(md_path, "r") as f:
content = f.read()
except OSError:
return False
if not content.startswith("---"):
return False
end = content.find("\n---", 3)
if end < 0:
return False
fm = content[3:end].lower()
return "status: published" in fm or "status: \"published\"" in fm
def discover_songbook_tracks(project_root: Path, band_slug: str) -> list[dict]:
"""Find published songbook entries for the band and return their titles."""
band_dir = project_root / "docs" / "songbook" / band_slug
if not band_dir.is_dir():
return []
tracks = []
for md_path in sorted(band_dir.glob("*.md")):
if not _is_published(md_path):
continue
title = _extract_title_from_songbook(md_path)
if title:
tracks.append({"name": title, "songbook_path": str(md_path.relative_to(project_root))})
return tracks
def render_playlist_yaml(album_name: str, tracks: list[dict], from_songbook: bool) -> str:
"""Render the playlist YAML content as a string."""
lines = []
lines.append(f"# Playlist order for {album_name} — authoritative source.")
lines.append("# This file is the SINGLE source of truth for the band's track sequence.")
lines.append("# Do NOT duplicate this list in other files (band profile YAML, ordering doc,")
lines.append("# voice context). Those files derive from or reference this YAML.")
lines.append("#")
lines.append("# When a song is published, add it to this file in the same write batch as")
lines.append("# the songbook entry. When the order changes, update this file first; the")
lines.append("# sequencing script's per-album companion .md is auto-refreshed from this.")
lines.append(f'album: "{album_name}"')
lines.append("tracks:")
if not tracks:
lines.append(" # Add tracks below as they are published. Each track needs:")
lines.append(' # - name: "<song title as it appears in the songbook>"')
lines.append(' # file: "<exact filename in docs/audio/, e.g. My Song.mp3>"')
lines.append(" # Order in this list = playlist order.")
else:
for t in tracks:
lines.append(f' - name: "{t["name"]}"')
if from_songbook:
# We discovered the song from songbook but don't know the audio filename.
# User must fill this in.
lines.append(" file: \"\" # TODO: set to the actual filename in docs/audio/")
if t.get("songbook_path"):
lines.append(f" # songbook: {t['songbook_path']}")
else:
lines.append(' file: ""')
return "\n".join(lines) + "\n"
def main():
parser = argparse.ArgumentParser(
description="Scaffold a per-band playlist YAML at docs/{band-slug}-playlist.yaml.",
)
parser.add_argument(
"band_slug",
help="The band's filename slug (kebab-case). Matches the band profile filename: docs/band-profiles/{band-slug}.yaml.",
)
parser.add_argument(
"--from-songbook",
action="store_true",
help="Pre-populate tracks from existing songbook entries at docs/songbook/{band-slug}/.",
)
parser.add_argument(
"--project-root",
default=".",
help="Project root (default: current directory).",
)
parser.add_argument(
"--album-name",
help="Album/band name to use in the YAML (default: derived from slug).",
)
parser.add_argument(
"--force",
action="store_true",
help="Overwrite existing playlist YAML if present.",
)
args = parser.parse_args()
project_root = Path(args.project_root).resolve()
if not project_root.is_dir():
print(json.dumps({"status": "error", "message": f"Project root not found: {project_root}"}))
sys.exit(1)
slug = args.band_slug.strip()
if not re.match(r"^[a-z0-9][a-z0-9_-]*$", slug):
print(json.dumps({
"status": "error",
"message": (
f"Invalid band slug {slug!r}. Use lowercase kebab-case "
f"(letters, digits, hyphens, underscores; must start with letter/digit)."
),
}))
sys.exit(1)
target = project_root / "docs" / f"{slug}-playlist.yaml"
if target.exists() and not args.force:
print(json.dumps({
"status": "exists",
"message": f"Playlist YAML already exists at {target}. Use --force to overwrite.",
"path": str(target.relative_to(project_root)),
}))
sys.exit(0)
album_name = args.album_name or _band_name_from_slug(slug)
tracks: list[dict] = []
if args.from_songbook:
tracks = discover_songbook_tracks(project_root, slug)
body = render_playlist_yaml(album_name, tracks, from_songbook=args.from_songbook)
target.parent.mkdir(parents=True, exist_ok=True)
with open(target, "w") as f:
f.write(body)
print(json.dumps({
"status": "created" if not args.force else "overwritten",
"path": str(target.relative_to(project_root)),
"album": album_name,
"tracks_seeded": len(tracks),
"from_songbook": args.from_songbook,
"note": (
"Audio filenames left as empty strings — fill in from docs/audio/ before "
"running the sequencing script."
) if tracks else (
"Empty template written. Add tracks as you publish them."
),
}))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,133 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = ["pytest>=7.0", "pyyaml>=6.0"]
# ///
"""Tests for diff-profiles.py"""
import sys
from pathlib import Path
import pytest
import yaml
sys.path.insert(0, str(Path(__file__).parent.parent))
from importlib.util import spec_from_file_location, module_from_spec
spec = spec_from_file_location(
"diff_profiles",
Path(__file__).parent.parent / "diff-profiles.py"
)
diff_profiles_mod = module_from_spec(spec)
spec.loader.exec_module(diff_profiles_mod)
diff_profiles = diff_profiles_mod.diff_profiles
PROFILE_A = {
"name": "Test Band",
"genre": "indie rock",
"mood": "melancholic",
"model_preference": "v4.5-all",
"tier": "free",
"style_baseline": "Indie rock with warm guitars",
"vocal": {
"gender": "male",
"tone": "warm",
"delivery": "intimate",
"energy": "restrained",
},
}
def write_yaml(tmp_path, filename, data):
path = tmp_path / filename
with open(path, "w") as f:
yaml.dump(data, f)
return path
def test_identical_profiles(tmp_path):
a = write_yaml(tmp_path, "a.yaml", PROFILE_A)
b = write_yaml(tmp_path, "b.yaml", PROFILE_A)
result = diff_profiles(a, b)
assert result["status"] == "pass"
assert result["has_changes"] is False
assert result["summary"]["total_changes"] == 0
def test_changed_fields(tmp_path):
modified = {**PROFILE_A, "genre": "electronic", "mood": "energetic"}
a = write_yaml(tmp_path, "a.yaml", PROFILE_A)
b = write_yaml(tmp_path, "b.yaml", modified)
result = diff_profiles(a, b)
assert result["has_changes"] is True
assert result["summary"]["fields_changed"] == 2
changed_fields = [c["field"] for c in result["changed"]]
assert "genre" in changed_fields
assert "mood" in changed_fields
def test_added_fields(tmp_path):
modified = {**PROFILE_A, "language": "Spanish", "instrumental": True}
a = write_yaml(tmp_path, "a.yaml", PROFILE_A)
b = write_yaml(tmp_path, "b.yaml", modified)
result = diff_profiles(a, b)
assert result["has_changes"] is True
assert result["summary"]["fields_added"] >= 2
added_fields = [c["field"] for c in result["added"]]
assert "language" in added_fields
assert "instrumental" in added_fields
def test_removed_fields(tmp_path):
modified = {k: v for k, v in PROFILE_A.items() if k != "mood"}
a = write_yaml(tmp_path, "a.yaml", PROFILE_A)
b = write_yaml(tmp_path, "b.yaml", modified)
result = diff_profiles(a, b)
assert result["has_changes"] is True
assert result["summary"]["fields_removed"] >= 1
removed_fields = [c["field"] for c in result["removed"]]
assert "mood" in removed_fields
def test_nested_changes(tmp_path):
modified = {**PROFILE_A, "vocal": {**PROFILE_A["vocal"], "tone": "bright, clear"}}
a = write_yaml(tmp_path, "a.yaml", PROFILE_A)
b = write_yaml(tmp_path, "b.yaml", modified)
result = diff_profiles(a, b)
assert result["has_changes"] is True
changed_fields = [c["field"] for c in result["changed"]]
assert "vocal.tone" in changed_fields
def test_missing_original(tmp_path):
b = write_yaml(tmp_path, "b.yaml", PROFILE_A)
result = diff_profiles(tmp_path / "nope.yaml", b)
assert result["status"] == "fail"
assert "errors" in result
def test_missing_modified(tmp_path):
a = write_yaml(tmp_path, "a.yaml", PROFILE_A)
result = diff_profiles(a, tmp_path / "nope.yaml")
assert result["status"] == "fail"
def test_invalid_yaml(tmp_path):
a = write_yaml(tmp_path, "a.yaml", PROFILE_A)
bad = tmp_path / "bad.yaml"
bad.write_text(": {{invalid yaml")
result = diff_profiles(a, bad)
assert result["status"] == "fail"
def test_mixed_changes(tmp_path):
modified = {**PROFILE_A, "genre": "electronic", "language": "French"}
del modified["mood"]
a = write_yaml(tmp_path, "a.yaml", PROFILE_A)
b = write_yaml(tmp_path, "b.yaml", modified)
result = diff_profiles(a, b)
assert result["has_changes"] is True
assert result["summary"]["fields_changed"] >= 1
assert result["summary"]["fields_added"] >= 1
assert result["summary"]["fields_removed"] >= 1

View File

@@ -0,0 +1,151 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = ["pytest>=7.0", "pyyaml>=6.0"]
# ///
"""Tests for list-profiles.py"""
import sys
from pathlib import Path
import pytest
import yaml
sys.path.insert(0, str(Path(__file__).parent.parent))
from importlib.util import spec_from_file_location, module_from_spec
spec = spec_from_file_location(
"list_profiles",
Path(__file__).parent.parent / "list-profiles.py"
)
list_profiles_mod = module_from_spec(spec)
spec.loader.exec_module(list_profiles_mod)
list_profiles = list_profiles_mod.list_profiles
check_profile = list_profiles_mod.check_profile
SAMPLE_PROFILE = {
"name": "Test Band",
"genre": "indie rock",
"mood": "melancholic",
"model_preference": "v4.5-all",
"tier": "free",
}
def test_nonexistent_directory(tmp_path):
result = list_profiles(tmp_path / "nope")
assert result["status"] == "pass"
assert result["count"] == 0
assert "No profiles directory" in result.get("message", "")
def test_empty_directory(tmp_path):
profiles_dir = tmp_path / "profiles"
profiles_dir.mkdir()
result = list_profiles(profiles_dir)
assert result["status"] == "pass"
assert result["count"] == 0
def test_single_profile(tmp_path):
profiles_dir = tmp_path / "profiles"
profiles_dir.mkdir()
with open(profiles_dir / "test-band.yaml", "w") as f:
yaml.dump(SAMPLE_PROFILE, f)
result = list_profiles(profiles_dir)
assert result["count"] == 1
assert result["profiles"][0]["name"] == "Test Band"
assert result["profiles"][0]["genre"] == "indie rock"
def test_multiple_profiles(tmp_path):
profiles_dir = tmp_path / "profiles"
profiles_dir.mkdir()
for i in range(3):
data = {**SAMPLE_PROFILE, "name": f"Band {i}"}
with open(profiles_dir / f"band-{i}.yaml", "w") as f:
yaml.dump(data, f)
result = list_profiles(profiles_dir)
assert result["count"] == 3
def test_writer_voice_detection(tmp_path):
profiles_dir = tmp_path / "profiles"
profiles_dir.mkdir()
data_with_voice = {
**SAMPLE_PROFILE,
"writer_voice": {"vocabulary": "formal, archaic", "rhythm": "long flowing"}
}
with open(profiles_dir / "voiced.yaml", "w") as f:
yaml.dump(data_with_voice, f)
data_without = {**SAMPLE_PROFILE}
with open(profiles_dir / "plain.yaml", "w") as f:
yaml.dump(data_without, f)
result = list_profiles(profiles_dir)
voiced = next(p for p in result["profiles"] if p["file"] == "voiced.yaml")
plain = next(p for p in result["profiles"] if p["file"] == "plain.yaml")
assert voiced["has_writer_voice"] is True
assert plain["has_writer_voice"] is False
def test_invalid_yaml_skipped(tmp_path):
profiles_dir = tmp_path / "profiles"
profiles_dir.mkdir()
(profiles_dir / "bad.yaml").write_text(": {{invalid")
with open(profiles_dir / "good.yaml", "w") as f:
yaml.dump(SAMPLE_PROFILE, f)
result = list_profiles(profiles_dir)
assert result["count"] == 1
def test_new_fields_in_listing(tmp_path):
profiles_dir = tmp_path / "profiles"
profiles_dir.mkdir()
data = {
**SAMPLE_PROFILE,
"instrumental": True,
"language": "Spanish",
"creativity_default": "experimental",
"generation_history": [{"date": "2026-03-19"}],
}
with open(profiles_dir / "test.yaml", "w") as f:
yaml.dump(data, f)
result = list_profiles(profiles_dir)
p = result["profiles"][0]
assert p["instrumental"] is True
assert p["language"] == "Spanish"
assert p["creativity_default"] == "experimental"
assert p["has_generation_history"] is True
# --- check_profile tests ---
def test_check_profile_exists(tmp_path):
profiles_dir = tmp_path / "profiles"
profiles_dir.mkdir()
with open(profiles_dir / "test-band.yaml", "w") as f:
yaml.dump(SAMPLE_PROFILE, f)
result = check_profile(profiles_dir, "test-band")
assert result["exists"] is True
assert result["name"] == "Test Band"
assert "size_bytes" in result
assert "last_modified" in result
def test_check_profile_exists_with_extension(tmp_path):
profiles_dir = tmp_path / "profiles"
profiles_dir.mkdir()
with open(profiles_dir / "test-band.yaml", "w") as f:
yaml.dump(SAMPLE_PROFILE, f)
result = check_profile(profiles_dir, "test-band.yaml")
assert result["exists"] is True
def test_check_profile_not_exists(tmp_path):
profiles_dir = tmp_path / "profiles"
profiles_dir.mkdir()
result = check_profile(profiles_dir, "nonexistent")
assert result["exists"] is False
assert result["query"] == "nonexistent"

View File

@@ -0,0 +1,129 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = ["pytest>=7.0"]
# ///
"""Tests for tier-features.py"""
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from importlib.util import spec_from_file_location, module_from_spec
spec = spec_from_file_location(
"tier_features",
Path(__file__).parent.parent / "tier-features.py"
)
tier_features_mod = module_from_spec(spec)
spec.loader.exec_module(tier_features_mod)
get_tier_features = tier_features_mod.get_tier_features
def test_free_tier():
result = get_tier_features("free")
assert result["status"] == "pass"
assert result["tier"] == "free"
assert result["sliders_available"] is False
assert result["personas_available"] is False
assert result["audio_influence_available"] is False
assert result["studio_available"] is False
assert "v4.5-all" in result["models"]
assert len(result["models"]) == 1
def test_pro_tier():
result = get_tier_features("pro")
assert result["status"] == "pass"
assert result["sliders_available"] is True
assert result["personas_available"] is True
assert result["audio_influence_available"] is True
assert result["studio_available"] is False
assert "v5 Pro" in result["models"]
assert len(result["unavailable"]) >= 1 # Studio and related
def test_premier_tier():
result = get_tier_features("premier")
assert result["status"] == "pass"
assert result["sliders_available"] is True
assert result["studio_available"] is True
assert len(result["unavailable"]) == 0 # Everything available
def test_invalid_tier():
result = get_tier_features("ultimate")
assert result["status"] == "fail"
assert "error" in result
def test_case_insensitive():
result = get_tier_features("PRO")
assert result["status"] == "pass"
assert result["tier"] == "pro"
def test_free_has_unavailable_features():
result = get_tier_features("free")
assert len(result["unavailable"]) > 5 # Many features gated
def test_all_tiers_have_available():
for tier in ["free", "pro", "premier"]:
result = get_tier_features(tier)
assert len(result["available"]) > 0
def test_all_tiers_have_pricing():
for tier in ["free", "pro", "premier"]:
result = get_tier_features(tier)
assert "pricing" in result
assert "monthly" in result["pricing"]
assert "annual_monthly" in result["pricing"]
def test_all_tiers_have_song_length():
for tier in ["free", "pro", "premier"]:
result = get_tier_features(tier)
assert "song_length_max" in result
def test_all_tiers_have_download_quality():
for tier in ["free", "pro", "premier"]:
result = get_tier_features(tier)
assert "download_quality" in result
def test_all_tiers_have_credit_cost():
for tier in ["free", "pro", "premier"]:
result = get_tier_features(tier)
assert "credit_cost" in result
assert result["credit_cost"]["generation"] == 10
assert result["credit_cost"]["extension"] == 5
def test_free_pricing_is_zero():
result = get_tier_features("free")
assert result["pricing"]["monthly"] == 0
assert result["pricing"]["annual_monthly"] == 0
def test_pro_pricing():
result = get_tier_features("pro")
assert result["pricing"]["monthly"] == 10
assert result["pricing"]["annual_monthly"] == 8
def test_premier_pricing():
result = get_tier_features("premier")
assert result["pricing"]["monthly"] == 30
assert result["pricing"]["annual_monthly"] == 24
def test_legacy_models_flagged():
for tier in ["pro", "premier"]:
result = get_tier_features(tier)
assert "legacy_models" in result
assert "v4 Pro" in result["legacy_models"]

View File

@@ -0,0 +1,314 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = ["pytest>=7.0", "pyyaml>=6.0"]
# ///
"""Tests for validate-profile.py"""
import json
import sys
import tempfile
from pathlib import Path
import pytest
import yaml
# Add parent directory to path for import
sys.path.insert(0, str(Path(__file__).parent.parent))
from importlib.util import spec_from_file_location, module_from_spec
# Import the module
spec = spec_from_file_location(
"validate_profile",
Path(__file__).parent.parent / "validate-profile.py"
)
validate_profile_mod = module_from_spec(spec)
spec.loader.exec_module(validate_profile_mod)
validate_profile = validate_profile_mod.validate_profile
derive_filename = validate_profile_mod.derive_filename
def write_profile(tmp_path, data):
"""Helper to write a YAML profile and return its path."""
profile_path = tmp_path / "test-band.yaml"
with open(profile_path, "w") as f:
yaml.dump(data, f)
return profile_path
VALID_PROFILE = {
"name": "Test Band",
"genre": "indie rock",
"mood": "melancholic",
"model_preference": "v4.5-all",
"tier": "free",
"style_baseline": "Indie rock with warm guitars and atmospheric pads",
"vocal": {
"gender": "male",
"tone": "warm, breathy",
"delivery": "intimate",
"energy": "restrained",
},
}
VALID_INSTRUMENTAL_PROFILE = {
"name": "Ambient Waves",
"genre": "ambient electronic",
"mood": "contemplative, spacious",
"model_preference": "v4.5-all",
"tier": "free",
"style_baseline": "Ambient electronic with lush pads and field recordings",
"instrumental": True,
}
def test_valid_profile(tmp_path):
path = write_profile(tmp_path, VALID_PROFILE)
result = validate_profile(path)
assert result["status"] == "pass"
assert result["summary"]["total"] == 0
def test_missing_file(tmp_path):
path = tmp_path / "nonexistent.yaml"
result = validate_profile(path)
assert result["status"] == "fail"
assert result["summary"]["critical"] == 1
def test_invalid_yaml(tmp_path):
path = tmp_path / "bad.yaml"
path.write_text(": invalid: yaml: {{{{")
result = validate_profile(path)
assert result["status"] == "fail"
assert result["summary"]["critical"] >= 1
def test_missing_required_fields(tmp_path):
path = write_profile(tmp_path, {"name": "Test"})
result = validate_profile(path)
assert result["status"] == "fail"
assert result["summary"]["critical"] >= 1
def test_invalid_model(tmp_path):
data = {**VALID_PROFILE, "model_preference": "v99 Ultra"}
path = write_profile(tmp_path, data)
result = validate_profile(path)
assert any(f.get("location", {}).get("field") == "model_preference"
for f in result["findings"])
def test_invalid_tier(tmp_path):
data = {**VALID_PROFILE, "tier": "ultimate"}
path = write_profile(tmp_path, data)
result = validate_profile(path)
assert any("tier" in str(f) for f in result["findings"])
def test_style_baseline_too_long(tmp_path):
data = {**VALID_PROFILE, "style_baseline": "x" * 1001}
path = write_profile(tmp_path, data)
result = validate_profile(path)
assert any("style_baseline" in str(f) for f in result["findings"])
def test_style_baseline_v4_pro_200_limit(tmp_path):
data = {**VALID_PROFILE, "model_preference": "v4 Pro", "tier": "pro",
"style_baseline": "x" * 201}
path = write_profile(tmp_path, data)
result = validate_profile(path)
assert any("style_baseline" in str(f) and "200" in str(f)
for f in result["findings"])
def test_free_tier_wrong_model(tmp_path):
data = {**VALID_PROFILE, "tier": "free", "model_preference": "v5 Pro"}
path = write_profile(tmp_path, data)
result = validate_profile(path)
assert any("free" in f.get("issue", "").lower() or "free" in f.get("fix", "").lower()
for f in result["findings"])
def test_free_tier_slider_warning(tmp_path):
data = {**VALID_PROFILE, "sliders": {"weirdness": 80, "style_influence": 30}}
path = write_profile(tmp_path, data)
result = validate_profile(path)
assert any("slider" in f.get("issue", "").lower() for f in result["findings"])
def test_slider_out_of_range(tmp_path):
data = {**VALID_PROFILE, "tier": "pro", "model_preference": "v5 Pro",
"sliders": {"weirdness": 150}}
path = write_profile(tmp_path, data)
result = validate_profile(path)
assert any("out of range" in f.get("issue", "").lower() for f in result["findings"])
def test_audio_influence_slider_validation(tmp_path):
data = {**VALID_PROFILE, "tier": "pro", "model_preference": "v5 Pro",
"sliders": {"audio_influence": 200}}
path = write_profile(tmp_path, data)
result = validate_profile(path)
assert any("audio_influence" in str(f) and "out of range" in f.get("issue", "").lower()
for f in result["findings"])
def test_invalid_vocal_gender(tmp_path):
data = {**VALID_PROFILE}
data["vocal"] = {**VALID_PROFILE["vocal"], "gender": "robot"}
path = write_profile(tmp_path, data)
result = validate_profile(path)
assert any("gender" in str(f) for f in result["findings"])
def test_missing_vocal_fields(tmp_path):
data = {**VALID_PROFILE, "vocal": {"gender": "male"}}
path = write_profile(tmp_path, data)
result = validate_profile(path)
assert result["summary"]["high"] >= 1
def test_too_many_exclusions(tmp_path):
data = {**VALID_PROFILE, "exclusion_defaults": [f"no thing {i}" for i in range(7)]}
path = write_profile(tmp_path, data)
result = validate_profile(path)
assert any("exclusion" in f.get("issue", "").lower() for f in result["findings"])
def test_pro_tier_valid_with_sliders(tmp_path):
data = {
**VALID_PROFILE,
"tier": "pro",
"model_preference": "v5 Pro",
"sliders": {"weirdness": 70, "style_influence": 40},
}
path = write_profile(tmp_path, data)
result = validate_profile(path)
assert result["status"] == "pass"
# --- Instrumental profile tests ---
def test_instrumental_profile_valid_without_vocal(tmp_path):
path = write_profile(tmp_path, VALID_INSTRUMENTAL_PROFILE)
result = validate_profile(path)
assert result["status"] == "pass"
assert result["summary"]["total"] == 0
def test_instrumental_profile_with_optional_vocal(tmp_path):
data = {**VALID_INSTRUMENTAL_PROFILE, "vocal": {"gender": "any"}}
path = write_profile(tmp_path, data)
result = validate_profile(path)
assert result["status"] == "pass"
def test_non_instrumental_requires_vocal(tmp_path):
data = {**VALID_PROFILE}
del data["vocal"]
path = write_profile(tmp_path, data)
result = validate_profile(path)
assert result["status"] == "fail"
assert result["summary"]["high"] >= 1
# --- New field tests ---
def test_valid_creativity_default(tmp_path):
for mode in ["conservative", "balanced", "experimental"]:
data = {**VALID_PROFILE, "creativity_default": mode}
path = write_profile(tmp_path, data)
result = validate_profile(path)
assert not any(f.get("location", {}).get("field") == "creativity_default"
for f in result["findings"]), f"Failed for {mode}"
def test_invalid_creativity_default(tmp_path):
data = {**VALID_PROFILE, "creativity_default": "wild"}
path = write_profile(tmp_path, data)
result = validate_profile(path)
assert any("creativity_default" in str(f) for f in result["findings"])
def test_valid_language(tmp_path):
data = {**VALID_PROFILE, "language": "Spanish"}
path = write_profile(tmp_path, data)
result = validate_profile(path)
assert not any(f.get("location", {}).get("field") == "language"
for f in result["findings"])
def test_empty_language(tmp_path):
data = {**VALID_PROFILE, "language": ""}
path = write_profile(tmp_path, data)
result = validate_profile(path)
assert any("language" in str(f) for f in result["findings"])
def test_generation_history_valid(tmp_path):
data = {**VALID_PROFILE, "generation_history": [
{"date": "2026-03-19", "style_prompt": "test", "model": "v4.5-all"}
]}
path = write_profile(tmp_path, data)
result = validate_profile(path)
assert not any(f.get("location", {}).get("field") == "generation_history"
for f in result["findings"])
def test_generation_history_too_many(tmp_path):
data = {**VALID_PROFILE, "generation_history": [
{"date": f"2026-03-{i:02d}"} for i in range(1, 15)
]}
path = write_profile(tmp_path, data)
result = validate_profile(path)
assert any("generation_history" in str(f) for f in result["findings"])
def test_generation_history_not_list(tmp_path):
data = {**VALID_PROFILE, "generation_history": "not a list"}
path = write_profile(tmp_path, data)
result = validate_profile(path)
assert any("generation_history" in str(f) for f in result["findings"])
def test_studio_preferences_non_premier_warning(tmp_path):
data = {**VALID_PROFILE, "tier": "pro", "model_preference": "v5 Pro",
"studio_preferences": {"bpm": 120, "key": "C minor"}}
path = write_profile(tmp_path, data)
result = validate_profile(path)
assert any("studio" in f.get("issue", "").lower() for f in result["findings"])
def test_studio_preferences_premier_valid(tmp_path):
data = {**VALID_PROFILE, "tier": "premier", "model_preference": "v5 Pro",
"studio_preferences": {"bpm": 120, "key": "C minor", "time_signature": "4/4"}}
path = write_profile(tmp_path, data)
result = validate_profile(path)
assert not any("studio" in f.get("issue", "").lower() for f in result["findings"])
def test_studio_preferences_invalid_bpm(tmp_path):
data = {**VALID_PROFILE, "tier": "premier", "model_preference": "v5 Pro",
"studio_preferences": {"bpm": "fast"}}
path = write_profile(tmp_path, data)
result = validate_profile(path)
assert any("bpm" in str(f).lower() for f in result["findings"])
# --- derive_filename tests ---
def test_derive_filename_basic():
assert derive_filename("Test Band") == "test-band.yaml"
def test_derive_filename_special_chars():
assert derive_filename("The Band's Name!") == "the-bands-name.yaml"
def test_derive_filename_multiple_spaces():
assert derive_filename(" My Cool Band ") == "my-cool-band.yaml"
def test_derive_filename_already_kebab():
assert derive_filename("already-kebab") == "already-kebab.yaml"

View File

@@ -0,0 +1,223 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = []
# ///
"""Return Suno feature availability for a given subscription tier.
Maps each tier (free, pro, premier) to its available and unavailable features,
helping the agent and user understand what profile options are valid.
"""
import argparse
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "_shared"))
from suno_constants import VALID_TIERS
TIER_FEATURES = {
"free": {
"available": [
"v4.5-all model",
"50 credits/day (~10 songs)",
"Vocal Gender selection",
"Manual/Auto Lyrics mode",
"Song Title",
"1 min audio upload",
"Song length determined by model — v4.5-all supports up to ~8 min",
"128kbps MP3 download",
],
"unavailable": [
"v5 Pro and other paid models",
"Commercial use",
"Personas (consistent voice reuse)",
"Weirdness slider (0-100)",
"Style Influence slider (0-100)",
"Audio Influence slider (0-100)",
"Add Vocals / Add Instrumental",
"Stems separation",
"Advanced editing",
"Studio features",
"Studio 1.2 (Warp Markers, Remove FX, Alternates, Time Signature)",
"MIDI export",
"Priority queue",
"Add-on credits",
"320kbps MP3 / WAV download",
],
"models": ["v4.5-all"],
"legacy_models": [],
"sliders_available": False,
"personas_available": False,
"voices_available": False,
"custom_models_available": False,
"audio_influence_available": False,
"legacy_editor_available": False,
"studio_available": False,
"song_length_max": "Determined by model — v4.5-all supports up to ~8 min",
"download_quality": "128kbps MP3",
"credit_cost": {"generation": 10, "extension": 5},
"pricing": {"monthly": 0, "annual_monthly": 0},
},
"pro": {
"available": [
"All models including v5 Pro and v5.5 Pro",
"2,500 credits/month (~500 songs)",
"Commercial use (new songs)",
"Personas (v4.5/v5), Voices (v5.5)",
"Weirdness slider (0-100)",
"Style Influence slider (0-100)",
"Audio Influence slider (0-100, with audio upload)",
"Custom Models (up to 3, v5.5)",
"Add Vocals / Add Instrumental (beta)",
"Covers (beta)",
"Remaster (Subtle/Normal/High)",
"Up to 12 stems",
"8 min audio upload",
"Legacy Editor (Replace, Extend, Crop, Fade, Rearrange)",
"Priority queue (10 concurrent)",
"Add-on credits",
"Song length determined by model — v4.5/v5 support up to ~8 min",
"320kbps MP3 + WAV download",
],
"unavailable": [
"Suno Studio (full GAW)",
"Warp Markers",
"Remove FX",
"Alternates / Take Lanes",
"EQ (6-band per track)",
"Time Signature control",
"Context Window",
"Recording (microphone)",
"Loop Recording",
"Sounds Mode (text-to-sound)",
"Stem Cover",
"Heal Edits",
"MIDI export (10 credits/stem)",
"MILO-1080 Sequencer",
],
"models": ["v4.5-all", "v4 Pro", "v4.5 Pro", "v4.5+ Pro", "v5 Pro", "v5.5 Pro"],
"legacy_models": ["v4 Pro"],
"sliders_available": True,
"personas_available": True,
"voices_available": True,
"custom_models_available": True,
"audio_influence_available": True,
"studio_available": False,
"legacy_editor_available": True,
"song_length_max": "Determined by model — v4.5/v5/v5.5 support up to ~8 min",
"download_quality": "320kbps MP3 + WAV",
"credit_cost": {"generation": 10, "extension": 5},
"pricing": {"monthly": 10, "annual_monthly": 8},
},
"premier": {
"available": [
"All models including v5 Pro, v5.5 Pro + Studio",
"10,000 credits/month (~2,000 songs)",
"Commercial use (new songs)",
"Personas (v4.5/v5), Voices (v5.5)",
"Weirdness slider (0-100)",
"Style Influence slider (0-100)",
"Audio Influence slider (0-100, with audio upload)",
"Custom Models (up to 3, v5.5)",
"Add Vocals / Add Instrumental (beta)",
"Covers (beta)",
"Remaster (Subtle/Normal/High)",
"Up to 12 stems",
"8 min audio upload",
"Legacy Editor (Replace, Extend, Crop, Fade, Rearrange)",
"Suno Studio (full GAW)",
"Warp Markers",
"Remove FX",
"Alternates / Take Lanes",
"EQ (6-band per track)",
"Time Signature control (editing only — not sent to generative models)",
"Context Window",
"Recording (microphone)",
"Loop Recording",
"Sounds Mode (text-to-sound, beta)",
"Stem Cover",
"Heal Edits",
"MIDI export (10 credits/stem)",
"MILO-1080 Sequencer",
"Priority queue (10 concurrent)",
"Add-on credits",
"Song length determined by model — v4.5/v5 support up to ~8 min",
"320kbps MP3 + WAV download",
],
"unavailable": [],
"models": ["v4.5-all", "v4 Pro", "v4.5 Pro", "v4.5+ Pro", "v5 Pro", "v5.5 Pro"],
"legacy_models": ["v4 Pro"],
"sliders_available": True,
"personas_available": True,
"voices_available": True,
"custom_models_available": True,
"audio_influence_available": True,
"studio_available": True,
"legacy_editor_available": True,
"song_length_max": "Determined by model — v4.5/v5/v5.5 support up to ~8 min",
"download_quality": "320kbps MP3 + WAV",
"credit_cost": {"generation": 10, "extension": 5},
"pricing": {"monthly": 30, "annual_monthly": 24},
},
}
def get_tier_features(tier: str) -> dict:
"""Return feature availability for the given tier."""
script_name = "tier-features"
tier_lower = tier.lower().strip()
if tier_lower not in VALID_TIERS:
return {
"script": script_name,
"version": "2.0.0",
"timestamp": datetime.now(timezone.utc).isoformat(),
"status": "fail",
"error": f"Unknown tier '{tier}'. Must be one of: {', '.join(sorted(VALID_TIERS))}",
}
features = TIER_FEATURES[tier_lower]
return {
"script": script_name,
"version": "2.0.0",
"timestamp": datetime.now(timezone.utc).isoformat(),
"status": "pass",
"tier": tier_lower,
**features,
}
def main():
parser = argparse.ArgumentParser(
description="Return available/unavailable Suno features for a given subscription tier.",
epilog="Exit codes: 0=success, 1=invalid tier"
)
parser.add_argument("tier", choices=["free", "pro", "premier"], help="Suno subscription tier")
parser.add_argument("-o", "--output", help="Output file (defaults to stdout)")
parser.add_argument("--verbose", action="store_true", help="Print diagnostics to stderr")
args = parser.parse_args()
if args.verbose:
print(f"Getting features for tier: {args.tier}", file=sys.stderr)
result = get_tier_features(args.tier)
output = json.dumps(result, indent=2)
if args.output:
from pathlib import Path
Path(args.output).write_text(output)
if args.verbose:
print(f"Results written to {args.output}", file=sys.stderr)
else:
print(output)
sys.exit(0 if result["status"] == "pass" else 1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,448 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = ["pyyaml>=6.0"]
# ///
"""Validate a band profile YAML file against the expected schema.
Checks required fields, value constraints, tier/model consistency,
instrumental mode, style_baseline length, and new fields (language,
creativity_default, generation_history, studio_preferences).
Returns structured JSON findings.
Also supports --derive-filename to convert a band name to kebab-case filename.
"""
import argparse
import json
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
import yaml
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "_shared"))
from suno_constants import VALID_MODELS, VALID_TIERS, STYLE_PROMPT_LIMITS, STYLE_PROMPT_DEFAULT_MAX, FREE_TIER_MODEL
VALID_GENDERS = {"male", "female", "nonbinary", "any"}
VALID_CREATIVITY = {"conservative", "balanced", "experimental"}
STYLE_BASELINE_MAX = STYLE_PROMPT_DEFAULT_MAX
STYLE_BASELINE_MAX_V4 = STYLE_PROMPT_LIMITS["v4 Pro"]
MAX_GENERATION_HISTORY = 10
def derive_filename(band_name: str) -> str:
"""Convert a band name to kebab-case filename."""
name = band_name.strip().lower()
name = re.sub(r"[^a-z0-9\s-]", "", name)
name = re.sub(r"[\s_]+", "-", name)
name = re.sub(r"-+", "-", name)
name = name.strip("-")
return f"{name}.yaml"
def validate_profile(profile_path: Path) -> dict:
"""Validate a profile YAML file and return structured findings."""
findings = []
script_name = "validate-profile"
if not profile_path.exists():
return {
"script": script_name,
"version": "2.0.0",
"skill_path": str(profile_path),
"timestamp": datetime.now(timezone.utc).isoformat(),
"status": "fail",
"findings": [{
"severity": "critical",
"category": "structure",
"location": {"file": str(profile_path)},
"issue": "Profile file does not exist",
"fix": f"Create the profile at {profile_path}"
}],
"summary": {"total": 1, "critical": 1, "high": 0, "medium": 0, "low": 0}
}
try:
with open(profile_path) as f:
profile = yaml.safe_load(f)
except yaml.YAMLError as e:
return {
"script": script_name,
"version": "2.0.0",
"skill_path": str(profile_path),
"timestamp": datetime.now(timezone.utc).isoformat(),
"status": "fail",
"findings": [{
"severity": "critical",
"category": "structure",
"location": {"file": str(profile_path)},
"issue": f"Invalid YAML: {e}",
"fix": "Fix YAML syntax errors"
}],
"summary": {"total": 1, "critical": 1, "high": 0, "medium": 0, "low": 0}
}
if not isinstance(profile, dict):
return {
"script": script_name,
"version": "2.0.0",
"skill_path": str(profile_path),
"timestamp": datetime.now(timezone.utc).isoformat(),
"status": "fail",
"findings": [{
"severity": "critical",
"category": "structure",
"location": {"file": str(profile_path)},
"issue": "Profile is not a YAML mapping",
"fix": "Profile must be a YAML dictionary/mapping at the top level"
}],
"summary": {"total": 1, "critical": 1, "high": 0, "medium": 0, "low": 0}
}
is_instrumental = profile.get("instrumental", False) is True
# Required top-level string fields
for field in ["name", "genre", "mood", "model_preference", "tier", "style_baseline"]:
val = profile.get(field)
if not val or not isinstance(val, str) or not val.strip():
findings.append({
"severity": "critical",
"category": "structure",
"location": {"file": str(profile_path), "field": field},
"issue": f"Required field '{field}' is missing or empty",
"fix": f"Add a non-empty '{field}' field to the profile"
})
# model_preference validation
model = profile.get("model_preference", "")
if model and model not in VALID_MODELS:
findings.append({
"severity": "high",
"category": "consistency",
"location": {"file": str(profile_path), "field": "model_preference"},
"issue": f"Invalid model_preference '{model}'",
"fix": f"Must be one of: {', '.join(sorted(VALID_MODELS))}"
})
# tier validation
tier = profile.get("tier", "")
if tier and tier not in VALID_TIERS:
findings.append({
"severity": "high",
"category": "consistency",
"location": {"file": str(profile_path), "field": "tier"},
"issue": f"Invalid tier '{tier}'",
"fix": f"Must be one of: {', '.join(sorted(VALID_TIERS))}"
})
# style_baseline length — model-aware
baseline = profile.get("style_baseline", "")
if isinstance(baseline, str):
max_len = STYLE_BASELINE_MAX_V4 if model == "v4 Pro" else STYLE_BASELINE_MAX
if len(baseline) > max_len:
findings.append({
"severity": "high",
"category": "consistency",
"location": {"file": str(profile_path), "field": "style_baseline"},
"issue": f"style_baseline is {len(baseline)} chars (max {max_len} for {model or 'this model'})",
"fix": f"Trim style_baseline to {max_len} characters. Front-load essential descriptors in the first 200 chars."
})
# vocal section — skip required checks if instrumental
vocal = profile.get("vocal", {})
if not is_instrumental:
if not isinstance(vocal, dict):
findings.append({
"severity": "high",
"category": "structure",
"location": {"file": str(profile_path), "field": "vocal"},
"issue": "'vocal' must be a mapping",
"fix": "Define vocal as a YAML mapping with gender, tone, delivery, energy fields"
})
else:
for vfield in ["gender", "tone", "delivery", "energy"]:
val = vocal.get(vfield)
if not val or not isinstance(val, str) or not val.strip():
findings.append({
"severity": "high",
"category": "structure",
"location": {"file": str(profile_path), "field": f"vocal.{vfield}"},
"issue": f"Required vocal field '{vfield}' is missing or empty",
"fix": f"Add a non-empty 'vocal.{vfield}' field (or set instrumental: true for instrumental projects)"
})
gender = vocal.get("gender", "")
if gender and gender not in VALID_GENDERS:
findings.append({
"severity": "medium",
"category": "consistency",
"location": {"file": str(profile_path), "field": "vocal.gender"},
"issue": f"Invalid vocal gender '{gender}'",
"fix": f"Must be one of: {', '.join(sorted(VALID_GENDERS))}"
})
elif isinstance(vocal, dict):
# Instrumental but vocal present — validate gender if provided
gender = vocal.get("gender", "")
if gender and gender not in VALID_GENDERS:
findings.append({
"severity": "medium",
"category": "consistency",
"location": {"file": str(profile_path), "field": "vocal.gender"},
"issue": f"Invalid vocal gender '{gender}'",
"fix": f"Must be one of: {', '.join(sorted(VALID_GENDERS))}"
})
# Tier-model consistency
if tier == "free" and model and model != FREE_TIER_MODEL:
findings.append({
"severity": "medium",
"category": "consistency",
"location": {"file": str(profile_path), "field": "model_preference"},
"issue": f"Free tier can only use '{FREE_TIER_MODEL}', but profile specifies '{model}'",
"fix": f"Change model_preference to '{FREE_TIER_MODEL}' or upgrade tier"
})
# Slider warnings for free tier
sliders = profile.get("sliders", {})
if tier == "free" and isinstance(sliders, dict) and sliders:
has_values = any(
k in ("weirdness", "style_influence") and v is not None and v != 50
for k, v in sliders.items()
)
if has_values:
findings.append({
"severity": "medium",
"category": "consistency",
"location": {"file": str(profile_path), "field": "sliders"},
"issue": "Slider values set but free tier does not support Weirdness/Style Influence sliders",
"fix": "Remove sliders section or upgrade to Pro/Premier tier"
})
# Slider range validation
if isinstance(sliders, dict):
for sname in ["weirdness", "style_influence", "audio_influence"]:
sval = sliders.get(sname)
if sval is not None:
if not isinstance(sval, (int, float)) or sval < 0 or sval > 100:
findings.append({
"severity": "medium",
"category": "consistency",
"location": {"file": str(profile_path), "field": f"sliders.{sname}"},
"issue": f"Slider '{sname}' value {sval} out of range",
"fix": "Must be an integer between 0 and 100"
})
# Exclusion defaults length check
exclusions = profile.get("exclusion_defaults", [])
if isinstance(exclusions, list):
if len(exclusions) > 5:
findings.append({
"severity": "low",
"category": "consistency",
"location": {"file": str(profile_path), "field": "exclusion_defaults"},
"issue": f"{len(exclusions)} exclusions defined (recommended max 5)",
"fix": "Too many negatives can confuse the model. Prioritize the most important."
})
# creativity_default validation
creativity = profile.get("creativity_default")
if creativity is not None:
if not isinstance(creativity, str) or creativity not in VALID_CREATIVITY:
findings.append({
"severity": "medium",
"category": "consistency",
"location": {"file": str(profile_path), "field": "creativity_default"},
"issue": f"Invalid creativity_default '{creativity}'",
"fix": f"Must be one of: {', '.join(sorted(VALID_CREATIVITY))}"
})
# language validation
language = profile.get("language")
if language is not None:
if not isinstance(language, str) or not language.strip():
findings.append({
"severity": "low",
"category": "consistency",
"location": {"file": str(profile_path), "field": "language"},
"issue": "language field is present but empty",
"fix": "Provide a language value (e.g., 'English', 'Spanish') or remove the field"
})
# generation_history validation
gen_history = profile.get("generation_history")
if gen_history is not None:
if not isinstance(gen_history, list):
findings.append({
"severity": "low",
"category": "structure",
"location": {"file": str(profile_path), "field": "generation_history"},
"issue": "generation_history must be a list",
"fix": "Set generation_history to a list of snapshot entries"
})
elif len(gen_history) > MAX_GENERATION_HISTORY:
findings.append({
"severity": "low",
"category": "consistency",
"location": {"file": str(profile_path), "field": "generation_history"},
"issue": f"generation_history has {len(gen_history)} entries (max {MAX_GENERATION_HISTORY})",
"fix": f"Keep only the {MAX_GENERATION_HISTORY} most recent or significant entries"
})
# studio_preferences validation — warn if not premier
studio = profile.get("studio_preferences", {})
if isinstance(studio, dict) and any(v is not None and v != "" for v in studio.values()):
if tier and tier != "premier":
findings.append({
"severity": "medium",
"category": "consistency",
"location": {"file": str(profile_path), "field": "studio_preferences"},
"issue": f"Studio preferences set but '{tier}' tier does not support Studio features",
"fix": "Remove studio_preferences or upgrade to Premier tier"
})
# Validate BPM if present
bpm = studio.get("bpm")
if bpm is not None and not isinstance(bpm, (int, float)):
findings.append({
"severity": "low",
"category": "consistency",
"location": {"file": str(profile_path), "field": "studio_preferences.bpm"},
"issue": f"BPM must be a number, got {type(bpm).__name__}",
"fix": "Set bpm to a numeric value (e.g., 120)"
})
# Build summary
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0}
for f in findings:
severity_counts[f["severity"]] = severity_counts.get(f["severity"], 0) + 1
# Per-band playlist YAML check: if the band has any songbook entries,
# `docs/{band-slug}-playlist.yaml` MUST exist as the canonical source of
# truth for playlist sequencing. Multi-band projects need this to keep
# bands independent (see playlist-sequencing-methodology.md "Per-Band
# Playlist YAML" section).
band_slug = profile_path.stem # e.g., docs/band-profiles/lennys-voice.yaml -> lennys-voice
project_root = profile_path.parent.parent.parent # band-profiles -> docs -> project_root
songbook_dir = project_root / "docs" / "songbook" / band_slug
playlist_yaml = project_root / "docs" / f"{band_slug}-playlist.yaml"
if songbook_dir.is_dir() and any(songbook_dir.glob("*.md")):
if not playlist_yaml.exists():
findings.append({
"severity": "high",
"category": "structure",
"location": {"file": str(profile_path), "expected_file": str(playlist_yaml)},
"issue": (
f"Band has songbook entries at {songbook_dir} but no canonical "
f"playlist YAML at {playlist_yaml}. Per-band playlist YAML is the "
f"single source of truth for sequencing."
),
"fix": (
f"Run `python3 src/skills/suno-band-profile-manager/scripts/scaffold-playlist.py "
f"{band_slug} --from-songbook` to bootstrap from songbook entries, then fill in "
f"audio file names and order. See profile-schema.md 'Per-Band Playlist YAML' section."
),
})
# Deprecated: in-profile `playlist:` block. Per v1.7.2 the band profile
# should NOT carry playlist data — that lives in docs/{band-slug}-playlist.yaml.
if "playlist" in profile and isinstance(profile["playlist"], dict):
findings.append({
"severity": "medium",
"category": "deprecation",
"location": {"file": str(profile_path), "field": "playlist"},
"issue": (
"The `playlist:` block in the band profile is DEPRECATED as of v1.7.2. "
"Playlist data must live in docs/{band-slug}-playlist.yaml as the single "
"source of truth, otherwise the two locations drift independently."
),
"fix": (
f"Move authoritative track list to docs/{band_slug}-playlist.yaml (or run "
f"scaffold-playlist.py to bootstrap), then remove the `playlist:` block "
f"from this profile YAML. Sequencing-history narrative notes can move to "
f"the band's playlist-ordering.md if you maintain one."
),
})
# Re-tally severity counts after the playlist checks above
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0}
for f in findings:
sev = f.get("severity", "low")
if sev in severity_counts:
severity_counts[sev] += 1
status = "pass"
if severity_counts["critical"] > 0:
status = "fail"
elif severity_counts["high"] > 0:
status = "fail"
elif severity_counts["medium"] > 0:
status = "warning"
return {
"script": script_name,
"version": "2.1.0",
"skill_path": str(profile_path),
"timestamp": datetime.now(timezone.utc).isoformat(),
"status": status,
"findings": findings,
"summary": {
"total": len(findings),
**severity_counts
}
}
def main():
parser = argparse.ArgumentParser(
description="Validate a band profile YAML file against the profile schema.",
epilog="Exit codes: 0=pass, 1=fail, 2=error"
)
parser.add_argument("profile_path", nargs="?", help="Path to the band profile YAML file")
parser.add_argument("-o", "--output", help="Output file (defaults to stdout)")
parser.add_argument("--verbose", action="store_true", help="Print diagnostics to stderr")
parser.add_argument(
"--derive-filename",
metavar="BAND_NAME",
help="Convert a band name to kebab-case filename and exit"
)
args = parser.parse_args()
if args.derive_filename:
result = {
"band_name": args.derive_filename,
"filename": derive_filename(args.derive_filename),
}
output = json.dumps(result, indent=2)
if args.output:
Path(args.output).write_text(output)
else:
print(output)
sys.exit(0)
if not args.profile_path:
parser.error("profile_path is required when not using --derive-filename")
profile_path = Path(args.profile_path)
if args.verbose:
print(f"Validating profile: {profile_path}", file=sys.stderr)
result = validate_profile(profile_path)
output = json.dumps(result, indent=2)
if args.output:
Path(args.output).write_text(output)
if args.verbose:
print(f"Results written to {args.output}", file=sys.stderr)
else:
print(output)
if result["status"] == "fail":
sys.exit(1)
sys.exit(0)
if __name__ == "__main__":
main()