feat: add MuseScore 4 CLI wrapper for music notation

First music notation tool in the CLI-Anything ecosystem. Wraps MuseScore 4's
mscore backend following the HARNESS.md 7-phase methodology.

Supports: transpose (by key/interval/diatonic), export (PDF/PNG/SVG/MP3/FLAC/
WAV/MIDI/MusicXML/Braille), part extraction, instrument management, score
analysis (probe/diff/stats), and interactive REPL with undo/redo.

56 tests (39 unit + 17 E2E) — all passing on macOS with MuseScore 4.6.5.
This commit is contained in:
Vicky Tam
2026-03-21 23:06:07 +09:00
parent b1e9d53e9d
commit e6eb002311
26 changed files with 3987 additions and 0 deletions
+4
View File
@@ -42,6 +42,7 @@
!/adguardhome/
!/novita/
!/ollama/
!/musescore/
# Step 5: Inside each software dir, ignore everything (including dotfiles)
/gimp/*
@@ -74,6 +75,8 @@
/adguardhome/.*
/ollama/*
/ollama/.*
/musescore/*
/musescore/.*
# Step 6: ...except agent-harness/
!/gimp/agent-harness/
@@ -92,6 +95,7 @@
!/adguardhome/agent-harness/
!/novita/agent-harness/
!/ollama/agent-harness/
!/musescore/agent-harness/
# Step 7: Ignore build artifacts within allowed dirs
**/__pycache__/
+8
View File
@@ -589,6 +589,13 @@ Each application received complete, production-ready CLI interfaces — not demo
<td align="center">✅ 22</td>
</tr>
<tr>
<td align="center"><strong>🎵 MuseScore</strong></td>
<td>Music Notation</td>
<td><code>cli-anything-musescore</code></td>
<td>mscore CLI (MSCX/MusicXML)</td>
<td align="center">✅ 56</td>
</tr>
<tr>
<td align="center"><strong>📐 Draw.io</strong></td>
<td>Diagramming</td>
<td><code>cli-anything-drawio</code></td>
@@ -737,6 +744,7 @@ cli-anything/
├── 🎞️ kdenlive/agent-harness/ # Kdenlive CLI (155 tests)
├── 🎬 shotcut/agent-harness/ # Shotcut CLI (154 tests)
├── 📞 zoom/agent-harness/ # Zoom CLI (22 tests)
├── 🎵 musescore/agent-harness/ # MuseScore CLI (56 tests)
├── 📐 drawio/agent-harness/ # Draw.io CLI (138 tests)
├── 🧜 mermaid/agent-harness/ # Mermaid Live Editor CLI (10 tests)
├── ✨ anygen/agent-harness/ # AnyGen CLI (50 tests)
+118
View File
@@ -0,0 +1,118 @@
# MUSESCORE.md — Software-Specific Analysis and SOP
## 1. Software Overview
**MuseScore 4** is a free, open-source music notation editor. It reads and writes `.mscz` (native), `.mxl` (compressed MusicXML), `.mid` (MIDI), and `.musicxml` formats.
- Homepage: https://musescore.org
- Version tested: 4.6.5
- License: GPL v3
## 2. Backend Engine
The `mscore` binary provides all rendering, transposition, and conversion capabilities.
### Binary Locations
| Platform | Path |
|----------|------|
| macOS | `/Applications/MuseScore 4.app/Contents/MacOS/mscore` |
| Linux | `/usr/bin/mscore4` or `/usr/local/bin/mscore4` |
| Windows | `C:\Program Files\MuseScore 4\bin\MuseScore4.exe` |
### Data Model
- `.mscz` = ZIP archive containing:
- `.mscx` XML (score data)
- `score_style.mss` (style overrides)
- `audiosettings.json`
- `viewsettings.json`
- `Thumbnails/thumbnail.png`
- `.mxl` = ZIP archive containing MusicXML `score.xml`
## 3. CLI Capabilities
### Export (`-o`)
```bash
mscore -o output.pdf input.mscz # PDF
mscore -o output.mid input.mscz # MIDI
mscore -o output.mp3 --bitrate 192 input.mscz # MP3
mscore -o output.png -r 150 input.mscz # PNG (per page)
mscore -o output.musicxml input.mscz # MusicXML
```
### Transpose (`--transpose` + `-o`)
JSON format:
```json
{
"mode": "to_key|by_interval|diatonically",
"direction": "up|down|closest",
"targetKey": 0,
"transposeInterval": 0,
"transposeKeySignatures": true,
"transposeChordNames": true,
"useDoubleSharpsFlats": false
}
```
### Key Signature Integer Mapping
```
-7=Cb -6=Gb -5=Db -4=Ab -3=Eb -2=Bb -1=F
0=C 1=G 2=D 3=A 4=E 5=B 6=F# 7=C#
```
### Metadata (`--score-meta`)
Returns JSON: title, composer, keysig, timesig, tempo, duration, measures, pages, parts.
### Parts (`--score-parts`)
Returns JSON with part names and base64-encoded .mscz data per part.
### Media (`--score-media`)
Returns JSON with pngs, svgs, pdf, midi, mxml, metadata.
### Batch Jobs (`-j`)
```json
[{"in": "/path/input.mscz", "out": "/path/output.pdf"}]
```
### Exit Codes
- `0` — success
- `31` — invalid transpose options
- `23` — invalid batch job format
### Output Verification (Magic Bytes)
| Format | Magic |
|--------|-------|
| PDF | `%PDF-` |
| MIDI | `MThd` |
| MP3 | `0xfffb` or `ID3` |
| PNG | `\x89PNG` |
| MSCZ | `PK` (ZIP) |
## 4. GUI-to-CLI Mapping
| GUI Action | CLI Equivalent |
|-----------|---------------|
| File → Export → PDF | `mscore -o output.pdf input.mscz` |
| Tools → Transpose | `mscore --transpose '{...}' -o out.mscz input.mscz` |
| File → Parts | `mscore --score-parts input.mscz` |
| File → Score Properties | `mscore --score-meta input.mscz` |
## 5. CLI Architecture
### Command Groups (v1 MVP)
| Group | Purpose | Backend |
|-------|---------|---------|
| `project` | open, info, save | MusicXML/MSCX parsing |
| `transpose` | by-key, by-interval, diatonic | `--transpose` + `-o` |
| `parts` | list, extract, generate | `--score-parts` |
| `export` | pdf, png, svg, mp3, flac, wav, midi, musicxml, braille, batch | `-o` |
| `instruments` | list, add, remove, reorder | MSCX XML manipulation |
| `media` | probe, diff, stats | `--score-meta`, `--diff` |
| `session` | status, undo, redo, history | In-memory state + JSON persistence |
### State Model
- In-memory `Session` dataclass with undo/redo stacks
- `fcntl.flock()` for safe concurrent JSON writes
- Session singleton via `get_session()`
@@ -0,0 +1,49 @@
# cli-anything-musescore
CLI wrapper for **MuseScore 4** — the first music notation tool in the [CLI-Anything](https://github.com/HKUDS/CLI-Anything) ecosystem.
## Features
- **Transpose** scores by key, interval, or diatonically
- **Export** to PDF, PNG, SVG, MP3, FLAC, WAV, MIDI, MusicXML, Braille
- **Extract parts** from multi-instrument scores
- **Manage instruments** — list, add, remove, reorder
- **Analyze scores** — metadata, diff, statistics
- **Interactive REPL** with undo/redo session management
## Requirements
- Python >= 3.10
- [MuseScore 4](https://musescore.org/en/download) installed
- macOS, Linux, or Windows
## Install
```bash
pip install -e .
```
## Quick Start
```bash
# Interactive REPL
cli-anything-musescore
# One-shot commands with JSON output
cli-anything-musescore --json project info -i score.mscz
cli-anything-musescore --json transpose by-key -i score.mscz -o out.mscz --target-key "C major"
cli-anything-musescore --json export pdf -i score.mscz -o score.pdf
cli-anything-musescore --json parts list -i score.mscz
```
## Command Groups
| Group | Commands | Description |
|-------|----------|-------------|
| `project` | open, info, save | Score file management |
| `transpose` | by-key, by-interval, diatonic | Transposition |
| `parts` | list, extract, generate | Part extraction |
| `export` | pdf, png, svg, mp3, flac, wav, midi, musicxml, braille, batch | Rendering |
| `instruments` | list, add, remove, reorder | Instrument management |
| `media` | probe, diff, stats | Score analysis |
| `session` | status, undo, redo, history | Session management |
@@ -0,0 +1,2 @@
"""cli-anything-musescore — CLI wrapper for MuseScore 4."""
__version__ = "1.0.0"
@@ -0,0 +1,3 @@
"""Allow running as python3 -m cli_anything.musescore"""
from cli_anything.musescore.musescore_cli import main
main()
@@ -0,0 +1,161 @@
"""Export/render pipeline via mscore backend."""
import os
from pathlib import Path
from cli_anything.musescore.utils import musescore_backend as backend
# ── Supported export formats ──────────────────────────────────────────
EXPORT_FORMATS = {
"pdf": {"ext": ".pdf", "magic": b"%PDF-", "desc": "PDF document"},
"png": {"ext": ".png", "magic": b"\x89PNG", "desc": "PNG image (per page)"},
"svg": {"ext": ".svg", "magic": None, "desc": "SVG vector (per page)"},
"mp3": {"ext": ".mp3", "magic": None, "desc": "MP3 audio"},
"flac": {"ext": ".flac", "magic": b"fLaC", "desc": "FLAC audio"},
"wav": {"ext": ".wav", "magic": b"RIFF", "desc": "WAV audio"},
"midi": {"ext": ".mid", "magic": b"MThd", "desc": "MIDI file"},
"musicxml": {"ext": ".musicxml", "magic": None, "desc": "MusicXML"},
"mscz": {"ext": ".mscz", "magic": b"PK", "desc": "MuseScore file"},
"braille": {"ext": ".brf", "magic": None, "desc": "Braille music notation"},
}
def export_score(input_path: str, output_path: str, *,
fmt: str | None = None,
dpi: int | None = None,
bitrate: int | None = None,
trim: int | None = None,
style: str | None = None,
sound_profile: str | None = None,
export_parts: bool = False) -> dict:
"""Export a score to the specified format.
Format is auto-detected from output_path extension, or can be
specified explicitly via fmt.
Returns:
Dict with export result info.
"""
if not os.path.isfile(input_path):
raise FileNotFoundError(f"Score file not found: {input_path}")
# Determine format
if fmt is None:
ext = Path(output_path).suffix.lower()
fmt = _ext_to_format(ext)
if fmt not in EXPORT_FORMATS:
raise ValueError(f"Unsupported format: {fmt}. Supported: {list(EXPORT_FORMATS.keys())}")
# Ensure output directory exists
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
# Run export
result_path = backend.export_score(
input_path, output_path,
dpi=dpi, bitrate=bitrate, trim=trim,
style=style, sound_profile=sound_profile,
export_parts=export_parts,
)
return {
"input": input_path,
"output": str(result_path),
"format": fmt,
}
def batch_export(input_path: str, outputs: list[str]) -> list[dict]:
"""Export a score to multiple formats at once via batch job.
Args:
input_path: Path to input score.
outputs: List of output file paths.
Returns:
List of dicts with per-output results.
"""
if not os.path.isfile(input_path):
raise FileNotFoundError(f"Score file not found: {input_path}")
job_list = [{"in": str(input_path), "out": str(o)} for o in outputs]
result_paths = backend.batch_convert(job_list)
return [
{
"input": input_path,
"output": str(p),
"format": _ext_to_format(p.suffix),
}
for p in result_paths
]
def verify_output(path: str, expected_format: str | None = None) -> dict:
"""Verify an exported file using magic bytes.
Args:
path: Path to the output file.
expected_format: Expected format name (e.g., "pdf", "midi").
Returns:
Dict with verification results.
"""
if not os.path.isfile(path):
return {"path": path, "exists": False, "valid": False}
size = os.path.getsize(path)
if size == 0:
return {"path": path, "exists": True, "size": 0, "valid": False}
result = {
"path": path,
"exists": True,
"size": size,
}
# Determine expected format from extension if not specified
if expected_format is None:
expected_format = _ext_to_format(Path(path).suffix)
fmt_info = EXPORT_FORMATS.get(expected_format)
if fmt_info and fmt_info["magic"]:
with open(path, "rb") as f:
header = f.read(max(len(fmt_info["magic"]), 5))
magic = fmt_info["magic"]
# Special handling for MP3 (can start with ID3 tag or sync bytes)
if expected_format == "mp3":
result["valid"] = (
header[:2] == b"\xff\xfb"
or header[:3] == b"ID3"
)
else:
result["valid"] = header[:len(magic)] == magic
else:
# No magic bytes to check; just verify non-empty
result["valid"] = size > 0
result["format"] = expected_format
return result
def _ext_to_format(ext: str) -> str:
"""Map file extension to format name."""
ext = ext.lower().lstrip(".")
mapping = {
"pdf": "pdf",
"png": "png",
"svg": "svg",
"mp3": "mp3",
"flac": "flac",
"wav": "wav",
"mid": "midi",
"midi": "midi",
"musicxml": "musicxml",
"xml": "musicxml",
"mscz": "mscz",
"brf": "braille",
}
return mapping.get(ext, ext)
@@ -0,0 +1,215 @@
"""Instrument management — list, add, remove, reorder.
For listing, uses mscore --score-meta. For add/remove/reorder,
manipulates the MSCX XML directly.
"""
import os
from pathlib import Path
from cli_anything.musescore.utils import musescore_backend as backend
from cli_anything.musescore.utils import mscx_xml as xml_utils
def list_instruments(path: str) -> list[dict]:
"""List instruments in a score.
Tries mscore --score-meta first, falls back to XML parsing.
"""
if not os.path.isfile(path):
raise FileNotFoundError(f"Score file not found: {path}")
# Try mscore metadata
try:
meta = backend.get_score_meta(path)
parts = meta.get("parts", [])
return [
{
"index": i,
"name": p.get("name", f"Instrument {i+1}"),
"instrumentId": p.get("instrumentId", ""),
"program": p.get("program", 0),
}
for i, p in enumerate(parts)
]
except Exception:
pass
# Fallback: XML parsing
try:
tree = xml_utils.read_score_tree(path)
instruments = xml_utils.get_instruments(tree)
return [
{
"index": i,
"name": inst.get("name") or inst.get("part_name", f"Instrument {i+1}"),
"instrumentId": inst.get("id", ""),
}
for i, inst in enumerate(instruments)
]
except Exception as e:
raise RuntimeError(f"Could not list instruments: {e}")
def add_instrument(path: str, output_path: str, instrument_id: str,
name: str) -> dict:
"""Add an instrument to a .mscz score via MSCX XML manipulation.
Args:
path: Path to input .mscz file.
output_path: Path to output .mscz file.
instrument_id: MuseScore instrument ID (e.g., "keyboard.piano").
name: Display name for the instrument.
Returns:
Dict with result info.
"""
fmt = xml_utils.detect_format(path)
if fmt != "mscz":
raise ValueError("Instrument manipulation requires .mscz format")
data = xml_utils.read_mscz(path)
root = data["mscx"].getroot()
# Find or create the Score element
score = root.find(".//Score")
if score is None:
raise RuntimeError("No <Score> element found in MSCX")
# Create a new Part element
import xml.etree.ElementTree as ET
part = ET.SubElement(score, "Part")
staff = ET.SubElement(part, "Staff")
staff_id = str(len(root.findall(".//Part")) + 1)
staff.set("id", staff_id)
instrument = ET.SubElement(part, "Instrument")
instrument.set("id", instrument_id)
long_name = ET.SubElement(instrument, "longName")
long_name.text = name
short_name = ET.SubElement(instrument, "shortName")
short_name.text = name[:3]
# Also add a Staff element at the score level
score_staff = ET.SubElement(score, "Staff")
score_staff.set("id", staff_id)
xml_utils.write_mscz(output_path, data)
return {
"action": "add",
"instrument_id": instrument_id,
"name": name,
"output": str(Path(output_path).resolve()),
}
def remove_instrument(path: str, output_path: str,
instrument_name: str) -> dict:
"""Remove an instrument from a .mscz score.
Args:
path: Path to input .mscz file.
output_path: Path to output .mscz file.
instrument_name: Name of the instrument to remove (case-insensitive).
Returns:
Dict with result info.
"""
fmt = xml_utils.detect_format(path)
if fmt != "mscz":
raise ValueError("Instrument manipulation requires .mscz format")
data = xml_utils.read_mscz(path)
root = data["mscx"].getroot()
score = root.find(".//Score")
if score is None:
raise RuntimeError("No <Score> element found in MSCX")
# Find the part to remove
removed = False
for part in score.findall("Part"):
inst = part.find("Instrument")
if inst is not None:
ln = inst.find("longName")
name = ln.text if ln is not None else ""
if name.lower() == instrument_name.lower():
# Get staff ID before removing
staff_elem = part.find("Staff")
staff_id = staff_elem.get("id") if staff_elem is not None else None
score.remove(part)
# Also remove corresponding score-level Staff
if staff_id:
for s in score.findall("Staff"):
if s.get("id") == staff_id:
score.remove(s)
break
removed = True
break
if not removed:
raise ValueError(f"Instrument '{instrument_name}' not found")
xml_utils.write_mscz(output_path, data)
return {
"action": "remove",
"instrument_name": instrument_name,
"output": str(Path(output_path).resolve()),
}
def reorder_instruments(path: str, output_path: str,
new_order: list[str]) -> dict:
"""Reorder instruments in a .mscz score.
Args:
path: Path to input .mscz file.
output_path: Path to output .mscz file.
new_order: List of instrument names in desired order.
Returns:
Dict with result info.
"""
fmt = xml_utils.detect_format(path)
if fmt != "mscz":
raise ValueError("Instrument manipulation requires .mscz format")
data = xml_utils.read_mscz(path)
root = data["mscx"].getroot()
score = root.find(".//Score")
if score is None:
raise RuntimeError("No <Score> element found in MSCX")
# Collect parts by name
parts_by_name = {}
for part in score.findall("Part"):
inst = part.find("Instrument")
if inst is not None:
ln = inst.find("longName")
name = ln.text if ln is not None else ""
parts_by_name[name.lower()] = part
# Remove all parts
for part in score.findall("Part"):
score.remove(part)
# Re-add in new order
for name in new_order:
part = parts_by_name.get(name.lower())
if part is None:
raise ValueError(f"Instrument '{name}' not found in score")
score.append(part)
xml_utils.write_mscz(output_path, data)
return {
"action": "reorder",
"new_order": new_order,
"output": str(Path(output_path).resolve()),
}
@@ -0,0 +1,101 @@
"""Media operations — probe, diff, stats."""
import os
from pathlib import Path
from cli_anything.musescore.utils import musescore_backend as backend
from cli_anything.musescore.utils import mscx_xml as xml_utils
def probe_score(path: str) -> dict:
"""Get comprehensive metadata about a score.
Combines mscore --score-meta with XML parsing for a rich result.
"""
if not os.path.isfile(path):
raise FileNotFoundError(f"Score file not found: {path}")
result = {
"path": str(Path(path).resolve()),
"format": xml_utils.detect_format(path),
"size_bytes": os.path.getsize(path),
}
# mscore metadata
try:
meta = backend.get_score_meta(path)
result["metadata"] = meta
except Exception:
# XML fallback
try:
tree = xml_utils.read_score_tree(path)
result["metadata"] = {
"title": xml_utils.get_score_title(tree),
"key_signature": xml_utils.get_key_signature(tree),
"time_signature": xml_utils.get_time_signature(tree),
"instruments": xml_utils.get_instruments(tree),
"measures": xml_utils.count_measures(tree),
"notes": xml_utils.count_notes(tree),
}
except Exception as e:
result["error"] = str(e)
return result
def diff_scores(path_a: str, path_b: str, raw: bool = False) -> dict:
"""Diff two scores using mscore --diff.
Args:
path_a: Path to first score.
path_b: Path to second score.
raw: If True, use --raw-diff.
Returns:
Dict with diff results.
"""
for p in [path_a, path_b]:
if not os.path.isfile(p):
raise FileNotFoundError(f"Score file not found: {p}")
diff_data = backend.diff_scores(path_a, path_b, raw=raw)
return {
"file_a": str(Path(path_a).resolve()),
"file_b": str(Path(path_b).resolve()),
"raw": raw,
"diff": diff_data,
}
def score_stats(path: str) -> dict:
"""Compute statistics about a score from XML analysis.
Returns note count, measure count, instrument count,
key signature, time signature, etc.
"""
if not os.path.isfile(path):
raise FileNotFoundError(f"Score file not found: {path}")
result = {
"path": str(Path(path).resolve()),
"format": xml_utils.detect_format(path),
"size_bytes": os.path.getsize(path),
}
try:
tree = xml_utils.read_score_tree(path)
key_int = xml_utils.get_key_signature(tree)
result["stats"] = {
"title": xml_utils.get_score_title(tree),
"measures": xml_utils.count_measures(tree),
"notes": xml_utils.count_notes(tree),
"instruments": len(xml_utils.get_instruments(tree)),
"key_signature": key_int,
"key_name": xml_utils.key_int_to_name(key_int) if key_int is not None else None,
"time_signature": xml_utils.get_time_signature(tree),
}
except Exception as e:
result["error"] = str(e)
return result
@@ -0,0 +1,143 @@
"""Part extraction and management."""
import base64
import os
from pathlib import Path
from cli_anything.musescore.utils import musescore_backend as backend
def list_parts(path: str) -> list[dict]:
"""List all parts in a score.
Returns:
List of dicts with part name, instrumentId, etc.
"""
if not os.path.isfile(path):
raise FileNotFoundError(f"Score file not found: {path}")
# Try score-meta first (lighter)
try:
meta = backend.get_score_meta(path)
parts = meta.get("parts", [])
return [
{
"index": i,
"name": p.get("name", f"Part {i+1}"),
"instrumentId": p.get("instrumentId", ""),
"program": p.get("program", 0),
"lyricCount": p.get("lyricCount", 0),
"harmonyCount": p.get("harmonyCount", 0),
}
for i, p in enumerate(parts)
]
except Exception:
pass
# Fallback: score-parts (parts = list of names, partsMeta = list of dicts)
try:
parts_data = backend.get_score_parts(path)
part_names = parts_data.get("parts", [])
part_meta = parts_data.get("partsMeta", [])
return [
{
"index": i,
"name": part_names[i] if i < len(part_names) else f"Part {i+1}",
"id": part_meta[i].get("id", "") if i < len(part_meta) else "",
}
for i in range(max(len(part_names), len(part_meta)))
]
except Exception as e:
raise RuntimeError(f"Could not list parts: {e}")
def extract_part(path: str, part_name: str, output_path: str) -> dict:
"""Extract a single part from a score.
Uses --score-parts to get base64-encoded .mscz data for each part,
then writes the matching part to the output file.
Args:
path: Path to the input score.
part_name: Name of the part to extract.
output_path: Path to write the extracted part.
Returns:
Dict with extraction result info.
"""
if not os.path.isfile(path):
raise FileNotFoundError(f"Score file not found: {path}")
parts_data = backend.get_score_parts(path)
part_names = parts_data.get("parts", [])
part_bins = parts_data.get("partsBin", [])
# Find matching part index (case-insensitive)
match_idx = None
for i, name in enumerate(part_names):
if name.lower() == part_name.lower():
match_idx = i
break
if match_idx is None:
raise ValueError(
f"Part '{part_name}' not found. Available parts: {part_names}"
)
if match_idx >= len(part_bins):
raise RuntimeError(f"No binary data for part '{part_name}'")
part_data = part_bins[match_idx]
if not part_data:
raise RuntimeError(f"No data for part '{part_name}'")
decoded = base64.b64decode(part_data)
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "wb") as f:
f.write(decoded)
return {
"part_name": part_names[match_idx],
"output": str(Path(output_path).resolve()),
"size_bytes": len(decoded),
}
def generate_all_parts(path: str, output_dir: str) -> list[dict]:
"""Extract all parts from a score into separate files.
Args:
path: Path to the input score.
output_dir: Directory to write part files into.
Returns:
List of dicts with extraction results.
"""
if not os.path.isfile(path):
raise FileNotFoundError(f"Score file not found: {path}")
parts_data = backend.get_score_parts(path)
part_names = parts_data.get("parts", [])
part_bins = parts_data.get("partsBin", [])
results = []
Path(output_dir).mkdir(parents=True, exist_ok=True)
for i, name in enumerate(part_names):
if i >= len(part_bins) or not part_bins[i]:
continue
safe_name = name.replace("/", "_").replace("\\", "_").replace(" ", "_")
output_path = os.path.join(output_dir, f"{safe_name}.mscz")
decoded = base64.b64decode(part_bins[i])
with open(output_path, "wb") as f:
f.write(decoded)
results.append({
"part_name": name,
"output": output_path,
"size_bytes": len(decoded),
})
return results
@@ -0,0 +1,103 @@
"""Project management — create, open, save, info."""
import os
from pathlib import Path
from cli_anything.musescore.utils import musescore_backend as backend
from cli_anything.musescore.utils import mscx_xml as xml_utils
def open_project(path: str) -> dict:
"""Open a score file and return project data.
Supports .mscz, .mxl, .musicxml, .mid formats.
"""
if not os.path.isfile(path):
raise FileNotFoundError(f"Score file not found: {path}")
fmt = xml_utils.detect_format(path)
project = {
"name": Path(path).stem,
"path": str(Path(path).resolve()),
"format": fmt,
}
# Try to get metadata from mscore
try:
meta = backend.get_score_meta(path)
project["metadata"] = meta
if meta.get("title"):
project["name"] = meta["title"]
except Exception:
# Fall back to XML parsing for metadata
try:
tree = xml_utils.read_score_tree(path)
title = xml_utils.get_score_title(tree)
if title:
project["name"] = title
project["metadata"] = {
"key_signature": xml_utils.get_key_signature(tree),
"time_signature": xml_utils.get_time_signature(tree),
"instruments": xml_utils.get_instruments(tree),
"measures": xml_utils.count_measures(tree),
"notes": xml_utils.count_notes(tree),
}
except Exception:
pass
return project
def save_project(project: dict, path: str | None = None) -> str:
"""Save a project. Currently just returns the path (scores are
saved via mscore export, not by rewriting the file directly)."""
save_path = path or project.get("path")
if not save_path:
raise RuntimeError("No save path specified.")
return str(save_path)
def project_info(path: str) -> dict:
"""Get comprehensive info about a score file."""
if not os.path.isfile(path):
raise FileNotFoundError(f"Score file not found: {path}")
info = {
"path": str(Path(path).resolve()),
"format": xml_utils.detect_format(path),
"size_bytes": os.path.getsize(path),
}
# Try mscore --score-meta first (most complete)
try:
meta = backend.get_score_meta(path)
info["metadata"] = meta
return info
except Exception:
pass
# Fall back to XML parsing
try:
tree = xml_utils.read_score_tree(path)
info["metadata"] = {
"title": xml_utils.get_score_title(tree),
"key_signature": xml_utils.get_key_signature(tree),
"key_name": _key_sig_name(xml_utils.get_key_signature(tree)),
"time_signature": xml_utils.get_time_signature(tree),
"instruments": xml_utils.get_instruments(tree),
"measures": xml_utils.count_measures(tree),
"notes": xml_utils.count_notes(tree),
}
except Exception as e:
info["error"] = f"Could not parse score: {e}"
return info
def _key_sig_name(key_int: int | None) -> str | None:
if key_int is None:
return None
try:
return xml_utils.key_int_to_name(key_int)
except ValueError:
return f"keysig={key_int}"
@@ -0,0 +1,143 @@
"""Session management with undo/redo and JSON persistence.
Maintains in-memory state for the currently open project, with
undo/redo stacks and safe file locking for concurrent access.
"""
import copy
import fcntl
import json
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
@dataclass
class Session:
"""Stateful session for the MuseScore CLI."""
project_path: str | None = None
project_data: dict | None = None
modified: bool = False
undo_stack: list[dict] = field(default_factory=list)
redo_stack: list[dict] = field(default_factory=list)
history: list[str] = field(default_factory=list)
def has_project(self) -> bool:
return self.project_data is not None
def get_project(self) -> dict:
if self.project_data is None:
raise RuntimeError("No project is open. Use 'project open' first.")
return self.project_data
def set_project(self, data: dict, path: str | None = None):
self.project_data = data
self.project_path = path
self.modified = False
self.undo_stack.clear()
self.redo_stack.clear()
self.history.clear()
def is_modified(self) -> bool:
return self.modified
def snapshot(self, description: str):
"""Save current state to undo stack before a modification."""
if self.project_data is not None:
self.undo_stack.append({
"description": description,
"data": copy.deepcopy(self.project_data),
})
self.redo_stack.clear()
self.history.append(description)
self.modified = True
def undo(self) -> str:
"""Undo the last operation."""
if not self.undo_stack:
raise RuntimeError("Nothing to undo.")
entry = self.undo_stack.pop()
self.redo_stack.append({
"description": entry["description"],
"data": copy.deepcopy(self.project_data),
})
self.project_data = entry["data"]
self.modified = True
return entry["description"]
def redo(self) -> str:
"""Redo the last undone operation."""
if not self.redo_stack:
raise RuntimeError("Nothing to redo.")
entry = self.redo_stack.pop()
self.undo_stack.append({
"description": entry["description"],
"data": copy.deepcopy(self.project_data),
})
self.project_data = entry["data"]
self.modified = True
return entry["description"]
def list_history(self) -> list[str]:
return list(self.history)
def status(self) -> dict:
return {
"project_path": self.project_path or "(none)",
"modified": self.modified,
"undo_depth": len(self.undo_stack),
"redo_depth": len(self.redo_stack),
"history_length": len(self.history),
}
def save_session(self, path: str | None = None) -> str:
"""Save session state to a JSON file with file locking."""
save_path = path or self.project_path
if not save_path:
raise RuntimeError("No save path specified.")
session_file = str(save_path) + ".session.json"
data = {
"project_path": self.project_path,
"modified": self.modified,
"history": self.history,
}
_locked_save_json(session_file, data)
return session_file
def _locked_save_json(path: str, data: Any):
"""Save JSON data with file locking (fcntl.flock).
Opens in r+ mode (never w which truncates before lock).
Creates the file first if it doesn't exist.
"""
# Ensure file exists
if not os.path.exists(path):
Path(path).parent.mkdir(parents=True, exist_ok=True)
with open(path, "w") as f:
f.write("{}")
with open(path, "r+") as f:
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
try:
f.seek(0)
f.truncate()
json.dump(data, f, indent=2, default=str)
finally:
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
# ── Singleton ─────────────────────────────────────────────────────────
_session: Session | None = None
def get_session() -> Session:
"""Get or create the global session singleton."""
global _session
if _session is None:
_session = Session()
return _session
@@ -0,0 +1,188 @@
"""Transposition logic — by key, by interval, diatonic."""
from pathlib import Path
from cli_anything.musescore.utils import musescore_backend as backend
from cli_anything.musescore.utils.mscx_xml import key_name_to_int
# ── Interval enum mapping (from MuseScore source) ────────────────────
# The transposeInterval field is an index into this enum, NOT semitones.
# Index: (name, semitones)
INTERVAL_ENUM = [
("Perfect Unison", 0),
("Minor Second", 1),
("Major Second", 2),
("Minor Third", 3),
("Major Third", 4),
("Perfect Fourth", 5),
("Augmented Fourth", 6),
("Perfect Fifth", 7),
("Minor Sixth", 8),
("Major Sixth", 9),
("Minor Seventh", 10),
("Major Seventh", 11),
("Perfect Octave", 12),
("Minor Ninth", 13),
("Major Ninth", 14),
("Minor Tenth", 15),
("Major Tenth", 16),
("Perfect Eleventh", 17),
("Augmented Eleventh", 18),
("Perfect Twelfth", 19),
("Minor Thirteenth", 20),
("Major Thirteenth", 21),
("Minor Fourteenth", 22),
("Major Fourteenth", 23),
("Perfect Fifteenth", 24),
("Double Augmented Unison", 25),
]
# Semitone → best-fit interval index (first match)
_SEMITONES_TO_INTERVAL = {}
for _idx, (_name, _semi) in enumerate(INTERVAL_ENUM):
if _semi not in _SEMITONES_TO_INTERVAL:
_SEMITONES_TO_INTERVAL[_semi] = _idx
def semitones_to_interval_index(semitones: int) -> int:
"""Convert a semitone count to the mscore transposeInterval index.
Args:
semitones: Number of semitones (0-24).
Returns:
Index into the MuseScore interval enum.
"""
abs_semi = abs(semitones) % 25
if abs_semi in _SEMITONES_TO_INTERVAL:
return _SEMITONES_TO_INTERVAL[abs_semi]
raise ValueError(f"No interval mapping for {semitones} semitones")
def transpose_by_key(input_path: str, output_path: str, *,
target_key: str,
direction: str = "closest",
transpose_key_signatures: bool = True,
transpose_chord_names: bool = True,
use_double_sharps_flats: bool = False) -> dict:
"""Transpose a score to a target key.
Args:
input_path: Path to input score.
output_path: Path to output score.
target_key: Key name (e.g., "C major", "Db", "Am").
direction: "up", "down", or "closest".
transpose_key_signatures: Whether to transpose key signatures.
transpose_chord_names: Whether to transpose chord names.
use_double_sharps_flats: Whether to use double sharps/flats.
Returns:
Dict with result info.
"""
key_int = key_name_to_int(target_key)
opts = {
"mode": "to_key",
"direction": direction,
"targetKey": key_int,
"transposeKeySignatures": transpose_key_signatures,
"transposeChordNames": transpose_chord_names,
"useDoubleSharpsFlats": use_double_sharps_flats,
}
result_path = backend.transpose_score(input_path, output_path, opts)
return {
"input": input_path,
"output": str(result_path),
"mode": "to_key",
"target_key": target_key,
"target_key_int": key_int,
"direction": direction,
}
def transpose_by_interval(input_path: str, output_path: str, *,
semitones: int | None = None,
interval_index: int | None = None,
direction: str = "up",
transpose_key_signatures: bool = True,
transpose_chord_names: bool = True,
use_double_sharps_flats: bool = False) -> dict:
"""Transpose a score by a chromatic interval.
Specify either semitones or interval_index (not both).
Args:
semitones: Number of semitones to transpose.
interval_index: Direct mscore interval enum index (0-25).
direction: "up" or "down".
"""
if semitones is not None and interval_index is not None:
raise ValueError("Specify either semitones or interval_index, not both.")
if semitones is None and interval_index is None:
raise ValueError("Must specify either semitones or interval_index.")
if semitones is not None:
if semitones < 0:
direction = "down"
semitones = abs(semitones)
idx = semitones_to_interval_index(semitones)
else:
idx = interval_index
opts = {
"mode": "by_interval",
"direction": direction,
"transposeInterval": idx,
"transposeKeySignatures": transpose_key_signatures,
"transposeChordNames": transpose_chord_names,
"useDoubleSharpsFlats": use_double_sharps_flats,
}
result_path = backend.transpose_score(input_path, output_path, opts)
return {
"input": input_path,
"output": str(result_path),
"mode": "by_interval",
"interval_index": idx,
"direction": direction,
}
def transpose_diatonic(input_path: str, output_path: str, *,
steps: int,
direction: str = "up",
transpose_key_signatures: bool = True,
transpose_chord_names: bool = True,
use_double_sharps_flats: bool = False) -> dict:
"""Transpose a score diatonically by a number of steps.
Args:
steps: Number of diatonic steps.
direction: "up" or "down".
"""
if steps < 0:
direction = "down"
steps = abs(steps)
opts = {
"mode": "diatonically",
"direction": direction,
"transposeInterval": steps,
"transposeKeySignatures": transpose_key_signatures,
"transposeChordNames": transpose_chord_names,
"useDoubleSharpsFlats": use_double_sharps_flats,
}
result_path = backend.transpose_score(input_path, output_path, opts)
return {
"input": input_path,
"output": str(result_path),
"mode": "diatonically",
"steps": steps,
"direction": direction,
}
@@ -0,0 +1,572 @@
#!/usr/bin/env python3
"""MuseScore CLI — A stateful command-line interface for music notation.
This CLI wraps MuseScore 4's mscore backend, providing transposition,
export (PDF/audio/MIDI), part extraction, instrument management, and
score analysis from the command line.
Usage:
cli-anything-musescore --json project info -i score.mscz
cli-anything-musescore --json transpose by-key -i score.mscz -o out.mscz --target-key "C major"
cli-anything-musescore --json export pdf -i score.mscz -o score.pdf
cli-anything-musescore # Enter interactive REPL
"""
import sys
import os
import json
import click
from typing import Optional
from cli_anything.musescore.core.session import Session, get_session
from cli_anything.musescore.core import project as proj_mod
from cli_anything.musescore.core import transpose as trans_mod
from cli_anything.musescore.core import parts as parts_mod
from cli_anything.musescore.core import export as export_mod
from cli_anything.musescore.core import instruments as inst_mod
from cli_anything.musescore.core import media as media_mod
_json_output = False
_repl_mode = False
def output(data, message: str = ""):
"""Output data as JSON or human-readable."""
if _json_output:
click.echo(json.dumps(data, indent=2, default=str))
else:
if message:
click.echo(message)
if isinstance(data, dict):
_print_dict(data)
elif isinstance(data, list):
_print_list(data)
else:
click.echo(str(data))
def _print_dict(d: dict, indent: int = 0):
prefix = " " * indent
for k, v in d.items():
if isinstance(v, dict):
click.echo(f"{prefix}{k}:")
_print_dict(v, indent + 1)
elif isinstance(v, list):
click.echo(f"{prefix}{k}:")
_print_list(v, indent + 1)
else:
click.echo(f"{prefix}{k}: {v}")
def _print_list(items: list, indent: int = 0):
prefix = " " * indent
for i, item in enumerate(items):
if isinstance(item, dict):
click.echo(f"{prefix}[{i}]")
_print_dict(item, indent + 1)
else:
click.echo(f"{prefix}- {item}")
def handle_error(func):
"""Decorator for consistent error handling across commands."""
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except FileNotFoundError as e:
if _json_output:
click.echo(json.dumps({"error": str(e), "type": "file_not_found"}))
else:
click.echo(f"Error: {e}", err=True)
if not _repl_mode:
sys.exit(1)
except (ValueError, IndexError, RuntimeError) as e:
if _json_output:
click.echo(json.dumps({"error": str(e), "type": type(e).__name__}))
else:
click.echo(f"Error: {e}", err=True)
if not _repl_mode:
sys.exit(1)
wrapper.__name__ = func.__name__
wrapper.__doc__ = func.__doc__
return wrapper
# ── Main CLI Group ────────────────────────────────────────────────────
@click.group(invoke_without_command=True)
@click.option("--json", "use_json", is_flag=True, help="Output as JSON")
@click.option("--project", "project_path", type=str, default=None,
help="Path to score file to open")
@click.pass_context
def cli(ctx, use_json, project_path):
"""MuseScore CLI — Music notation from the command line.
Run without a subcommand to enter interactive REPL mode.
"""
global _json_output
_json_output = use_json
if project_path:
sess = get_session()
if not sess.has_project():
proj = proj_mod.open_project(project_path)
sess.set_project(proj, project_path)
if ctx.invoked_subcommand is None:
ctx.invoke(repl)
# ── Project Commands ──────────────────────────────────────────────────
@cli.group()
def project():
"""Project management commands."""
pass
@project.command("open")
@click.option("-i", "--input", "path", required=True, help="Score file path")
@handle_error
def project_open(path):
"""Open a score file."""
proj = proj_mod.open_project(path)
sess = get_session()
sess.set_project(proj, path)
output(proj, f"Opened: {path}")
@project.command("info")
@click.option("-i", "--input", "path", required=True, help="Score file path")
@handle_error
def project_info(path):
"""Show score information."""
info = proj_mod.project_info(path)
output(info)
@project.command("save")
@click.option("-o", "--output", "path", default=None, help="Save path")
@handle_error
def project_save(path):
"""Save the current project."""
sess = get_session()
saved = proj_mod.save_project(sess.get_project(), path)
output({"saved": saved}, f"Saved to: {saved}")
# ── Transpose Commands ────────────────────────────────────────────────
@cli.group()
def transpose():
"""Transposition commands."""
pass
@transpose.command("by-key")
@click.option("-i", "--input", "input_path", required=True, help="Input score")
@click.option("-o", "--output", "output_path", required=True, help="Output score")
@click.option("--target-key", required=True, help="Target key (e.g., 'C major', 'Db', 'Am')")
@click.option("--direction", type=click.Choice(["up", "down", "closest"]),
default="closest", help="Transpose direction")
@click.option("--no-key-sig", is_flag=True, help="Don't transpose key signatures")
@click.option("--no-chord-names", is_flag=True, help="Don't transpose chord names")
@handle_error
def transpose_by_key(input_path, output_path, target_key, direction,
no_key_sig, no_chord_names):
"""Transpose to a target key."""
sess = get_session()
if sess.has_project():
sess.snapshot(f"Transpose to {target_key}")
result = trans_mod.transpose_by_key(
input_path, output_path,
target_key=target_key,
direction=direction,
transpose_key_signatures=not no_key_sig,
transpose_chord_names=not no_chord_names,
)
output(result, f"Transposed to {target_key}")
@transpose.command("by-interval")
@click.option("-i", "--input", "input_path", required=True, help="Input score")
@click.option("-o", "--output", "output_path", required=True, help="Output score")
@click.option("--semitones", type=int, default=None, help="Semitones (negative = down)")
@click.option("--interval", "interval_index", type=int, default=None,
help="MuseScore interval index (0-25)")
@click.option("--direction", type=click.Choice(["up", "down"]),
default="up", help="Transpose direction")
@click.option("--no-key-sig", is_flag=True, help="Don't transpose key signatures")
@click.option("--no-chord-names", is_flag=True, help="Don't transpose chord names")
@handle_error
def transpose_by_interval(input_path, output_path, semitones, interval_index,
direction, no_key_sig, no_chord_names):
"""Transpose by a chromatic interval."""
sess = get_session()
if sess.has_project():
sess.snapshot(f"Transpose by interval")
result = trans_mod.transpose_by_interval(
input_path, output_path,
semitones=semitones,
interval_index=interval_index,
direction=direction,
transpose_key_signatures=not no_key_sig,
transpose_chord_names=not no_chord_names,
)
output(result, "Transposed by interval")
@transpose.command("diatonic")
@click.option("-i", "--input", "input_path", required=True, help="Input score")
@click.option("-o", "--output", "output_path", required=True, help="Output score")
@click.option("--steps", type=int, required=True, help="Diatonic steps (negative = down)")
@click.option("--direction", type=click.Choice(["up", "down"]),
default="up", help="Transpose direction")
@click.option("--no-key-sig", is_flag=True, help="Don't transpose key signatures")
@click.option("--no-chord-names", is_flag=True, help="Don't transpose chord names")
@handle_error
def transpose_diatonic(input_path, output_path, steps, direction,
no_key_sig, no_chord_names):
"""Transpose diatonically."""
sess = get_session()
if sess.has_project():
sess.snapshot(f"Diatonic transpose by {steps}")
result = trans_mod.transpose_diatonic(
input_path, output_path,
steps=steps,
direction=direction,
transpose_key_signatures=not no_key_sig,
transpose_chord_names=not no_chord_names,
)
output(result, f"Diatonic transpose by {steps} steps")
# ── Parts Commands ────────────────────────────────────────────────────
@cli.group()
def parts():
"""Part extraction and management."""
pass
@parts.command("list")
@click.option("-i", "--input", "path", required=True, help="Score file path")
@handle_error
def parts_list(path):
"""List all parts in a score."""
result = parts_mod.list_parts(path)
output(result, "Parts:")
@parts.command("extract")
@click.option("-i", "--input", "path", required=True, help="Score file path")
@click.option("-o", "--output", "output_path", required=True, help="Output file path")
@click.option("--part", "part_name", required=True, help="Part name to extract")
@handle_error
def parts_extract(path, output_path, part_name):
"""Extract a single part from a score."""
result = parts_mod.extract_part(path, part_name, output_path)
output(result, f"Extracted part: {part_name}")
@parts.command("generate")
@click.option("-i", "--input", "path", required=True, help="Score file path")
@click.option("-d", "--output-dir", required=True, help="Output directory")
@handle_error
def parts_generate(path, output_dir):
"""Generate all parts as separate files."""
result = parts_mod.generate_all_parts(path, output_dir)
output(result, f"Generated {len(result)} parts")
# ── Export Commands ───────────────────────────────────────────────────
@cli.group("export")
def export_group():
"""Export/render commands."""
pass
def _make_export_cmd(fmt_name, description):
"""Factory for format-specific export commands."""
@export_group.command(fmt_name)
@click.option("-i", "--input", "input_path", required=True, help="Input score")
@click.option("-o", "--output", "output_path", required=True, help="Output file")
@click.option("--dpi", type=int, default=None, help="DPI for PNG export")
@click.option("--bitrate", type=int, default=None, help="Bitrate for MP3 (kbps)")
@click.option("--trim", type=int, default=None, help="Trim margin for PNG/SVG")
@click.option("--style", type=str, default=None, help="Style file (.mss)")
@click.option("--sound-profile", type=str, default=None,
help="Audio profile (MuseScore Basic or Muse Sounds)")
@handle_error
def export_cmd(input_path, output_path, dpi, bitrate, trim, style, sound_profile):
result = export_mod.export_score(
input_path, output_path, fmt=fmt_name,
dpi=dpi, bitrate=bitrate, trim=trim,
style=style, sound_profile=sound_profile,
)
output(result, f"Exported {fmt_name}: {output_path}")
export_cmd.__doc__ = description
return export_cmd
# Create format-specific commands
_make_export_cmd("pdf", "Export as PDF document")
_make_export_cmd("png", "Export as PNG images (one per page)")
_make_export_cmd("svg", "Export as SVG vector graphics")
_make_export_cmd("mp3", "Export as MP3 audio")
_make_export_cmd("flac", "Export as FLAC audio")
_make_export_cmd("wav", "Export as WAV audio")
_make_export_cmd("midi", "Export as MIDI file")
_make_export_cmd("musicxml", "Export as MusicXML")
_make_export_cmd("braille", "Export as Braille music notation")
@export_group.command("batch")
@click.option("-i", "--input", "input_path", required=True, help="Input score")
@click.option("-o", "--output", "outputs", multiple=True, required=True,
help="Output files (specify multiple)")
@handle_error
def export_batch(input_path, outputs):
"""Export to multiple formats at once."""
result = export_mod.batch_export(input_path, list(outputs))
output(result, f"Batch exported {len(result)} files")
@export_group.command("verify")
@click.argument("path")
@click.option("--format", "fmt", default=None, help="Expected format")
@handle_error
def export_verify(path, fmt):
"""Verify an exported file using magic bytes."""
result = export_mod.verify_output(path, fmt)
output(result)
# ── Instruments Commands ──────────────────────────────────────────────
@cli.group()
def instruments():
"""Instrument management commands."""
pass
@instruments.command("list")
@click.option("-i", "--input", "path", required=True, help="Score file path")
@handle_error
def instruments_list(path):
"""List instruments in a score."""
result = inst_mod.list_instruments(path)
output(result, "Instruments:")
@instruments.command("add")
@click.option("-i", "--input", "path", required=True, help="Input .mscz file")
@click.option("-o", "--output", "output_path", required=True, help="Output .mscz file")
@click.option("--id", "instrument_id", required=True, help="Instrument ID")
@click.option("--name", required=True, help="Display name")
@handle_error
def instruments_add(path, output_path, instrument_id, name):
"""Add an instrument to a score."""
sess = get_session()
if sess.has_project():
sess.snapshot(f"Add instrument: {name}")
result = inst_mod.add_instrument(path, output_path, instrument_id, name)
output(result, f"Added instrument: {name}")
@instruments.command("remove")
@click.option("-i", "--input", "path", required=True, help="Input .mscz file")
@click.option("-o", "--output", "output_path", required=True, help="Output .mscz file")
@click.option("--name", required=True, help="Instrument name to remove")
@handle_error
def instruments_remove(path, output_path, name):
"""Remove an instrument from a score."""
sess = get_session()
if sess.has_project():
sess.snapshot(f"Remove instrument: {name}")
result = inst_mod.remove_instrument(path, output_path, name)
output(result, f"Removed instrument: {name}")
@instruments.command("reorder")
@click.option("-i", "--input", "path", required=True, help="Input .mscz file")
@click.option("-o", "--output", "output_path", required=True, help="Output .mscz file")
@click.option("--order", required=True, help="Comma-separated instrument names")
@handle_error
def instruments_reorder(path, output_path, order):
"""Reorder instruments in a score."""
new_order = [n.strip() for n in order.split(",")]
sess = get_session()
if sess.has_project():
sess.snapshot("Reorder instruments")
result = inst_mod.reorder_instruments(path, output_path, new_order)
output(result, "Reordered instruments")
# ── Media Commands ────────────────────────────────────────────────────
@cli.group()
def media():
"""Media analysis commands."""
pass
@media.command("probe")
@click.option("-i", "--input", "path", required=True, help="Score file path")
@handle_error
def media_probe(path):
"""Probe score metadata."""
result = media_mod.probe_score(path)
output(result)
@media.command("diff")
@click.option("--reference", required=True, help="Reference score")
@click.option("--compare", required=True, help="Comparison score")
@click.option("--raw", is_flag=True, help="Use raw diff format")
@handle_error
def media_diff(reference, compare, raw):
"""Diff two scores."""
result = media_mod.diff_scores(reference, compare, raw=raw)
output(result)
@media.command("stats")
@click.option("-i", "--input", "path", required=True, help="Score file path")
@handle_error
def media_stats(path):
"""Show score statistics."""
result = media_mod.score_stats(path)
output(result)
# ── Session Commands ──────────────────────────────────────────────────
@cli.group("session")
def session_group():
"""Session management commands."""
pass
@session_group.command("status")
@handle_error
def session_status():
"""Show session status."""
sess = get_session()
output(sess.status())
@session_group.command("undo")
@handle_error
def session_undo():
"""Undo the last operation."""
sess = get_session()
desc = sess.undo()
output({"undone": desc}, f"Undone: {desc}")
@session_group.command("redo")
@handle_error
def session_redo():
"""Redo the last undone operation."""
sess = get_session()
desc = sess.redo()
output({"redone": desc}, f"Redone: {desc}")
@session_group.command("history")
@handle_error
def session_history():
"""Show undo history."""
sess = get_session()
history = sess.list_history()
output(history, "History:")
# ── REPL ──────────────────────────────────────────────────────────────
@cli.command(hidden=True)
@handle_error
def repl():
"""Start interactive REPL session."""
global _repl_mode
_repl_mode = True
from cli_anything.musescore.utils.repl_skin import ReplSkin
skin = ReplSkin("musescore", version="1.0.0")
skin.print_banner()
pt_session = skin.create_prompt_session()
while True:
try:
sess = get_session()
proj_name = ""
modified = False
if sess.has_project():
proj = sess.get_project()
proj_name = proj.get("name", "")
modified = sess.is_modified()
line = skin.get_input(pt_session, project_name=proj_name,
modified=modified)
if not line:
continue
if line.lower() in ("quit", "exit", "q"):
skin.print_goodbye()
break
if line.lower() == "help":
_repl_help(skin)
continue
args = line.split()
try:
cli.main(args, standalone_mode=False)
except SystemExit:
pass
except click.exceptions.UsageError as e:
skin.error(f"Usage error: {e}")
except Exception as e:
skin.error(str(e))
except (EOFError, KeyboardInterrupt):
skin.print_goodbye()
break
_repl_mode = False
def _repl_help(skin=None):
commands = {
"project open|info|save": "Project management",
"transpose by-key|by-interval|diatonic": "Transposition",
"parts list|extract|generate": "Part extraction",
"export pdf|png|svg|mp3|wav|midi|...": "Export/render",
"instruments list|add|remove|reorder": "Instrument management",
"media probe|diff|stats": "Score analysis",
"session status|undo|redo|history": "Session management",
"help": "Show this help",
"quit": "Exit REPL",
}
if skin is not None:
skin.help(commands)
else:
click.echo("\nCommands:")
for cmd, desc in commands.items():
click.echo(f" {cmd:50s} {desc}")
click.echo()
# ── Entry Point ───────────────────────────────────────────────────────
def main():
cli()
if __name__ == "__main__":
main()
@@ -0,0 +1,94 @@
---
name: musescore
display_name: MuseScore
version: 1.0.0
description: CLI for music notation — transpose, export PDF/audio/MIDI, extract parts, manage instruments
requires: MuseScore 4 (musescore.org)
entry_point: cli-anything-musescore
category: music
---
# MuseScore CLI Skill
## Overview
Wraps MuseScore 4's `mscore` backend for music notation tasks: transposition, export to multiple formats (PDF, PNG, MP3, MIDI, MusicXML, Braille), part extraction, instrument management, and score analysis.
## Commands
### Project Management
```bash
cli-anything-musescore --json project info -i score.mscz
cli-anything-musescore --json project open -i score.mscz
```
### Transposition
```bash
# Transpose to a target key
cli-anything-musescore --json transpose by-key -i score.mscz -o out.mscz --target-key "C major" --direction closest
# Transpose by semitones
cli-anything-musescore --json transpose by-interval -i score.mscz -o out.mscz --semitones 3
# Diatonic transposition
cli-anything-musescore --json transpose diatonic -i score.mscz -o out.mscz --steps 2
```
### Part Extraction
```bash
cli-anything-musescore --json parts list -i score.mscz
cli-anything-musescore --json parts extract -i score.mscz -o piano.mscz --part "Piano"
cli-anything-musescore --json parts generate -i score.mscz -d ./parts/
```
### Export
```bash
cli-anything-musescore --json export pdf -i score.mscz -o score.pdf
cli-anything-musescore --json export mp3 -i score.mscz -o score.mp3 --bitrate 192
cli-anything-musescore --json export png -i score.mscz -o score.png --dpi 300
cli-anything-musescore --json export midi -i score.mscz -o score.mid
cli-anything-musescore --json export musicxml -i score.mscz -o score.musicxml
cli-anything-musescore --json export braille -i score.mscz -o score.brf
cli-anything-musescore --json export batch -i score.mscz -o score.pdf -o score.mid
```
### Instrument Management
```bash
cli-anything-musescore --json instruments list -i score.mscz
cli-anything-musescore --json instruments add -i score.mscz -o out.mscz --id keyboard.piano --name "Piano"
cli-anything-musescore --json instruments remove -i score.mscz -o out.mscz --name "Violin"
```
### Score Analysis
```bash
cli-anything-musescore --json media probe -i score.mscz
cli-anything-musescore --json media stats -i score.mscz
cli-anything-musescore --json media diff --reference a.mscz --compare b.mscz
```
### Session
```bash
cli-anything-musescore --json session status
cli-anything-musescore --json session undo
cli-anything-musescore --json session redo
cli-anything-musescore --json session history
```
## Supported Input Formats
- `.mscz` (MuseScore native)
- `.mxl` (compressed MusicXML)
- `.musicxml` / `.xml` (MusicXML)
- `.mid` / `.midi` (MIDI)
## Key Names for Transposition
Major: Cb, Gb, Db, Ab, Eb, Bb, F, C, G, D, A, E, B, F#, C#
Minor: Ab, Eb, Bb, F, C, G, D, A, E, B, F#, C#, G#, D#, A#
Accepted formats: "C", "C major", "Am", "A minor", "Db major", "F# minor"
## Agent Guidance
- Always use `--json` flag for machine-readable output
- Verify exports with `export verify` after rendering
- Use `media probe` to inspect an unknown score before operating on it
- Transposition requires both `-i` (input) and `-o` (output)
- Part names are case-insensitive for `parts extract`
@@ -0,0 +1,130 @@
# TEST.md — Test Plan and Results
## Test Plan
### Unit Tests (`test_core.py`)
| # | Test | Module | Description |
|---|------|--------|-------------|
| 1 | TestSession::test_create_session | session | Create empty session |
| 2 | TestSession::test_set_project | session | Set project data and path |
| 3 | TestSession::test_get_project_raises | session | Error when no project open |
| 4 | TestSession::test_undo_redo | session | Undo/redo state transitions |
| 5 | TestSession::test_undo_empty_raises | session | Error on empty undo |
| 6 | TestSession::test_redo_empty_raises | session | Error on empty redo |
| 7 | TestSession::test_snapshot_clears_redo | session | New edit clears redo |
| 8 | TestSession::test_history | session | History tracking |
| 9 | TestSession::test_status | session | Status dict format |
| 10 | TestSession::test_modified_flag | session | Modified flag tracking |
| 11 | TestSession::test_save_session | session | JSON persistence with locking |
| 12 | TestKeySignature::test_major_keys | mscx_xml | Key name → int (majors) |
| 13 | TestKeySignature::test_minor_keys | mscx_xml | Key name → int (minors) |
| 14 | TestKeySignature::test_case_insensitive | mscx_xml | Case-insensitive lookup |
| 15 | TestKeySignature::test_invalid_key | mscx_xml | Error on invalid key |
| 16 | TestKeySignature::test_int_to_name | mscx_xml | Int → key name |
| 17 | TestKeySignature::test_all_major_keys_roundtrip | mscx_xml | Full roundtrip |
| 18 | TestTranspose::test_semitones_* | transpose | Semitone → interval mapping |
| 19 | TestXMLParsing::test_get_key_signature | mscx_xml | XML key sig extraction |
| 20 | TestXMLParsing::test_get_time_signature | mscx_xml | XML time sig extraction |
| 21 | TestXMLParsing::test_get_instruments | mscx_xml | XML instrument extraction |
| 22 | TestXMLParsing::test_get_score_title | mscx_xml | XML title extraction |
| 23 | TestXMLParsing::test_count_measures | mscx_xml | Measure counting |
| 24 | TestXMLParsing::test_count_notes | mscx_xml | Note counting |
| 25 | TestXMLParsing::test_detect_format | mscx_xml | Format detection |
| 26 | TestXMLParsing::test_mscz_roundtrip | mscx_xml | MSCZ write + read |
| 27 | TestExportVerification::test_* | export | Magic byte verification |
| 28 | TestMediaStats::test_score_stats | media | Synthetic MXL stats |
### E2E Tests (`test_full_e2e.py`)
| # | Test | Requires | Description |
|---|------|----------|-------------|
| 1 | TestExportE2E::test_export_pdf | mscore + samples | Export PDF, verify magic |
| 2 | TestExportE2E::test_export_midi | mscore + samples | Export MIDI, verify magic |
| 3 | TestExportE2E::test_export_mp3 | mscore + samples | Export MP3, verify magic |
| 4 | TestExportE2E::test_export_musicxml | mscore + samples | Export MusicXML, verify XML |
| 5 | TestExportE2E::test_export_png | mscore + samples | Export PNG pages |
| 6 | TestTransposeE2E::test_transpose_db_to_c | mscore + samples | Db→C key verification |
| 7 | TestTransposeE2E::test_transpose_by_interval | mscore + samples | Interval transpose |
| 8 | TestPartsE2E::test_list_parts | mscore + samples | Part listing |
| 9 | TestPartsE2E::test_extract_part | mscore + samples | Part extraction |
| 10 | TestMediaE2E::test_probe_mxl | mscore + samples | MXL metadata probe |
| 11 | TestMediaE2E::test_probe_mscz | mscore + samples | MSCZ metadata probe |
| 12 | TestMediaE2E::test_stats_mxl | mscore + samples | Score statistics |
| 13 | TestCLISubprocess::test_help | none | --help flag |
| 14 | TestCLISubprocess::test_json_project_info | mscore + samples | Subprocess JSON output |
| 15 | TestCLISubprocess::test_json_export_pdf | mscore + samples | Subprocess PDF export |
| 16 | TestCLISubprocess::test_json_transpose_by_key | mscore + samples | Subprocess transpose |
| 17 | TestCLISubprocess::test_full_workflow | mscore + samples | Info→transpose→export→verify |
## Test Results
```
$ python3 -m pytest cli_anything/musescore/tests/ -v --tb=short
============================= test session starts ==============================
platform darwin -- Python 3.12.2, pytest-7.4.4
rootdir: /Users/vickytam/Study/cli-anything/musescore/agent-harness
cli_anything/musescore/tests/test_core.py::TestSession::test_create_session PASSED
cli_anything/musescore/tests/test_core.py::TestSession::test_set_project PASSED
cli_anything/musescore/tests/test_core.py::TestSession::test_get_project_raises_without_open PASSED
cli_anything/musescore/tests/test_core.py::TestSession::test_undo_redo PASSED
cli_anything/musescore/tests/test_core.py::TestSession::test_undo_empty_raises PASSED
cli_anything/musescore/tests/test_core.py::TestSession::test_redo_empty_raises PASSED
cli_anything/musescore/tests/test_core.py::TestSession::test_snapshot_clears_redo PASSED
cli_anything/musescore/tests/test_core.py::TestSession::test_history PASSED
cli_anything/musescore/tests/test_core.py::TestSession::test_status PASSED
cli_anything/musescore/tests/test_core.py::TestSession::test_modified_flag PASSED
cli_anything/musescore/tests/test_core.py::TestSession::test_save_session PASSED
cli_anything/musescore/tests/test_core.py::TestKeySignature::test_major_keys PASSED
cli_anything/musescore/tests/test_core.py::TestKeySignature::test_minor_keys PASSED
cli_anything/musescore/tests/test_core.py::TestKeySignature::test_case_insensitive PASSED
cli_anything/musescore/tests/test_core.py::TestKeySignature::test_invalid_key PASSED
cli_anything/musescore/tests/test_core.py::TestKeySignature::test_int_to_name PASSED
cli_anything/musescore/tests/test_core.py::TestKeySignature::test_all_major_keys_roundtrip PASSED
cli_anything/musescore/tests/test_core.py::TestTranspose::test_semitones_to_interval_unison PASSED
cli_anything/musescore/tests/test_core.py::TestTranspose::test_semitones_to_interval_minor_second PASSED
cli_anything/musescore/tests/test_core.py::TestTranspose::test_semitones_to_interval_octave PASSED
cli_anything/musescore/tests/test_core.py::TestTranspose::test_semitones_to_interval_fifth PASSED
cli_anything/musescore/tests/test_core.py::TestTranspose::test_interval_enum_count PASSED
cli_anything/musescore/tests/test_core.py::TestXMLParsing::test_get_key_signature PASSED
cli_anything/musescore/tests/test_core.py::TestXMLParsing::test_get_time_signature PASSED
cli_anything/musescore/tests/test_core.py::TestXMLParsing::test_get_instruments PASSED
cli_anything/musescore/tests/test_core.py::TestXMLParsing::test_get_score_title PASSED
cli_anything/musescore/tests/test_core.py::TestXMLParsing::test_count_measures PASSED
cli_anything/musescore/tests/test_core.py::TestXMLParsing::test_count_notes PASSED
cli_anything/musescore/tests/test_core.py::TestXMLParsing::test_detect_format PASSED
cli_anything/musescore/tests/test_core.py::TestXMLParsing::test_mscz_roundtrip PASSED
cli_anything/musescore/tests/test_core.py::TestExportVerification::test_ext_to_format PASSED
cli_anything/musescore/tests/test_core.py::TestExportVerification::test_verify_nonexistent PASSED
cli_anything/musescore/tests/test_core.py::TestExportVerification::test_verify_pdf PASSED
cli_anything/musescore/tests/test_core.py::TestExportVerification::test_verify_midi PASSED
cli_anything/musescore/tests/test_core.py::TestExportVerification::test_verify_mp3_sync PASSED
cli_anything/musescore/tests/test_core.py::TestExportVerification::test_verify_mp3_id3 PASSED
cli_anything/musescore/tests/test_core.py::TestExportVerification::test_verify_png PASSED
cli_anything/musescore/tests/test_core.py::TestExportVerification::test_verify_empty_file PASSED
cli_anything/musescore/tests/test_core.py::TestMediaStats::test_score_stats_from_mxl PASSED
cli_anything/musescore/tests/test_full_e2e.py::TestExportE2E::test_export_pdf PASSED
cli_anything/musescore/tests/test_full_e2e.py::TestExportE2E::test_export_midi PASSED
cli_anything/musescore/tests/test_full_e2e.py::TestExportE2E::test_export_mp3 PASSED
cli_anything/musescore/tests/test_full_e2e.py::TestExportE2E::test_export_musicxml PASSED
cli_anything/musescore/tests/test_full_e2e.py::TestExportE2E::test_export_png PASSED
cli_anything/musescore/tests/test_full_e2e.py::TestTransposeE2E::test_transpose_db_to_c PASSED
cli_anything/musescore/tests/test_full_e2e.py::TestTransposeE2E::test_transpose_by_interval PASSED
cli_anything/musescore/tests/test_full_e2e.py::TestPartsE2E::test_list_parts PASSED
cli_anything/musescore/tests/test_full_e2e.py::TestPartsE2E::test_extract_part PASSED
cli_anything/musescore/tests/test_full_e2e.py::TestMediaE2E::test_probe_mxl PASSED
cli_anything/musescore/tests/test_full_e2e.py::TestMediaE2E::test_probe_mscz PASSED
cli_anything/musescore/tests/test_full_e2e.py::TestMediaE2E::test_stats_mxl PASSED
cli_anything/musescore/tests/test_full_e2e.py::TestCLISubprocess::test_help PASSED
cli_anything/musescore/tests/test_full_e2e.py::TestCLISubprocess::test_json_project_info PASSED
cli_anything/musescore/tests/test_full_e2e.py::TestCLISubprocess::test_json_export_pdf PASSED
cli_anything/musescore/tests/test_full_e2e.py::TestCLISubprocess::test_json_transpose_by_key PASSED
cli_anything/musescore/tests/test_full_e2e.py::TestCLISubprocess::test_full_workflow PASSED
============================= 56 passed in 15.10s ==============================
```
**Environment**: macOS Darwin 24.1.0, Python 3.12.2, MuseScore 4.6.5
**Date**: 2026-03-19
**Status**: 56/56 PASSED (39 unit + 17 E2E)
@@ -0,0 +1,398 @@
"""Unit tests for cli-anything-musescore core modules.
These tests use synthetic data and do NOT require mscore to be installed.
They test key name resolution, session management, XML parsing, etc.
"""
import json
import os
import tempfile
import xml.etree.ElementTree as ET
import zipfile
from pathlib import Path
import pytest
# ── Session Tests ─────────────────────────────────────────────────────
from cli_anything.musescore.core.session import Session
class TestSession:
def test_create_session(self):
s = Session()
assert not s.has_project()
assert s.project_data is None
def test_set_project(self):
s = Session()
s.set_project({"name": "test"}, "/tmp/test.mscz")
assert s.has_project()
assert s.project_data["name"] == "test"
assert s.project_path == "/tmp/test.mscz"
def test_get_project_raises_without_open(self):
s = Session()
with pytest.raises(RuntimeError, match="No project"):
s.get_project()
def test_undo_redo(self):
s = Session()
s.set_project({"name": "v1"})
s.snapshot("edit 1")
s.project_data["name"] = "v2"
s.snapshot("edit 2")
s.project_data["name"] = "v3"
# Undo edit 2
desc = s.undo()
assert desc == "edit 2"
assert s.project_data["name"] == "v2"
# Undo edit 1
desc = s.undo()
assert desc == "edit 1"
assert s.project_data["name"] == "v1"
# Redo edit 1
desc = s.redo()
assert desc == "edit 1"
assert s.project_data["name"] == "v2"
def test_undo_empty_raises(self):
s = Session()
s.set_project({"name": "test"})
with pytest.raises(RuntimeError, match="Nothing to undo"):
s.undo()
def test_redo_empty_raises(self):
s = Session()
s.set_project({"name": "test"})
with pytest.raises(RuntimeError, match="Nothing to redo"):
s.redo()
def test_snapshot_clears_redo(self):
s = Session()
s.set_project({"name": "v1"})
s.snapshot("edit 1")
s.project_data["name"] = "v2"
s.undo()
# New edit should clear redo stack
s.snapshot("edit 2")
assert len(s.redo_stack) == 0
def test_history(self):
s = Session()
s.set_project({"name": "test"})
s.snapshot("action 1")
s.snapshot("action 2")
s.snapshot("action 3")
assert s.list_history() == ["action 1", "action 2", "action 3"]
def test_status(self):
s = Session()
s.set_project({"name": "test"}, "/tmp/test.mscz")
s.snapshot("edit")
status = s.status()
assert status["project_path"] == "/tmp/test.mscz"
assert status["undo_depth"] == 1
assert status["redo_depth"] == 0
def test_modified_flag(self):
s = Session()
s.set_project({"name": "test"})
assert not s.is_modified()
s.snapshot("edit")
assert s.is_modified()
def test_save_session(self):
s = Session()
s.set_project({"name": "test"}, "/tmp/test.mscz")
s.snapshot("edit")
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "test.mscz")
result = s.save_session(path)
assert os.path.isfile(result)
with open(result) as f:
data = json.load(f)
assert data["history"] == ["edit"]
# ── Key Signature Tests ───────────────────────────────────────────────
from cli_anything.musescore.utils.mscx_xml import (
key_name_to_int, key_int_to_name, KEY_INT_TO_MAJOR,
)
class TestKeySignature:
def test_major_keys(self):
assert key_name_to_int("C") == 0
assert key_name_to_int("C major") == 0
assert key_name_to_int("G") == 1
assert key_name_to_int("Db") == -5
assert key_name_to_int("Db major") == -5
assert key_name_to_int("F#") == 6
def test_minor_keys(self):
assert key_name_to_int("A minor") == 0
assert key_name_to_int("Am") == 0
assert key_name_to_int("D minor") == -1
assert key_name_to_int("F# minor") == 3
def test_case_insensitive(self):
assert key_name_to_int("c major") == 0
assert key_name_to_int("DB MAJOR") == -5
assert key_name_to_int("am") == 0
def test_invalid_key(self):
with pytest.raises(ValueError, match="Unrecognized key"):
key_name_to_int("X major")
def test_int_to_name(self):
assert key_int_to_name(0) == "C major"
assert key_int_to_name(-5) == "Db major"
assert key_int_to_name(0, minor=True) == "A minor"
def test_all_major_keys_roundtrip(self):
for i, name in KEY_INT_TO_MAJOR.items():
assert key_name_to_int(name) == i
assert key_name_to_int(f"{name} major") == i
# ── Transpose Option Building Tests ───────────────────────────────────
from cli_anything.musescore.core.transpose import (
semitones_to_interval_index, INTERVAL_ENUM,
)
class TestTranspose:
def test_semitones_to_interval_unison(self):
assert semitones_to_interval_index(0) == 0 # Perfect Unison
def test_semitones_to_interval_minor_second(self):
assert semitones_to_interval_index(1) == 1 # Minor Second
def test_semitones_to_interval_octave(self):
assert semitones_to_interval_index(12) == 12 # Perfect Octave
def test_semitones_to_interval_fifth(self):
assert semitones_to_interval_index(7) == 7 # Perfect Fifth
def test_interval_enum_count(self):
assert len(INTERVAL_ENUM) == 26
# ── XML Parsing Tests ─────────────────────────────────────────────────
from cli_anything.musescore.utils.mscx_xml import (
get_key_signature, get_time_signature, get_instruments,
get_score_title, count_measures, count_notes,
detect_format, read_mscz, write_mscz,
)
class TestXMLParsing:
def _make_musicxml(self, fifths=-5, beats="4", beat_type="4",
title="Test Score", num_measures=4, num_notes=16):
"""Create a synthetic MusicXML tree for testing."""
root = ET.Element("score-partwise", version="4.0")
# Work title
work = ET.SubElement(root, "work")
ET.SubElement(work, "work-title").text = title
# Part list
part_list = ET.SubElement(root, "part-list")
sp = ET.SubElement(part_list, "score-part", id="P1")
ET.SubElement(sp, "part-name").text = "Piano"
si = ET.SubElement(sp, "score-instrument", id="P1-I1")
ET.SubElement(si, "instrument-name").text = "Piano"
# Part with measures
part = ET.SubElement(root, "part", id="P1")
for m in range(num_measures):
measure = ET.SubElement(part, "measure", number=str(m + 1))
if m == 0:
attrs = ET.SubElement(measure, "attributes")
key = ET.SubElement(attrs, "key")
ET.SubElement(key, "fifths").text = str(fifths)
time = ET.SubElement(attrs, "time")
ET.SubElement(time, "beats").text = beats
ET.SubElement(time, "beat-type").text = beat_type
for n in range(num_notes // num_measures):
note = ET.SubElement(measure, "note")
pitch = ET.SubElement(note, "pitch")
ET.SubElement(pitch, "step").text = "C"
ET.SubElement(pitch, "octave").text = "4"
return ET.ElementTree(root)
def test_get_key_signature(self):
tree = self._make_musicxml(fifths=-5)
assert get_key_signature(tree) == -5
def test_get_time_signature(self):
tree = self._make_musicxml(beats="3", beat_type="4")
assert get_time_signature(tree) == "3/4"
def test_get_instruments(self):
tree = self._make_musicxml()
instruments = get_instruments(tree)
assert len(instruments) == 1
assert instruments[0]["name"] == "Piano"
def test_get_score_title(self):
tree = self._make_musicxml(title="My Score")
assert get_score_title(tree) == "My Score"
def test_count_measures(self):
tree = self._make_musicxml(num_measures=8)
assert count_measures(tree) == 8
def test_count_notes(self):
tree = self._make_musicxml(num_measures=4, num_notes=16)
assert count_notes(tree) == 16
def test_detect_format(self):
assert detect_format("score.mscz") == "mscz"
assert detect_format("score.mxl") == "mxl"
assert detect_format("score.musicxml") == "musicxml"
assert detect_format("score.mid") == "mid"
assert detect_format("score.txt") == "unknown"
def test_mscz_roundtrip(self):
"""Test writing and reading a .mscz file."""
tree = self._make_musicxml()
data = {
"mscx": tree,
"mscx_filename": "score.mscx",
"style": "<Style></Style>",
"audio_settings": '{"master_gain": 1.0}',
"view_settings": '{"zoom": 100}',
"other_files": {},
}
with tempfile.NamedTemporaryFile(suffix=".mscz", delete=False) as f:
tmp_path = f.name
try:
write_mscz(tmp_path, data)
assert os.path.isfile(tmp_path)
# Verify it's a valid ZIP
assert zipfile.is_zipfile(tmp_path)
# Read back
read_data = read_mscz(tmp_path)
assert read_data["mscx"] is not None
assert read_data["style"] == "<Style></Style>"
assert get_key_signature(read_data["mscx"]) == -5
finally:
os.unlink(tmp_path)
# ── Export Verification Tests ─────────────────────────────────────────
from cli_anything.musescore.core.export import verify_output, _ext_to_format
class TestExportVerification:
def test_ext_to_format(self):
assert _ext_to_format(".pdf") == "pdf"
assert _ext_to_format(".mid") == "midi"
assert _ext_to_format(".mp3") == "mp3"
assert _ext_to_format(".musicxml") == "musicxml"
def test_verify_nonexistent(self):
result = verify_output("/nonexistent/file.pdf")
assert not result["exists"]
assert not result["valid"]
def test_verify_pdf(self):
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f:
f.write(b"%PDF-1.4 test content")
tmp = f.name
try:
result = verify_output(tmp, "pdf")
assert result["exists"]
assert result["valid"]
finally:
os.unlink(tmp)
def test_verify_midi(self):
with tempfile.NamedTemporaryFile(suffix=".mid", delete=False) as f:
f.write(b"MThd\x00\x00\x00\x06")
tmp = f.name
try:
result = verify_output(tmp, "midi")
assert result["exists"]
assert result["valid"]
finally:
os.unlink(tmp)
def test_verify_mp3_sync(self):
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
f.write(b"\xff\xfb\x90\x00" + b"\x00" * 100)
tmp = f.name
try:
result = verify_output(tmp, "mp3")
assert result["valid"]
finally:
os.unlink(tmp)
def test_verify_mp3_id3(self):
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
f.write(b"ID3" + b"\x00" * 100)
tmp = f.name
try:
result = verify_output(tmp, "mp3")
assert result["valid"]
finally:
os.unlink(tmp)
def test_verify_png(self):
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
f.write(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)
tmp = f.name
try:
result = verify_output(tmp, "png")
assert result["valid"]
finally:
os.unlink(tmp)
def test_verify_empty_file(self):
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f:
tmp = f.name
try:
result = verify_output(tmp, "pdf")
assert result["exists"]
assert not result["valid"]
finally:
os.unlink(tmp)
# ── Media Stats Tests (synthetic) ─────────────────────────────────────
class TestMediaStats:
def test_score_stats_from_mxl(self):
"""Create a synthetic .mxl and test stats extraction."""
tree = TestXMLParsing()._make_musicxml(
fifths=0, num_measures=8, num_notes=32, title="Stats Test"
)
xml_str = ET.tostring(tree.getroot(), encoding="unicode",
xml_declaration=True)
with tempfile.NamedTemporaryFile(suffix=".mxl", delete=False) as f:
tmp_path = f.name
try:
with zipfile.ZipFile(tmp_path, "w") as zf:
zf.writestr("score.xml", xml_str)
from cli_anything.musescore.core.media import score_stats
result = score_stats(tmp_path)
assert result["format"] == "mxl"
assert result["stats"]["measures"] == 8
assert result["stats"]["notes"] == 32
assert result["stats"]["title"] == "Stats Test"
assert result["stats"]["key_signature"] == 0
assert result["stats"]["key_name"] == "C major"
finally:
os.unlink(tmp_path)
@@ -0,0 +1,364 @@
"""End-to-end tests for cli-anything-musescore.
These tests require a real MuseScore 4 (mscore) installation and
use the sample files in test-mscore/sia-snowman/.
Run with: pytest cli_anything/musescore/tests/test_full_e2e.py -v -s
"""
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
import pytest
# ── Test fixture paths ────────────────────────────────────────────────
# Walk up from this file to find the test-mscore directory
_THIS_DIR = Path(__file__).resolve().parent
_REPO_ROOT = _THIS_DIR.parent.parent.parent.parent.parent
_SAMPLE_DIR = _REPO_ROOT / "test-mscore" / "sia-snowman"
_SAMPLE_MXL = _SAMPLE_DIR / "sia-snowman-Db.mxl"
_SAMPLE_MSCZ = _SAMPLE_DIR / "sia-snowman-C.mscz"
_SAMPLE_MID = _SAMPLE_DIR / "sia-snowman-Db.mid"
def _resolve_cli(name: str) -> list[str]:
"""Resolve the CLI command for subprocess tests.
If CLI_ANYTHING_FORCE_INSTALLED is set, use the installed command.
Otherwise, use python -m.
"""
if os.environ.get("CLI_ANYTHING_FORCE_INSTALLED"):
import shutil
path = shutil.which(name)
if path:
return [path]
raise RuntimeError(f"{name} not found on PATH")
return [sys.executable, "-m", "cli_anything.musescore"]
def _has_mscore() -> bool:
"""Check if mscore is available."""
try:
from cli_anything.musescore.utils.musescore_backend import find_musescore
find_musescore()
return True
except RuntimeError:
return False
def _has_samples() -> bool:
"""Check if sample files exist."""
return _SAMPLE_MXL.is_file()
requires_mscore = pytest.mark.skipif(
not _has_mscore(), reason="mscore not installed"
)
requires_samples = pytest.mark.skipif(
not _has_samples(), reason="sample files not found"
)
# ── Export E2E Tests ──────────────────────────────────────────────────
@requires_mscore
@requires_samples
class TestExportE2E:
def test_export_pdf(self):
"""Export MXL to PDF, verify magic bytes."""
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f:
out = f.name
try:
from cli_anything.musescore.core.export import export_score, verify_output
export_score(str(_SAMPLE_MXL), out, fmt="pdf")
result = verify_output(out, "pdf")
assert result["valid"], f"PDF verification failed: {result}"
print(f" PDF output: {out} ({result['size']} bytes)")
finally:
if os.path.exists(out):
os.unlink(out)
def test_export_midi(self):
"""Export MXL to MIDI, verify magic bytes."""
with tempfile.NamedTemporaryFile(suffix=".mid", delete=False) as f:
out = f.name
try:
from cli_anything.musescore.core.export import export_score, verify_output
export_score(str(_SAMPLE_MXL), out, fmt="midi")
result = verify_output(out, "midi")
assert result["valid"], f"MIDI verification failed: {result}"
print(f" MIDI output: {out} ({result['size']} bytes)")
finally:
if os.path.exists(out):
os.unlink(out)
def test_export_mp3(self):
"""Export MXL to MP3, verify magic bytes."""
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
out = f.name
try:
from cli_anything.musescore.core.export import export_score, verify_output
export_score(str(_SAMPLE_MXL), out, fmt="mp3", bitrate=128)
result = verify_output(out, "mp3")
assert result["valid"], f"MP3 verification failed: {result}"
print(f" MP3 output: {out} ({result['size']} bytes)")
finally:
if os.path.exists(out):
os.unlink(out)
def test_export_musicxml(self):
"""Export MSCZ to MusicXML, verify XML structure."""
with tempfile.NamedTemporaryFile(suffix=".musicxml", delete=False) as f:
out = f.name
try:
from cli_anything.musescore.core.export import export_score
export_score(str(_SAMPLE_MSCZ), out, fmt="musicxml")
import xml.etree.ElementTree as ET
tree = ET.parse(out)
assert tree.getroot().tag == "score-partwise"
print(f" MusicXML output: {out}")
finally:
if os.path.exists(out):
os.unlink(out)
def test_export_png(self):
"""Export MXL to PNG, verify at least one page produced."""
with tempfile.TemporaryDirectory() as tmpdir:
out = os.path.join(tmpdir, "output.png")
from cli_anything.musescore.core.export import export_score
export_score(str(_SAMPLE_MXL), out, fmt="png", dpi=72)
# mscore produces output-1.png, output-2.png, etc.
pngs = list(Path(tmpdir).glob("*.png"))
assert len(pngs) >= 1, f"No PNG files produced in {tmpdir}"
# Verify first PNG magic bytes
with open(pngs[0], "rb") as f:
header = f.read(4)
assert header == b"\x89PNG", f"Invalid PNG header: {header}"
print(f" PNG pages: {len(pngs)}")
# ── Transpose E2E Tests ──────────────────────────────────────────────
@requires_mscore
@requires_samples
class TestTransposeE2E:
def test_transpose_db_to_c(self):
"""Transpose Db major MXL to C major, verify key signature."""
with tempfile.NamedTemporaryFile(suffix=".mscz", delete=False) as f:
out = f.name
try:
from cli_anything.musescore.core.transpose import transpose_by_key
result = transpose_by_key(
str(_SAMPLE_MXL), out,
target_key="C major",
direction="closest",
)
assert result["target_key_int"] == 0
# Export to MusicXML and check key signature
with tempfile.NamedTemporaryFile(suffix=".musicxml", delete=False) as fx:
xml_out = fx.name
from cli_anything.musescore.core.export import export_score
export_score(out, xml_out, fmt="musicxml")
from cli_anything.musescore.utils.mscx_xml import read_score_tree, get_key_signature
tree = read_score_tree(xml_out)
keysig = get_key_signature(tree)
assert keysig == 0, f"Expected keysig=0 (C major), got {keysig}"
print(f" Transposed Db→C: keysig={keysig}")
os.unlink(xml_out)
finally:
if os.path.exists(out):
os.unlink(out)
def test_transpose_by_interval(self):
"""Transpose by 2 semitones (major second up)."""
with tempfile.NamedTemporaryFile(suffix=".mscz", delete=False) as f:
out = f.name
try:
from cli_anything.musescore.core.transpose import transpose_by_interval
result = transpose_by_interval(
str(_SAMPLE_MXL), out,
semitones=2,
direction="up",
)
assert result["mode"] == "by_interval"
assert os.path.isfile(out)
print(f" Transposed by +2 semitones: {out}")
finally:
if os.path.exists(out):
os.unlink(out)
# ── Parts E2E Tests ──────────────────────────────────────────────────
@requires_mscore
@requires_samples
class TestPartsE2E:
def test_list_parts(self):
"""List parts in the sample score."""
from cli_anything.musescore.core.parts import list_parts
parts = list_parts(str(_SAMPLE_MXL))
assert len(parts) >= 1
print(f" Parts: {[p['name'] for p in parts]}")
def test_extract_part(self):
"""Extract the first part."""
from cli_anything.musescore.core.parts import list_parts, extract_part
parts = list_parts(str(_SAMPLE_MXL))
if not parts:
pytest.skip("No parts found")
first_part = parts[0]["name"]
with tempfile.NamedTemporaryFile(suffix=".mscz", delete=False) as f:
out = f.name
try:
result = extract_part(str(_SAMPLE_MXL), first_part, out)
assert os.path.isfile(out)
assert result["size_bytes"] > 0
print(f" Extracted '{first_part}': {result['size_bytes']} bytes")
finally:
if os.path.exists(out):
os.unlink(out)
# ── Media E2E Tests ──────────────────────────────────────────────────
@requires_mscore
@requires_samples
class TestMediaE2E:
def test_probe_mxl(self):
"""Probe sample MXL file."""
from cli_anything.musescore.core.media import probe_score
result = probe_score(str(_SAMPLE_MXL))
assert result["format"] == "mxl"
assert "metadata" in result
print(f" Probe: {json.dumps(result.get('metadata', {}), indent=2, default=str)[:200]}")
def test_probe_mscz(self):
"""Probe sample MSCZ file."""
from cli_anything.musescore.core.media import probe_score
result = probe_score(str(_SAMPLE_MSCZ))
assert result["format"] == "mscz"
assert "metadata" in result
def test_stats_mxl(self):
"""Get stats for sample MXL file."""
from cli_anything.musescore.core.media import score_stats
result = score_stats(str(_SAMPLE_MXL))
assert "stats" in result
assert result["stats"]["measures"] > 0
assert result["stats"]["notes"] > 0
print(f" Stats: {result['stats']}")
# ── CLI Subprocess Tests ─────────────────────────────────────────────
class TestCLISubprocess:
def test_help(self):
"""Test --help flag."""
cmd = _resolve_cli("cli-anything-musescore") + ["--help"]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
assert result.returncode == 0
assert "MuseScore CLI" in result.stdout or "musescore" in result.stdout.lower()
print(f" --help: OK ({len(result.stdout)} chars)")
@requires_mscore
@requires_samples
def test_json_project_info(self):
"""Test --json project info via subprocess."""
cmd = _resolve_cli("cli-anything-musescore") + [
"--json", "project", "info", "-i", str(_SAMPLE_MXL)
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
assert result.returncode == 0
data = json.loads(result.stdout)
assert "metadata" in data or "path" in data
print(f" JSON project info: OK")
@requires_mscore
@requires_samples
def test_json_export_pdf(self):
"""Test --json export pdf via subprocess."""
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f:
out = f.name
try:
cmd = _resolve_cli("cli-anything-musescore") + [
"--json", "export", "pdf",
"-i", str(_SAMPLE_MXL),
"-o", out,
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
assert result.returncode == 0
data = json.loads(result.stdout)
assert data.get("format") == "pdf"
# Verify the output file
assert os.path.isfile(out)
with open(out, "rb") as f:
header = f.read(5)
assert header == b"%PDF-"
print(f" JSON export pdf: OK")
finally:
if os.path.exists(out):
os.unlink(out)
@requires_mscore
@requires_samples
def test_json_transpose_by_key(self):
"""Test --json transpose by-key via subprocess."""
with tempfile.NamedTemporaryFile(suffix=".mscz", delete=False) as f:
out = f.name
try:
cmd = _resolve_cli("cli-anything-musescore") + [
"--json", "transpose", "by-key",
"-i", str(_SAMPLE_MXL),
"-o", out,
"--target-key", "C major",
"--direction", "closest",
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
assert result.returncode == 0
data = json.loads(result.stdout)
assert data.get("target_key_int") == 0
print(f" JSON transpose by-key: OK")
finally:
if os.path.exists(out):
os.unlink(out)
@requires_mscore
@requires_samples
def test_full_workflow(self):
"""Test a full workflow: info → transpose → export PDF → verify."""
with tempfile.TemporaryDirectory() as tmpdir:
transposed = os.path.join(tmpdir, "transposed.mscz")
pdf_out = os.path.join(tmpdir, "output.pdf")
# 1. Transpose Db → C
cmd = _resolve_cli("cli-anything-musescore") + [
"--json", "transpose", "by-key",
"-i", str(_SAMPLE_MXL),
"-o", transposed,
"--target-key", "C major",
]
r = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
assert r.returncode == 0, f"Transpose failed: {r.stderr}"
# 2. Export to PDF
cmd = _resolve_cli("cli-anything-musescore") + [
"--json", "export", "pdf",
"-i", transposed,
"-o", pdf_out,
]
r = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
assert r.returncode == 0, f"Export failed: {r.stderr}"
# 3. Verify PDF
with open(pdf_out, "rb") as f:
assert f.read(5) == b"%PDF-"
print(f" Full workflow: transpose → export → verify: OK")
@@ -0,0 +1,349 @@
"""MSCX/MusicXML parsing utilities.
Handles reading and writing .mscz (ZIP containing .mscx XML) and
.mxl (ZIP containing MusicXML) files, plus XML inspection helpers.
"""
import os
import xml.etree.ElementTree as ET
import zipfile
from pathlib import Path
# ── Key Signature Mapping ─────────────────────────────────────────────
# Integer key signatures: -7 (Cb) to +7 (C#)
# Negative = flats, positive = sharps, 0 = C major / A minor
KEY_INT_TO_MAJOR = {
-7: "Cb", -6: "Gb", -5: "Db", -4: "Ab", -3: "Eb", -2: "Bb", -1: "F",
0: "C", 1: "G", 2: "D", 3: "A", 4: "E", 5: "B", 6: "F#", 7: "C#",
}
KEY_INT_TO_MINOR = {
-7: "Ab", -6: "Eb", -5: "Bb", -4: "F", -3: "C", -2: "G", -1: "D",
0: "A", 1: "E", 2: "B", 3: "F#", 4: "C#", 5: "G#", 6: "D#", 7: "A#",
}
# Reverse: name → integer (case-insensitive, supports "C major", "C", "Cm", "C minor")
_KEY_NAME_TO_INT: dict[str, int] = {}
for _i, _name in KEY_INT_TO_MAJOR.items():
_KEY_NAME_TO_INT[_name.lower()] = _i
_KEY_NAME_TO_INT[f"{_name.lower()} major"] = _i
_KEY_NAME_TO_INT[f"{_name.lower()}maj"] = _i
for _i, _name in KEY_INT_TO_MINOR.items():
_KEY_NAME_TO_INT[f"{_name.lower()} minor"] = _i
_KEY_NAME_TO_INT[f"{_name.lower()}m"] = _i
_KEY_NAME_TO_INT[f"{_name.lower()}min"] = _i
def key_name_to_int(name: str) -> int:
"""Convert a key name to its integer representation.
Accepts: "C", "C major", "Db", "Db major", "A minor", "Am", etc.
Raises:
ValueError: If the key name is not recognized.
"""
normalized = name.strip().lower()
if normalized in _KEY_NAME_TO_INT:
return _KEY_NAME_TO_INT[normalized]
raise ValueError(
f"Unrecognized key name: '{name}'. "
f"Examples: C, Db major, F# minor, Bb, Am"
)
def key_int_to_name(key_int: int, minor: bool = False) -> str:
"""Convert a key integer to its name."""
table = KEY_INT_TO_MINOR if minor else KEY_INT_TO_MAJOR
if key_int not in table:
raise ValueError(f"Invalid key integer: {key_int}. Must be -7 to 7.")
suffix = " minor" if minor else " major"
return table[key_int] + suffix
# ── MSCZ (MuseScore ZIP) I/O ─────────────────────────────────────────
def read_mscz(path: str) -> dict:
"""Read a .mscz file (ZIP archive).
Returns:
Dict with keys:
- "mscx": ElementTree of the .mscx XML
- "mscx_filename": name of the .mscx file inside the ZIP
- "style": content of score_style.mss (str or None)
- "audio_settings": content of audiosettings.json (str or None)
- "view_settings": content of viewsettings.json (str or None)
- "other_files": dict of other filename → bytes
"""
result = {
"mscx": None,
"mscx_filename": None,
"style": None,
"audio_settings": None,
"view_settings": None,
"other_files": {},
}
with zipfile.ZipFile(path, "r") as zf:
for name in zf.namelist():
if name.endswith(".mscx"):
result["mscx_filename"] = name
xml_bytes = zf.read(name)
result["mscx"] = ET.ElementTree(ET.fromstring(xml_bytes))
elif name == "score_style.mss" or name.endswith("/score_style.mss"):
result["style"] = zf.read(name).decode("utf-8")
elif name == "audiosettings.json" or name.endswith("/audiosettings.json"):
result["audio_settings"] = zf.read(name).decode("utf-8")
elif name == "viewsettings.json" or name.endswith("/viewsettings.json"):
result["view_settings"] = zf.read(name).decode("utf-8")
else:
result["other_files"][name] = zf.read(name)
if result["mscx"] is None:
raise ValueError(f"No .mscx file found inside {path}")
return result
def write_mscz(path: str, data: dict) -> Path:
"""Write a .mscz file from component data.
Args:
path: Output .mscz path.
data: Dict as returned by read_mscz().
Returns:
Path to the written file.
"""
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
# Write the .mscx XML
mscx_filename = data.get("mscx_filename", "score.mscx")
xml_str = ET.tostring(data["mscx"].getroot(), encoding="unicode",
xml_declaration=True)
zf.writestr(mscx_filename, xml_str)
# Write style
if data.get("style"):
zf.writestr("score_style.mss", data["style"])
# Write settings
if data.get("audio_settings"):
zf.writestr("audiosettings.json", data["audio_settings"])
if data.get("view_settings"):
zf.writestr("viewsettings.json", data["view_settings"])
# Write other files
for name, content in data.get("other_files", {}).items():
zf.writestr(name, content)
return Path(path)
# ── MXL (MusicXML ZIP) I/O ───────────────────────────────────────────
def read_mxl(path: str) -> ET.ElementTree:
"""Read a .mxl file (compressed MusicXML).
Returns:
ElementTree of the MusicXML content.
"""
with zipfile.ZipFile(path, "r") as zf:
# Look for the MusicXML file
for name in zf.namelist():
if name.endswith(".xml") and not name.startswith("META-INF"):
xml_bytes = zf.read(name)
return ET.ElementTree(ET.fromstring(xml_bytes))
raise ValueError(f"No MusicXML file found inside {path}")
# ── XML Inspection Helpers ────────────────────────────────────────────
def get_key_signature(tree: ET.ElementTree) -> int | None:
"""Extract the first key signature from a MusicXML or MSCX tree.
Returns:
Integer key signature (-7 to 7), or None if not found.
"""
root = tree.getroot()
# MusicXML: <key><fifths>-5</fifths></key>
fifths = root.find(".//{*}fifths")
if fifths is not None and fifths.text:
return int(fifths.text)
# MSCX: <KeySig><accidental>-5</accidental></KeySig>
# or <KeySig><concertKey>-5</concertKey></KeySig>
for tag in ["accidental", "concertKey"]:
elem = root.find(f".//KeySig/{tag}")
if elem is not None and elem.text:
return int(elem.text)
return None
def get_time_signature(tree: ET.ElementTree) -> str | None:
"""Extract the first time signature.
Returns:
String like "4/4", "3/4", etc., or None.
"""
root = tree.getroot()
# MusicXML: <time><beats>4</beats><beat-type>4</beat-type></time>
beats = root.find(".//{*}beats")
beat_type = root.find(".//{*}beat-type")
if beats is not None and beat_type is not None:
return f"{beats.text}/{beat_type.text}"
# MSCX: <TimeSig><sigN>4</sigN><sigD>4</sigD></TimeSig>
sig_n = root.find(".//TimeSig/sigN")
sig_d = root.find(".//TimeSig/sigD")
if sig_n is not None and sig_d is not None:
return f"{sig_n.text}/{sig_d.text}"
return None
def get_instruments(tree: ET.ElementTree) -> list[dict]:
"""Extract instrument info from a MusicXML or MSCX tree.
Returns:
List of dicts with 'id', 'name', 'part_name' keys.
"""
root = tree.getroot()
instruments = []
# MusicXML: <score-part id="P1"><part-name>Piano</part-name>
# <score-instrument id="P1-I1"><instrument-name>Piano</instrument-name>
for sp in root.findall(".//{*}score-part"):
inst = {"id": sp.get("id", ""), "name": "", "part_name": ""}
pn = sp.find("{*}part-name")
if pn is None:
pn = sp.find("part-name")
if pn is not None:
inst["part_name"] = pn.text or ""
sin = sp.find(".//{*}instrument-name")
if sin is not None:
inst["name"] = sin.text or ""
else:
inst["name"] = inst["part_name"]
instruments.append(inst)
if instruments:
return instruments
# MSCX: <Part><Instrument id="keyboard.piano"><longName>Piano</longName>
for part in root.findall(".//Part"):
inst_elem = part.find("Instrument")
if inst_elem is not None:
inst = {
"id": inst_elem.get("id", ""),
"name": "",
"part_name": "",
}
ln = inst_elem.find("longName")
if ln is not None:
inst["name"] = ln.text or ""
sn = inst_elem.find("shortName")
if sn is not None:
inst["part_name"] = sn.text or inst["name"]
else:
inst["part_name"] = inst["name"]
instruments.append(inst)
return instruments
def get_score_title(tree: ET.ElementTree) -> str:
"""Extract score title from XML."""
root = tree.getroot()
# MusicXML: <work><work-title>...</work-title></work>
# or <movement-title>...</movement-title>
wt = root.find(".//{*}work-title")
if wt is not None and wt.text:
return wt.text
mt = root.find(".//{*}movement-title")
if mt is not None and mt.text:
return mt.text
# MSCX: <metaTag name="workTitle">...</metaTag>
for meta in root.findall(".//metaTag"):
if meta.get("name") == "workTitle" and meta.text:
return meta.text
return ""
def count_measures(tree: ET.ElementTree) -> int:
"""Count the number of measures in a score."""
root = tree.getroot()
# MusicXML: count <measure> elements in the first part
measures = root.findall(".//{*}measure")
if measures:
# Each part has its own measures; count the first part's
first_part = root.find(".//{*}part")
if first_part is not None:
return len(first_part.findall("{*}measure"))
return len(measures)
# MSCX: count <Measure> elements in the first staff
mscx_measures = root.findall(".//Measure")
if mscx_measures:
first_staff = root.find(".//Staff")
if first_staff is not None:
return len(first_staff.findall("Measure"))
return len(mscx_measures)
return 0
def count_notes(tree: ET.ElementTree) -> int:
"""Count the number of notes in a score."""
root = tree.getroot()
# MusicXML
notes = root.findall(".//{*}note")
if notes:
return len(notes)
# MSCX
return len(root.findall(".//Note"))
def detect_format(path: str) -> str:
"""Detect score file format from extension.
Returns:
One of: "mscz", "mxl", "musicxml", "mid", "unknown"
"""
ext = Path(path).suffix.lower()
return {
".mscz": "mscz",
".mxl": "mxl",
".musicxml": "musicxml",
".xml": "musicxml",
".mid": "mid",
".midi": "mid",
}.get(ext, "unknown")
def read_score_tree(path: str) -> ET.ElementTree:
"""Read a score file and return its XML tree.
Supports .mscz, .mxl, .musicxml, .xml formats.
"""
fmt = detect_format(path)
if fmt == "mscz":
data = read_mscz(path)
return data["mscx"]
elif fmt == "mxl":
return read_mxl(path)
elif fmt == "musicxml":
return ET.parse(path)
else:
raise ValueError(f"Cannot read XML tree from format: {fmt} ({path})")
@@ -0,0 +1,277 @@
"""Backend interface for MuseScore 4 CLI (mscore).
Finds the mscore binary and provides Python wrappers for all CLI operations:
export, transpose, metadata, parts, media, diff, batch jobs.
"""
import json
import os
import platform
import shutil
import subprocess
import tempfile
from pathlib import Path
def find_musescore() -> str:
"""Locate the mscore executable.
Search order:
1. MUSESCORE_PATH environment variable
2. shutil.which("mscore")
3. macOS app bundle: /Applications/MuseScore 4.app/Contents/MacOS/mscore
4. Common Linux paths: /usr/bin/mscore4, /usr/local/bin/mscore4
5. Windows: C:\\Program Files\\MuseScore 4\\bin\\MuseScore4.exe
Returns:
Absolute path to the mscore binary.
Raises:
RuntimeError: If mscore cannot be found.
"""
# 1. Environment variable override
env_path = os.environ.get("MUSESCORE_PATH")
if env_path and os.path.isfile(env_path):
return env_path
# 2. On PATH
which = shutil.which("mscore")
if which:
return which
# 3. Platform-specific paths
system = platform.system()
candidates = []
if system == "Darwin":
candidates = [
"/Applications/MuseScore 4.app/Contents/MacOS/mscore",
os.path.expanduser("~/Applications/MuseScore 4.app/Contents/MacOS/mscore"),
]
elif system == "Linux":
candidates = [
"/usr/bin/mscore4",
"/usr/local/bin/mscore4",
"/usr/bin/mscore",
"/usr/local/bin/mscore",
"/snap/musescore/current/bin/mscore4",
]
elif system == "Windows":
candidates = [
r"C:\Program Files\MuseScore 4\bin\MuseScore4.exe",
r"C:\Program Files (x86)\MuseScore 4\bin\MuseScore4.exe",
]
for path in candidates:
if os.path.isfile(path):
return path
raise RuntimeError(
"MuseScore 4 (mscore) not found.\n\n"
"Install MuseScore 4 from https://musescore.org/en/download\n\n"
"Or set the MUSESCORE_PATH environment variable:\n"
" export MUSESCORE_PATH=/path/to/mscore\n\n"
"Expected locations:\n"
" macOS: /Applications/MuseScore 4.app/Contents/MacOS/mscore\n"
" Linux: /usr/bin/mscore4\n"
" Windows: C:\\Program Files\\MuseScore 4\\bin\\MuseScore4.exe"
)
def _filter_qt_noise(stderr: str) -> str:
"""Filter harmless Qt/QML warnings from mscore stderr."""
if not stderr:
return ""
lines = []
for line in stderr.splitlines():
# Skip known Qt noise
if any(pat in line for pat in [
"qt.qml.typeregistration",
"QML",
"Qt WebEngine",
"Fontconfig",
"MESA-LOADER",
"libpng warning",
"IMKClient",
"IMKInputSession",
]):
continue
if line.strip():
lines.append(line)
return "\n".join(lines)
def run_mscore(args: list[str], capture_stdout: bool = True,
timeout: int = 120) -> subprocess.CompletedProcess:
"""Run mscore with the given arguments.
Args:
args: Command-line arguments (not including the mscore binary itself).
capture_stdout: Whether to capture stdout.
timeout: Timeout in seconds.
Returns:
CompletedProcess result.
Raises:
RuntimeError: If mscore exits with a non-zero code.
"""
mscore = find_musescore()
cmd = [mscore] + args
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
)
filtered_stderr = _filter_qt_noise(result.stderr)
if result.returncode != 0:
raise RuntimeError(
f"mscore exited with code {result.returncode}\n"
f"Command: {' '.join(cmd)}\n"
f"stderr: {filtered_stderr or result.stderr}"
)
result.stderr = filtered_stderr
return result
def export_score(input_path: str, output_path: str, *,
dpi: int | None = None,
bitrate: int | None = None,
trim: int | None = None,
style: str | None = None,
sound_profile: str | None = None,
export_parts: bool = False) -> Path:
"""Export a score to the specified format via mscore -o.
Format is inferred from the output file extension.
Args:
input_path: Path to input score (.mscz, .mxl, .mid).
output_path: Path to output file (extension determines format).
dpi: PNG resolution in DPI.
bitrate: MP3 bitrate in kbps.
trim: PNG/SVG whitespace trim margin.
style: Path to .mss style file to apply.
sound_profile: Audio profile ("MuseScore Basic" or "Muse Sounds").
export_parts: Whether to append parts to PDF export.
Returns:
Path to the output file.
"""
args = []
if style:
args.extend(["-S", style])
if dpi is not None:
args.extend(["-r", str(dpi)])
if bitrate is not None:
args.extend(["-b", str(bitrate)])
if trim is not None:
args.extend(["-T", str(trim)])
if sound_profile:
args.extend(["--sound-profile", sound_profile])
if export_parts:
args.append("-P")
args.extend(["-o", str(output_path), str(input_path)])
run_mscore(args)
return Path(output_path)
def transpose_score(input_path: str, output_path: str,
transpose_opts: dict) -> Path:
"""Transpose a score and save the result.
Args:
input_path: Path to input score.
output_path: Path to output score.
transpose_opts: Transpose options dict with keys:
mode, direction, targetKey, transposeInterval,
transposeKeySignatures, transposeChordNames,
useDoubleSharpsFlats.
Returns:
Path to the output file.
"""
opts_json = json.dumps(transpose_opts)
args = ["--transpose", opts_json, "-o", str(output_path), str(input_path)]
run_mscore(args)
return Path(output_path)
def get_score_meta(input_path: str) -> dict:
"""Get score metadata via --score-meta.
Returns parsed JSON with title, composer, keysig, timesig,
tempo, duration, measures, pages, parts, etc.
The raw output wraps everything in a "metadata" key; we unwrap it.
"""
result = run_mscore(["--score-meta", str(input_path)])
data = json.loads(result.stdout)
# Unwrap the outer "metadata" envelope if present
if "metadata" in data and isinstance(data["metadata"], dict):
return data["metadata"]
return data
def get_score_parts(input_path: str) -> dict:
"""Get score parts via --score-parts.
Returns parsed JSON with part names and base64-encoded .mscz data.
"""
result = run_mscore(["--score-parts", str(input_path)])
return json.loads(result.stdout)
def get_score_media(input_path: str) -> dict:
"""Get all score media via --score-media.
Returns parsed JSON with pngs, svgs, pdf, midi, mxml, metadata, etc.
"""
result = run_mscore(["--score-media", str(input_path)])
return json.loads(result.stdout)
def diff_scores(file_a: str, file_b: str, raw: bool = False) -> dict:
"""Diff two scores.
Args:
file_a: Path to first score.
file_b: Path to second score.
raw: If True, use --raw-diff instead of --diff.
Returns:
Parsed JSON diff result.
"""
flag = "--raw-diff" if raw else "--diff"
result = run_mscore([flag, str(file_a), str(file_b)])
return json.loads(result.stdout)
def batch_convert(job_list: list[dict]) -> list[Path]:
"""Run batch conversion via mscore -j.
Args:
job_list: List of dicts with "in" and "out" keys.
Returns:
List of output paths.
"""
with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False
) as f:
json.dump(job_list, f)
job_file = f.name
try:
run_mscore(["-j", job_file])
finally:
os.unlink(job_file)
return [Path(job["out"]) for job in job_list]
@@ -0,0 +1,498 @@
"""cli-anything REPL Skin — Unified terminal interface for all CLI harnesses.
Copy this file into your CLI package at:
cli_anything/<software>/utils/repl_skin.py
Usage:
from cli_anything.<software>.utils.repl_skin import ReplSkin
skin = ReplSkin("shotcut", version="1.0.0")
skin.print_banner()
prompt_text = skin.prompt(project_name="my_video.mlt", modified=True)
skin.success("Project saved")
skin.error("File not found")
skin.warning("Unsaved changes")
skin.info("Processing 24 clips...")
skin.status("Track 1", "3 clips, 00:02:30")
skin.table(headers, rows)
skin.print_goodbye()
"""
import os
import sys
# ── ANSI color codes (no external deps for core styling) ──────────────
_RESET = "\033[0m"
_BOLD = "\033[1m"
_DIM = "\033[2m"
_ITALIC = "\033[3m"
_UNDERLINE = "\033[4m"
# Brand colors
_CYAN = "\033[38;5;80m" # cli-anything brand cyan
_CYAN_BG = "\033[48;5;80m"
_WHITE = "\033[97m"
_GRAY = "\033[38;5;245m"
_DARK_GRAY = "\033[38;5;240m"
_LIGHT_GRAY = "\033[38;5;250m"
# Software accent colors — each software gets a unique accent
_ACCENT_COLORS = {
"gimp": "\033[38;5;214m", # warm orange
"blender": "\033[38;5;208m", # deep orange
"inkscape": "\033[38;5;39m", # bright blue
"audacity": "\033[38;5;33m", # navy blue
"libreoffice": "\033[38;5;40m", # green
"obs_studio": "\033[38;5;55m", # purple
"kdenlive": "\033[38;5;69m", # slate blue
"shotcut": "\033[38;5;35m", # teal green
}
_DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue
# Status colors
_GREEN = "\033[38;5;78m"
_YELLOW = "\033[38;5;220m"
_RED = "\033[38;5;196m"
_BLUE = "\033[38;5;75m"
_MAGENTA = "\033[38;5;176m"
# ── Brand icon ────────────────────────────────────────────────────────
# The cli-anything icon: a small colored diamond/chevron mark
_ICON = f"{_CYAN}{_BOLD}{_RESET}"
_ICON_SMALL = f"{_CYAN}{_RESET}"
# ── Box drawing characters ────────────────────────────────────────────
_H_LINE = ""
_V_LINE = ""
_TL = ""
_TR = ""
_BL = ""
_BR = ""
_T_DOWN = ""
_T_UP = ""
_T_RIGHT = ""
_T_LEFT = ""
_CROSS = ""
def _strip_ansi(text: str) -> str:
"""Remove ANSI escape codes for length calculation."""
import re
return re.sub(r"\033\[[^m]*m", "", text)
def _visible_len(text: str) -> int:
"""Get visible length of text (excluding ANSI codes)."""
return len(_strip_ansi(text))
class ReplSkin:
"""Unified REPL skin for cli-anything CLIs.
Provides consistent branding, prompts, and message formatting
across all CLI harnesses built with the cli-anything methodology.
"""
def __init__(self, software: str, version: str = "1.0.0",
history_file: str | None = None):
"""Initialize the REPL skin.
Args:
software: Software name (e.g., "gimp", "shotcut", "blender").
version: CLI version string.
history_file: Path for persistent command history.
Defaults to ~/.cli-anything-<software>/history
"""
self.software = software.lower().replace("-", "_")
self.display_name = software.replace("_", " ").title()
self.version = version
self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT)
# History file
if history_file is None:
from pathlib import Path
hist_dir = Path.home() / f".cli-anything-{self.software}"
hist_dir.mkdir(parents=True, exist_ok=True)
self.history_file = str(hist_dir / "history")
else:
self.history_file = history_file
# Detect terminal capabilities
self._color = self._detect_color_support()
def _detect_color_support(self) -> bool:
"""Check if terminal supports color."""
if os.environ.get("NO_COLOR"):
return False
if os.environ.get("CLI_ANYTHING_NO_COLOR"):
return False
if not hasattr(sys.stdout, "isatty"):
return False
return sys.stdout.isatty()
def _c(self, code: str, text: str) -> str:
"""Apply color code if colors are supported."""
if not self._color:
return text
return f"{code}{text}{_RESET}"
# ── Banner ────────────────────────────────────────────────────────
def print_banner(self):
"""Print the startup banner with branding."""
inner = 54
def _box_line(content: str) -> str:
"""Wrap content in box drawing, padding to inner width."""
pad = inner - _visible_len(content)
vl = self._c(_DARK_GRAY, _V_LINE)
return f"{vl}{content}{' ' * max(0, pad)}{vl}"
top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}")
bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}")
# Title: ◆ cli-anything · Shotcut
icon = self._c(_CYAN + _BOLD, "")
brand = self._c(_CYAN + _BOLD, "cli-anything")
dot = self._c(_DARK_GRAY, "·")
name = self._c(self.accent + _BOLD, self.display_name)
title = f" {icon} {brand} {dot} {name}"
ver = f" {self._c(_DARK_GRAY, f' v{self.version}')}"
tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}"
empty = ""
print(top)
print(_box_line(title))
print(_box_line(ver))
print(_box_line(empty))
print(_box_line(tip))
print(bot)
print()
# ── Prompt ────────────────────────────────────────────────────────
def prompt(self, project_name: str = "", modified: bool = False,
context: str = "") -> str:
"""Build a styled prompt string for prompt_toolkit or input().
Args:
project_name: Current project name (empty if none open).
modified: Whether the project has unsaved changes.
context: Optional extra context to show in prompt.
Returns:
Formatted prompt string.
"""
parts = []
# Icon
if self._color:
parts.append(f"{_CYAN}{_RESET} ")
else:
parts.append("> ")
# Software name
parts.append(self._c(self.accent + _BOLD, self.software))
# Project context
if project_name or context:
ctx = context or project_name
mod = "*" if modified else ""
parts.append(f" {self._c(_DARK_GRAY, '[')}")
parts.append(self._c(_LIGHT_GRAY, f"{ctx}{mod}"))
parts.append(self._c(_DARK_GRAY, ']'))
parts.append(self._c(_GRAY, " "))
return "".join(parts)
def prompt_tokens(self, project_name: str = "", modified: bool = False,
context: str = ""):
"""Build prompt_toolkit formatted text tokens for the prompt.
Use with prompt_toolkit's FormattedText for proper ANSI handling.
Returns:
list of (style, text) tuples for prompt_toolkit.
"""
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
tokens = []
tokens.append(("class:icon", ""))
tokens.append(("class:software", self.software))
if project_name or context:
ctx = context or project_name
mod = "*" if modified else ""
tokens.append(("class:bracket", " ["))
tokens.append(("class:context", f"{ctx}{mod}"))
tokens.append(("class:bracket", "]"))
tokens.append(("class:arrow", " "))
return tokens
def get_prompt_style(self):
"""Get a prompt_toolkit Style object matching the skin.
Returns:
prompt_toolkit.styles.Style
"""
try:
from prompt_toolkit.styles import Style
except ImportError:
return None
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
return Style.from_dict({
"icon": "#5fdfdf bold", # cyan brand color
"software": f"{accent_hex} bold",
"bracket": "#585858",
"context": "#bcbcbc",
"arrow": "#808080",
# Completion menu
"completion-menu.completion": "bg:#303030 #bcbcbc",
"completion-menu.completion.current": f"bg:{accent_hex} #000000",
"completion-menu.meta.completion": "bg:#303030 #808080",
"completion-menu.meta.completion.current": f"bg:{accent_hex} #000000",
# Auto-suggest
"auto-suggest": "#585858",
# Bottom toolbar
"bottom-toolbar": "bg:#1c1c1c #808080",
"bottom-toolbar.text": "#808080",
})
# ── Messages ──────────────────────────────────────────────────────
def success(self, message: str):
"""Print a success message with green checkmark."""
icon = self._c(_GREEN + _BOLD, "")
print(f" {icon} {self._c(_GREEN, message)}")
def error(self, message: str):
"""Print an error message with red cross."""
icon = self._c(_RED + _BOLD, "")
print(f" {icon} {self._c(_RED, message)}", file=sys.stderr)
def warning(self, message: str):
"""Print a warning message with yellow triangle."""
icon = self._c(_YELLOW + _BOLD, "")
print(f" {icon} {self._c(_YELLOW, message)}")
def info(self, message: str):
"""Print an info message with blue dot."""
icon = self._c(_BLUE, "")
print(f" {icon} {self._c(_LIGHT_GRAY, message)}")
def hint(self, message: str):
"""Print a subtle hint message."""
print(f" {self._c(_DARK_GRAY, message)}")
def section(self, title: str):
"""Print a section header."""
print()
print(f" {self._c(self.accent + _BOLD, title)}")
print(f" {self._c(_DARK_GRAY, _H_LINE * len(title))}")
# ── Status display ────────────────────────────────────────────────
def status(self, label: str, value: str):
"""Print a key-value status line."""
lbl = self._c(_GRAY, f" {label}:")
val = self._c(_WHITE, f" {value}")
print(f"{lbl}{val}")
def status_block(self, items: dict[str, str], title: str = ""):
"""Print a block of status key-value pairs.
Args:
items: Dict of label -> value pairs.
title: Optional title for the block.
"""
if title:
self.section(title)
max_key = max(len(k) for k in items) if items else 0
for label, value in items.items():
lbl = self._c(_GRAY, f" {label:<{max_key}}")
val = self._c(_WHITE, f" {value}")
print(f"{lbl}{val}")
def progress(self, current: int, total: int, label: str = ""):
"""Print a simple progress indicator.
Args:
current: Current step number.
total: Total number of steps.
label: Optional label for the progress.
"""
pct = int(current / total * 100) if total > 0 else 0
bar_width = 20
filled = int(bar_width * current / total) if total > 0 else 0
bar = "" * filled + "" * (bar_width - filled)
text = f" {self._c(_CYAN, bar)} {self._c(_GRAY, f'{pct:3d}%')}"
if label:
text += f" {self._c(_LIGHT_GRAY, label)}"
print(text)
# ── Table display ─────────────────────────────────────────────────
def table(self, headers: list[str], rows: list[list[str]],
max_col_width: int = 40):
"""Print a formatted table with box-drawing characters.
Args:
headers: Column header strings.
rows: List of rows, each a list of cell strings.
max_col_width: Maximum column width before truncation.
"""
if not headers:
return
# Calculate column widths
col_widths = [min(len(h), max_col_width) for h in headers]
for row in rows:
for i, cell in enumerate(row):
if i < len(col_widths):
col_widths[i] = min(
max(col_widths[i], len(str(cell))), max_col_width
)
def pad(text: str, width: int) -> str:
t = str(text)[:width]
return t + " " * (width - len(t))
# Header
header_cells = [
self._c(_CYAN + _BOLD, pad(h, col_widths[i]))
for i, h in enumerate(headers)
]
sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
header_line = f" {sep.join(header_cells)}"
print(header_line)
# Separator
sep_parts = [self._c(_DARK_GRAY, _H_LINE * w) for w in col_widths]
sep_line = self._c(_DARK_GRAY, f" {'───'.join([_H_LINE * w for w in col_widths])}")
print(sep_line)
# Rows
for row in rows:
cells = []
for i, cell in enumerate(row):
if i < len(col_widths):
cells.append(self._c(_LIGHT_GRAY, pad(str(cell), col_widths[i])))
row_sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
print(f" {row_sep.join(cells)}")
# ── Help display ──────────────────────────────────────────────────
def help(self, commands: dict[str, str]):
"""Print a formatted help listing.
Args:
commands: Dict of command -> description pairs.
"""
self.section("Commands")
max_cmd = max(len(c) for c in commands) if commands else 0
for cmd, desc in commands.items():
cmd_styled = self._c(self.accent, f" {cmd:<{max_cmd}}")
desc_styled = self._c(_GRAY, f" {desc}")
print(f"{cmd_styled}{desc_styled}")
print()
# ── Goodbye ───────────────────────────────────────────────────────
def print_goodbye(self):
"""Print a styled goodbye message."""
print(f"\n {_ICON_SMALL} {self._c(_GRAY, 'Goodbye!')}\n")
# ── Prompt toolkit session factory ────────────────────────────────
def create_prompt_session(self):
"""Create a prompt_toolkit PromptSession with skin styling.
Returns:
A configured PromptSession, or None if prompt_toolkit unavailable.
"""
try:
from prompt_toolkit import PromptSession
from prompt_toolkit.history import FileHistory
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
from prompt_toolkit.formatted_text import FormattedText
style = self.get_prompt_style()
session = PromptSession(
history=FileHistory(self.history_file),
auto_suggest=AutoSuggestFromHistory(),
style=style,
enable_history_search=True,
)
return session
except ImportError:
return None
def get_input(self, pt_session, project_name: str = "",
modified: bool = False, context: str = "") -> str:
"""Get input from user using prompt_toolkit or fallback.
Args:
pt_session: A prompt_toolkit PromptSession (or None).
project_name: Current project name.
modified: Whether project has unsaved changes.
context: Optional context string.
Returns:
User input string (stripped).
"""
if pt_session is not None:
from prompt_toolkit.formatted_text import FormattedText
tokens = self.prompt_tokens(project_name, modified, context)
return pt_session.prompt(FormattedText(tokens)).strip()
else:
raw_prompt = self.prompt(project_name, modified, context)
return input(raw_prompt).strip()
# ── Toolbar builder ───────────────────────────────────────────────
def bottom_toolbar(self, items: dict[str, str]):
"""Create a bottom toolbar callback for prompt_toolkit.
Args:
items: Dict of label -> value pairs to show in toolbar.
Returns:
A callable that returns FormattedText for the toolbar.
"""
def toolbar():
from prompt_toolkit.formatted_text import FormattedText
parts = []
for i, (k, v) in enumerate(items.items()):
if i > 0:
parts.append(("class:bottom-toolbar.text", ""))
parts.append(("class:bottom-toolbar.text", f" {k}: "))
parts.append(("class:bottom-toolbar", v))
return FormattedText(parts)
return toolbar
# ── ANSI 256-color to hex mapping (for prompt_toolkit styles) ─────────
_ANSI_256_TO_HEX = {
"\033[38;5;33m": "#0087ff", # audacity navy blue
"\033[38;5;35m": "#00af5f", # shotcut teal
"\033[38;5;39m": "#00afff", # inkscape bright blue
"\033[38;5;40m": "#00d700", # libreoffice green
"\033[38;5;55m": "#5f00af", # obs purple
"\033[38;5;69m": "#5f87ff", # kdenlive slate blue
"\033[38;5;75m": "#5fafff", # default sky blue
"\033[38;5;80m": "#5fd7d7", # brand cyan
"\033[38;5;208m": "#ff8700", # blender deep orange
"\033[38;5;214m": "#ffaf00", # gimp warm orange
}
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""setup.py for cli-anything-musescore
Install with: pip install -e .
"""
from setuptools import setup, find_namespace_packages
with open("cli_anything/musescore/README.md", "r", encoding="utf-8") as fh:
long_description = fh.read()
setup(
name="cli-anything-musescore",
version="1.0.0",
author="cli-anything contributors",
author_email="",
description="CLI harness for MuseScore 4 — transpose, export PDF/audio/MIDI, extract parts, manage instruments",
long_description=long_description,
long_description_content_type="text/markdown",
url="https://github.com/HKUDS/CLI-Anything",
packages=find_namespace_packages(include=["cli_anything.*"]),
classifiers=[
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Topic :: Multimedia :: Sound/Audio",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
],
python_requires=">=3.10",
install_requires=[
"click>=8.0.0",
"prompt-toolkit>=3.0.0",
],
extras_require={
"dev": [
"pytest>=7.0.0",
"pytest-cov>=4.0.0",
],
},
entry_points={
"console_scripts": [
"cli-anything-musescore=cli_anything.musescore.musescore_cli:main",
],
},
package_data={
"cli_anything.musescore": ["skills/*.md"],
},
include_package_data=True,
zip_safe=False,
)
+14
View File
@@ -254,6 +254,20 @@
"category": "ai",
"contributor": "Alex-wuhu",
"contributor_url": "https://github.com/Alex-wuhu"
},
{
"name": "musescore",
"display_name": "MuseScore",
"version": "1.0.0",
"description": "CLI for music notation — transpose, export PDF/audio/MIDI, extract parts, manage instruments",
"requires": "MuseScore 4 (musescore.org)",
"homepage": "https://musescore.org",
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=musescore/agent-harness",
"entry_point": "cli-anything-musescore",
"skill_md": "musescore/agent-harness/cli_anything/musescore/skills/SKILL.md",
"category": "music",
"contributor": "tamvicky",
"contributor_url": "https://github.com/tamvicky"
}
]
}