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
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:
160
.agent/skills/suno-band-profile-manager/scripts/diff-profiles.py
Normal file
160
.agent/skills/suno-band-profile-manager/scripts/diff-profiles.py
Normal 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()
|
||||
167
.agent/skills/suno-band-profile-manager/scripts/list-profiles.py
Normal file
167
.agent/skills/suno-band-profile-manager/scripts/list-profiles.py
Normal 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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
223
.agent/skills/suno-band-profile-manager/scripts/tier-features.py
Normal file
223
.agent/skills/suno-band-profile-manager/scripts/tier-features.py
Normal 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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user