From e6eb002311f5f9932f6df0a323fdad3f58dbe073 Mon Sep 17 00:00:00 2001 From: Vicky Tam Date: Sat, 21 Mar 2026 23:06:07 +0900 Subject: [PATCH] feat: add MuseScore 4 CLI wrapper for music notation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitignore | 4 + README.md | 8 + musescore/agent-harness/MUSESCORE.md | 118 ++++ .../cli_anything/musescore/README.md | 49 ++ .../cli_anything/musescore/__init__.py | 2 + .../cli_anything/musescore/__main__.py | 3 + .../cli_anything/musescore/core/__init__.py | 0 .../cli_anything/musescore/core/export.py | 161 +++++ .../musescore/core/instruments.py | 215 +++++++ .../cli_anything/musescore/core/media.py | 101 ++++ .../cli_anything/musescore/core/parts.py | 143 +++++ .../cli_anything/musescore/core/project.py | 103 ++++ .../cli_anything/musescore/core/session.py | 143 +++++ .../cli_anything/musescore/core/transpose.py | 188 ++++++ .../cli_anything/musescore/musescore_cli.py | 572 ++++++++++++++++++ .../cli_anything/musescore/skills/SKILL.md | 94 +++ .../cli_anything/musescore/tests/TEST.md | 130 ++++ .../cli_anything/musescore/tests/__init__.py | 0 .../cli_anything/musescore/tests/test_core.py | 398 ++++++++++++ .../musescore/tests/test_full_e2e.py | 364 +++++++++++ .../cli_anything/musescore/utils/__init__.py | 0 .../cli_anything/musescore/utils/mscx_xml.py | 349 +++++++++++ .../musescore/utils/musescore_backend.py | 277 +++++++++ .../cli_anything/musescore/utils/repl_skin.py | 498 +++++++++++++++ musescore/agent-harness/setup.py | 53 ++ registry.json | 14 + 26 files changed, 3987 insertions(+) create mode 100644 musescore/agent-harness/MUSESCORE.md create mode 100644 musescore/agent-harness/cli_anything/musescore/README.md create mode 100644 musescore/agent-harness/cli_anything/musescore/__init__.py create mode 100644 musescore/agent-harness/cli_anything/musescore/__main__.py create mode 100644 musescore/agent-harness/cli_anything/musescore/core/__init__.py create mode 100644 musescore/agent-harness/cli_anything/musescore/core/export.py create mode 100644 musescore/agent-harness/cli_anything/musescore/core/instruments.py create mode 100644 musescore/agent-harness/cli_anything/musescore/core/media.py create mode 100644 musescore/agent-harness/cli_anything/musescore/core/parts.py create mode 100644 musescore/agent-harness/cli_anything/musescore/core/project.py create mode 100644 musescore/agent-harness/cli_anything/musescore/core/session.py create mode 100644 musescore/agent-harness/cli_anything/musescore/core/transpose.py create mode 100644 musescore/agent-harness/cli_anything/musescore/musescore_cli.py create mode 100644 musescore/agent-harness/cli_anything/musescore/skills/SKILL.md create mode 100644 musescore/agent-harness/cli_anything/musescore/tests/TEST.md create mode 100644 musescore/agent-harness/cli_anything/musescore/tests/__init__.py create mode 100644 musescore/agent-harness/cli_anything/musescore/tests/test_core.py create mode 100644 musescore/agent-harness/cli_anything/musescore/tests/test_full_e2e.py create mode 100644 musescore/agent-harness/cli_anything/musescore/utils/__init__.py create mode 100644 musescore/agent-harness/cli_anything/musescore/utils/mscx_xml.py create mode 100644 musescore/agent-harness/cli_anything/musescore/utils/musescore_backend.py create mode 100644 musescore/agent-harness/cli_anything/musescore/utils/repl_skin.py create mode 100644 musescore/agent-harness/setup.py diff --git a/.gitignore b/.gitignore index d25f2334c..014259d38 100644 --- a/.gitignore +++ b/.gitignore @@ -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__/ diff --git a/README.md b/README.md index 8816de87f..f7108d08f 100644 --- a/README.md +++ b/README.md @@ -589,6 +589,13 @@ Each application received complete, production-ready CLI interfaces โ€” not demo โœ… 22 +๐ŸŽต MuseScore +Music Notation +cli-anything-musescore +mscore CLI (MSCX/MusicXML) +โœ… 56 + + ๐Ÿ“ Draw.io Diagramming cli-anything-drawio @@ -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) diff --git a/musescore/agent-harness/MUSESCORE.md b/musescore/agent-harness/MUSESCORE.md new file mode 100644 index 000000000..055b78fb9 --- /dev/null +++ b/musescore/agent-harness/MUSESCORE.md @@ -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()` diff --git a/musescore/agent-harness/cli_anything/musescore/README.md b/musescore/agent-harness/cli_anything/musescore/README.md new file mode 100644 index 000000000..e5ceb2248 --- /dev/null +++ b/musescore/agent-harness/cli_anything/musescore/README.md @@ -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 | diff --git a/musescore/agent-harness/cli_anything/musescore/__init__.py b/musescore/agent-harness/cli_anything/musescore/__init__.py new file mode 100644 index 000000000..1250cfc54 --- /dev/null +++ b/musescore/agent-harness/cli_anything/musescore/__init__.py @@ -0,0 +1,2 @@ +"""cli-anything-musescore โ€” CLI wrapper for MuseScore 4.""" +__version__ = "1.0.0" diff --git a/musescore/agent-harness/cli_anything/musescore/__main__.py b/musescore/agent-harness/cli_anything/musescore/__main__.py new file mode 100644 index 000000000..d0c708126 --- /dev/null +++ b/musescore/agent-harness/cli_anything/musescore/__main__.py @@ -0,0 +1,3 @@ +"""Allow running as python3 -m cli_anything.musescore""" +from cli_anything.musescore.musescore_cli import main +main() diff --git a/musescore/agent-harness/cli_anything/musescore/core/__init__.py b/musescore/agent-harness/cli_anything/musescore/core/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/musescore/agent-harness/cli_anything/musescore/core/export.py b/musescore/agent-harness/cli_anything/musescore/core/export.py new file mode 100644 index 000000000..ac82238c9 --- /dev/null +++ b/musescore/agent-harness/cli_anything/musescore/core/export.py @@ -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) diff --git a/musescore/agent-harness/cli_anything/musescore/core/instruments.py b/musescore/agent-harness/cli_anything/musescore/core/instruments.py new file mode 100644 index 000000000..b9fba6c31 --- /dev/null +++ b/musescore/agent-harness/cli_anything/musescore/core/instruments.py @@ -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 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 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 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()), + } diff --git a/musescore/agent-harness/cli_anything/musescore/core/media.py b/musescore/agent-harness/cli_anything/musescore/core/media.py new file mode 100644 index 000000000..8b6639e9b --- /dev/null +++ b/musescore/agent-harness/cli_anything/musescore/core/media.py @@ -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 diff --git a/musescore/agent-harness/cli_anything/musescore/core/parts.py b/musescore/agent-harness/cli_anything/musescore/core/parts.py new file mode 100644 index 000000000..25b248578 --- /dev/null +++ b/musescore/agent-harness/cli_anything/musescore/core/parts.py @@ -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 diff --git a/musescore/agent-harness/cli_anything/musescore/core/project.py b/musescore/agent-harness/cli_anything/musescore/core/project.py new file mode 100644 index 000000000..f56b17b54 --- /dev/null +++ b/musescore/agent-harness/cli_anything/musescore/core/project.py @@ -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}" diff --git a/musescore/agent-harness/cli_anything/musescore/core/session.py b/musescore/agent-harness/cli_anything/musescore/core/session.py new file mode 100644 index 000000000..5bee1dfbe --- /dev/null +++ b/musescore/agent-harness/cli_anything/musescore/core/session.py @@ -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 diff --git a/musescore/agent-harness/cli_anything/musescore/core/transpose.py b/musescore/agent-harness/cli_anything/musescore/core/transpose.py new file mode 100644 index 000000000..22f36e29e --- /dev/null +++ b/musescore/agent-harness/cli_anything/musescore/core/transpose.py @@ -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, + } diff --git a/musescore/agent-harness/cli_anything/musescore/musescore_cli.py b/musescore/agent-harness/cli_anything/musescore/musescore_cli.py new file mode 100644 index 000000000..c31975a06 --- /dev/null +++ b/musescore/agent-harness/cli_anything/musescore/musescore_cli.py @@ -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() diff --git a/musescore/agent-harness/cli_anything/musescore/skills/SKILL.md b/musescore/agent-harness/cli_anything/musescore/skills/SKILL.md new file mode 100644 index 000000000..4554b06e4 --- /dev/null +++ b/musescore/agent-harness/cli_anything/musescore/skills/SKILL.md @@ -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` diff --git a/musescore/agent-harness/cli_anything/musescore/tests/TEST.md b/musescore/agent-harness/cli_anything/musescore/tests/TEST.md new file mode 100644 index 000000000..28175db72 --- /dev/null +++ b/musescore/agent-harness/cli_anything/musescore/tests/TEST.md @@ -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) diff --git a/musescore/agent-harness/cli_anything/musescore/tests/__init__.py b/musescore/agent-harness/cli_anything/musescore/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/musescore/agent-harness/cli_anything/musescore/tests/test_core.py b/musescore/agent-harness/cli_anything/musescore/tests/test_core.py new file mode 100644 index 000000000..d4b55bacd --- /dev/null +++ b/musescore/agent-harness/cli_anything/musescore/tests/test_core.py @@ -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": "", + "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"] == "" + 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) diff --git a/musescore/agent-harness/cli_anything/musescore/tests/test_full_e2e.py b/musescore/agent-harness/cli_anything/musescore/tests/test_full_e2e.py new file mode 100644 index 000000000..365b9c4ec --- /dev/null +++ b/musescore/agent-harness/cli_anything/musescore/tests/test_full_e2e.py @@ -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") diff --git a/musescore/agent-harness/cli_anything/musescore/utils/__init__.py b/musescore/agent-harness/cli_anything/musescore/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/musescore/agent-harness/cli_anything/musescore/utils/mscx_xml.py b/musescore/agent-harness/cli_anything/musescore/utils/mscx_xml.py new file mode 100644 index 000000000..47fa130a3 --- /dev/null +++ b/musescore/agent-harness/cli_anything/musescore/utils/mscx_xml.py @@ -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: -5 + fifths = root.find(".//{*}fifths") + if fifths is not None and fifths.text: + return int(fifths.text) + + # MSCX: -5 + # or -5 + 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: + 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: 44 + 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: Piano + # Piano + 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: Piano + 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: ... + # or ... + 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: ... + 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 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 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})") diff --git a/musescore/agent-harness/cli_anything/musescore/utils/musescore_backend.py b/musescore/agent-harness/cli_anything/musescore/utils/musescore_backend.py new file mode 100644 index 000000000..ed8645f72 --- /dev/null +++ b/musescore/agent-harness/cli_anything/musescore/utils/musescore_backend.py @@ -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] diff --git a/musescore/agent-harness/cli_anything/musescore/utils/repl_skin.py b/musescore/agent-harness/cli_anything/musescore/utils/repl_skin.py new file mode 100644 index 000000000..47260bebd --- /dev/null +++ b/musescore/agent-harness/cli_anything/musescore/utils/repl_skin.py @@ -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//utils/repl_skin.py + +Usage: + from cli_anything..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-/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 +} diff --git a/musescore/agent-harness/setup.py b/musescore/agent-harness/setup.py new file mode 100644 index 000000000..f544c2f2b --- /dev/null +++ b/musescore/agent-harness/setup.py @@ -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, +) diff --git a/registry.json b/registry.json index 7895a11c4..b0695c71b 100644 --- a/registry.json +++ b/registry.json @@ -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" } ] }