Skill: skaravind/clawtunes-play (version 1.0.3)

--- name: clawtunes-play description: Play Apple Music songs on macOS using clawtunes, including streaming catalog tracks via a practical keyboard-navigation workaround after opening the song in Music. Use when the user asks to play a song, artist, album, playlist, or mood in Apple Music and normal AppleScript/library playback is not enough. Works with the Mac Apple Music app plus online catalog search/play. --- # Clawtunes Play Use `clawtunes` as the first choice for Apple Music control on this Mac. ## Workflow 1. For library items, try direct playback first. 2. For songs likely not in the library, search the Apple Music catalog and open the selected result in Music. 3. For catalog playback, prefer the wrapper script that tries the best-known keyboard sequence automatically: - `Tab` + `Tab` + `Enter` - fallback: `Tab` + `Enter` - fallback: `Shift-Tab` + `Enter` 4. Check whether playback actually changed to the requested song. 5. If playback did not switch, say so plainly. ## Commands - Direct library-style play: - `clawtunes_play --song "<song>"` - Catalog play wrapper: - `catalog_play "<query>"` - Status only: - `clawtunes_play --status` - Experimental step-by-step catalog workflow: - `catalog_play_experiment "<query>" --index 1 --strategy tab-tab-enter` ## Requirements This skill assumes these local tools are available on macOS: - `clawtunes` - `python3` - `osascript` - `open` It also needs macOS Accessibility / Automation permission so `System Events` can send keyboard input to Music. ## Install / Use - Keep repo in `skills/clawtunes-play` - Commands: `clawtunes_play`, `catalog_play`, `catalog_play_experiment` - For catalog playback, use the wrapper script in `scripts/catalog_play_wrapper.sh` ## Notes - `clawtunes play song` targets library playback. - `clawtunes catalog search` opens the streaming result in Music, but Music may not autoplay unless the UI focus lands correctly. - The current best-known workaround is keyboard navigation after opening the result. - This is macOS UI-state dependent; it is usable, but not a guaranteed Apple primitive.

path: INSTALL.md
content: # Clawtunes Play Install

## Requirements

This skill assumes these local tools are available on macOS:
- `clawtunes`
- `python3`
- `osascript`
- `open`

It also needs macOS Accessibility / Automation permission so `System Events` can send keyboard input to Music.

## Where it lives

Keep the repo in:
- `skills/clawtunes-play`

## Commands

- `clawtunes_play --song "<song>"`
- `catalog_play "<query>"`
- `catalog_play_experiment "<query>" --index 1 --strategy tab-tab-enter`

## Wrapper

Catalog playback uses:
- `scripts/catalog_play_wrapper.sh`

That wrapper resolves repo-relative paths and avoids hardcoded user-specific paths.


path: scripts/catalog_play.py
content: #!/usr/bin/env python3
import argparse
import json
import subprocess
import sys
import time
import urllib.parse
import urllib.request

ITUNES_SEARCH_URL = "https://itunes.apple.com/search"
STRATEGIES = ["tab-tab-enter", "tab-enter", "shift-tab-enter"]


def run(cmd):
    return subprocess.run(cmd, text=True, capture_output=True)


def current_track():
    r = run(["clawtunes", "status"])
    text = (r.stdout or r.stderr).strip().splitlines()
    if not text:
        return {"raw": "", "name": "", "artist": ""}
    name = ""
    artist = ""
    first = text[0].strip()
    if first.startswith(("▶", "⏸", "⏹")):
        name = first[1:].strip()
    for line in text:
        line = line.strip()
        if line.startswith("Artist:"):
            artist = line.split(":", 1)[1].strip()
    return {"raw": "\n".join(text), "name": name, "artist": artist}


def search_catalog(query, limit=5):
    params = urllib.parse.urlencode({
        "term": query,
        "media": "music",
        "entity": "song",
        "limit": limit,
    })
    with urllib.request.urlopen(f"{ITUNES_SEARCH_URL}?{params}", timeout=10) as r:
        return json.loads(r.read().decode("utf-8")).get("results", [])


def send_keycode(code: int, shift: bool = False):
    if shift:
        return run(["osascript", "-e", 'tell application "Music" to activate', "-e", f'tell application "System Events" to key code {code} using shift down'])
    return run(["osascript", "-e", 'tell application "Music" to activate', "-e", f'tell application "System Events" to key code {code}'])


def apply_strategy(name: str):
    actions = []
    if name == "tab-tab-enter":
        for label, code in [("tab-1", 48), ("tab-2", 48), ("enter", 36)]:
            r = send_keycode(code)
            actions.append({"action": label, "rc": r.returncode, "stderr": r.stderr})
            time.sleep(0.3)
    elif name == "tab-enter":
        for label, code in [("tab", 48), ("enter", 36)]:
            r = send_keycode(code)
            actions.append({"action": label, "rc": r.returncode, "stderr": r.stderr})
            time.sleep(0.3)
    elif name == "shift-tab-enter":
        r1 = send_keycode(48, shift=True)
        actions.append({"action": "shift-tab", "rc": r1.returncode, "stderr": r1.stderr})
        time.sleep(0.3)
        r2 = send_keycode(36)
        actions.append({"action": "enter", "rc": r2.returncode, "stderr": r2.stderr})
    time.sleep(1)
    return actions


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("query")
    ap.add_argument("--limit", type=int, default=5)
    ap.add_argument("--index", type=int, default=1)
    args = ap.parse_args()

    before = current_track()
    results = search_catalog(args.query, args.limit)
    if not results:
        json.dump({"ok": False, "error": "no_results", "query": args.query}, sys.stdout, indent=2)
        sys.stdout.write("\n")
        sys.exit(1)

    idx = max(1, min(args.index, len(results))) - 1
    chosen = results[idx]
    music_url = chosen["trackViewUrl"].replace("https://", "music://")
    open_result = run(["open", music_url])
    time.sleep(2)

    attempts = []
    final_after = before
    worked = False
    for strategy in STRATEGIES:
        actions = apply_strategy(strategy)
        after = current_track()
        changed = (after["name"], after["artist"]) != (before["name"], before["artist"])
        matched = chosen.get("trackName", "").lower() in after["name"].lower() if after["name"] else False
        attempt = {
            "strategy": strategy,
            "actions": actions,
            "after": after,
            "changed": changed,
            "matched": matched,
        }
        attempts.append(attempt)
        final_after = after
        if changed and matched:
            worked = True
            break

    json.dump(
        {
            "ok": open_result.returncode == 0,
            "worked": worked,
            "query": args.query,
            "chosen": {
                "trackName": chosen.get("trackName"),
                "artistName": chosen.get("artistName"),
                "collectionName": chosen.get("collectionName"),
                "trackViewUrl": chosen.get("trackViewUrl"),
                "musicUrl": music_url,
            },
            "before": before,
            "after": final_after,
            "attempts": attempts,
        },
        sys.stdout,
        indent=2,
    )
    sys.stdout.write("\n")


if __name__ == "__main__":
    main()


path: scripts/catalog_play_experiment.py
content: #!/usr/bin/env python3
import argparse
import json
import subprocess
import sys
import time
import urllib.parse
import urllib.request
from typing import List

ITUNES_SEARCH_URL = "https://itunes.apple.com/search"


def run(cmd):
    return subprocess.run(cmd, text=True, capture_output=True)


def current_track():
    script = r'''
    tell application "Music"
        try
            set s to player state as text
        on error
            set s to "unknown"
        end try
        try
            set t to current track
            return s & "|" & (name of t) & "|" & (artist of t)
        on error
            return s & "|" & "" & "|" & ""
        end try
    end tell
    '''
    r = run(["osascript", "-e", script])
    parts = (r.stdout or "").strip().split("|", 2)
    while len(parts) < 3:
        parts.append("")
    return {"state": parts[0], "name": parts[1], "artist": parts[2]}


def search_catalog(query, limit=5):
    params = urllib.parse.urlencode({
        "term": query,
        "media": "music",
        "entity": "song",
        "limit": limit,
    })
    url = f"{ITUNES_SEARCH_URL}?{params}"
    with urllib.request.urlopen(url, timeout=10) as response:
        data = json.loads(response.read().decode("utf-8"))
    return data.get("results", [])


def open_music_url(track_url):
    music_url = track_url.replace("https://", "music://")
    r = run(["open", music_url])
    return music_url, r


def send_keycode(code: int):
    return run(["osascript", "-e", 'tell application "Music" to activate', "-e", f'tell application "System Events" to key code {code}'])


def run_strategy(strategy: str) -> List[dict]:
    actions = []
    if strategy == "enter":
        r = send_keycode(36)
        actions.append({"action": "enter", "rc": r.returncode, "stdout": r.stdout, "stderr": r.stderr})
        time.sleep(1)
    elif strategy == "space":
        r = send_keycode(49)
        actions.append({"action": "space", "rc": r.returncode, "stdout": r.stdout, "stderr": r.stderr})
        time.sleep(1)
    elif strategy == "tab-enter":
        r1 = send_keycode(48)
        actions.append({"action": "tab", "rc": r1.returncode, "stdout": r1.stdout, "stderr": r1.stderr})
        time.sleep(0.3)
        r2 = send_keycode(36)
        actions.append({"action": "enter", "rc": r2.returncode, "stdout": r2.stdout, "stderr": r2.stderr})
        time.sleep(1)
    elif strategy == "double-enter":
        r1 = send_keycode(36)
        actions.append({"action": "enter-1", "rc": r1.returncode, "stdout": r1.stdout, "stderr": r1.stderr})
        time.sleep(0.3)
        r2 = send_keycode(36)
        actions.append({"action": "enter-2", "rc": r2.returncode, "stdout": r2.stdout, "stderr": r2.stderr})
        time.sleep(1)
    elif strategy == "tab-tab-enter":
        for name, code in [("tab-1", 48), ("tab-2", 48), ("enter", 36)]:
            r = send_keycode(code)
            actions.append({"action": name, "rc": r.returncode, "stdout": r.stdout, "stderr": r.stderr})
            time.sleep(0.3)
        time.sleep(1)
    elif strategy == "shift-tab-enter":
        r1 = run(["osascript", "-e", 'tell application "Music" to activate', "-e", 'tell application "System Events" to key code 48 using shift down'])
        actions.append({"action": "shift-tab", "rc": r1.returncode, "stdout": r1.stdout, "stderr": r1.stderr})
        time.sleep(0.3)
        r2 = send_keycode(36)
        actions.append({"action": "enter", "rc": r2.returncode, "stdout": r2.stdout, "stderr": r2.stderr})
        time.sleep(1)
    return actions


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("query")
    ap.add_argument("--limit", type=int, default=5)
    ap.add_argument("--index", type=int, default=1, help="1-based result index")
    ap.add_argument("--press-enter", action="store_true")
    ap.add_argument("--press-space", action="store_true")
    ap.add_argument("--strategy", choices=["enter", "space", "tab-enter", "double-enter", "tab-tab-enter", "shift-tab-enter"])
    args = ap.parse_args()

    before = current_track()
    results = search_catalog(args.query, args.limit)
    if not results:
        json.dump({"ok": False, "error": "no_results", "query": args.query}, sys.stdout, indent=2)
        sys.stdout.write("\n")
        sys.exit(1)

    idx = max(1, min(args.index, len(results))) - 1
    chosen = results[idx]
    music_url, open_result = open_music_url(chosen["trackViewUrl"])
    time.sleep(2)

    actions = []
    if args.strategy:
        actions.extend(run_strategy(args.strategy))
    else:
        if args.press_enter:
            actions.extend(run_strategy("enter"))
        if args.press_space:
            actions.extend(run_strategy("space"))

    after = current_track()
    json.dump(
        {
            "ok": open_result.returncode == 0,
            "query": args.query,
            "chosen": {
                "trackName": chosen.get("trackName"),
                "artistName": chosen.get("artistName"),
                "collectionName": chosen.get("collectionName"),
                "trackViewUrl": chosen.get("trackViewUrl"),
                "musicUrl": music_url,
            },
            "open": {"rc": open_result.returncode, "stdout": open_result.stdout, "stderr": open_result.stderr},
            "actions": actions,
            "before": before,
            "after": after,
            "changed": (before["name"], before["artist"]) != (after["name"], after["artist"]),
        },
        sys.stdout,
        indent=2,
    )
    sys.stdout.write("\n")


if __name__ == "__main__":
    main()


path: scripts/catalog_play_wrapper.sh
content: #!/bin/sh
set -eu
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
exec python3 "$SCRIPT_DIR/catalog_play.py" "$@"


path: scripts/clawtunes_play.py
content: #!/usr/bin/env python3
import argparse
import json
import subprocess
import sys
import time


def run(cmd):
    return subprocess.run(cmd, text=True, capture_output=True)


def current_track():
    script = r'''
    tell application "Music"
        try
            set s to player state as text
        on error
            set s to "unknown"
        end try
        try
            set t to current track
            return s & "|" & (name of t) & "|" & (artist of t)
        on error
            return s & "|" & "" & "|" & ""
        end try
    end tell
    '''
    r = run(["osascript", "-e", script])
    out = (r.stdout or "").strip().split("|", 2)
    while len(out) < 3:
        out.append("")
    return {"state": out[0], "name": out[1], "artist": out[2]}


def play_library(song):
    return run(["clawtunes", "-1", "play", "song", song])


def open_catalog(song):
    return run(["clawtunes", "-1", "catalog", "search", song, "-n", "5"])


def music_play():
    return run(["osascript", "-e", 'tell application "Music" to play'])


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--song")
    ap.add_argument("--catalog", action="store_true")
    ap.add_argument("--status", action="store_true")
    args = ap.parse_args()

    if args.status:
        json.dump({"ok": True, "track": current_track()}, sys.stdout, indent=2)
        sys.stdout.write("\n")
        return

    if not args.song:
        print("--song is required unless --status is used", file=sys.stderr)
        sys.exit(2)

    before = current_track()

    if args.catalog:
        r = open_catalog(args.song)
        time.sleep(2)
        music_play()
        time.sleep(2)
        after = current_track()
        changed = (after["name"], after["artist"]) != (before["name"], before["artist"])
        json.dump(
            {
                "ok": r.returncode == 0,
                "mode": "catalog",
                "requested": args.song,
                "command_stdout": r.stdout,
                "command_stderr": r.stderr,
                "before": before,
                "after": after,
                "changed": changed,
                "autoplay_worked": changed and after["state"] == "playing",
            },
            sys.stdout,
            indent=2,
        )
        sys.stdout.write("\n")
        return

    r = play_library(args.song)
    time.sleep(2)
    after = current_track()
    changed = (after["name"], after["artist"]) != (before["name"], before["artist"])
    json.dump(
        {
            "ok": r.returncode == 0,
            "mode": "library",
            "requested": args.song,
            "command_stdout": r.stdout,
            "command_stderr": r.stderr,
            "before": before,
            "after": after,
            "changed": changed,
            "autoplay_worked": changed and after["state"] == "playing",
        },
        sys.stdout,
        indent=2,
    )
    sys.stdout.write("\n")


if __name__ == "__main__":
    main()


path: scripts/playlist_create.py
content: #!/usr/bin/env python3
import argparse
import json
import subprocess
import sys
import time
import urllib.parse
import urllib.request

ITUNES_SEARCH_URL = "https://itunes.apple.com/search"


def run(cmd):
    return subprocess.run(cmd, text=True, capture_output=True)


def applescript(script: str):
    return run(["osascript", "-e", script])


def search_catalog(query, limit=5):
    params = urllib.parse.urlencode({
        "term": query,
        "media": "music",
        "entity": "song",
        "limit": limit,
    })
    with urllib.request.urlopen(f"{ITUNES_SEARCH_URL}?{params}", timeout=10) as r:
        return json.loads(r.read().decode("utf-8")).get("results", [])


def ensure_playlist(name: str):
    script = f'''
    tell application "Music"
      if not (exists playlist "{name}") then
        make new user playlist with properties {{name:"{name}"}}
      end if
    end tell
    '''
    r = applescript(script)
    return r.returncode == 0, (r.stderr or r.stdout).strip()


def add_track_to_playlist(playlist_name: str, song_name: str, artist_name: str):
    esc_song = song_name.replace('"', '\\"')
    esc_artist = artist_name.replace('"', '\\"')
    esc_playlist = playlist_name.replace('"', '\\"')
    script = f'''
    tell application "Music"
      set matchingTracks to (every track of library playlist 1 whose name is "{esc_song}" and artist is "{esc_artist}")
      if (count of matchingTracks) is 0 then
        return "NOT_FOUND"
      end if
      duplicate item 1 of matchingTracks to playlist "{esc_playlist}"
      return "OK"
    end tell
    '''
    r = applescript(script)
    out = (r.stdout or "").strip()
    err = (r.stderr or "").strip()
    return r.returncode == 0 and out == "OK", out or err


def add_catalog_song_to_library(track_view_url: str):
    music_url = track_view_url.replace("https://", "music://")
    open_res = run(["open", music_url])
    time.sleep(2)
    steps = [
        'tell application "Music" to activate',
        'tell application "System Events" to tell process "Music" to keystroke "l" using command down',
        'tell application "System Events" to key code 36',
    ]
    for s in steps:
        applescript(s)
        time.sleep(0.6)
    return open_res.returncode == 0


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("playlist")
    ap.add_argument("queries", nargs="+")
    ap.add_argument("--limit", type=int, default=5)
    args = ap.parse_args()

    ok, msg = ensure_playlist(args.playlist)
    if not ok:
        print(json.dumps({"ok": False, "error": "playlist_create_failed", "details": msg}, indent=2))
        sys.exit(1)

    results = []
    for query in args.queries:
        entry = {"query": query}
        try:
            found = search_catalog(query, args.limit)
        except Exception as e:
            entry.update({"ok": False, "error": f"catalog_search_failed: {e}"})
            results.append(entry)
            continue
        if not found:
            entry.update({"ok": False, "error": "no_catalog_results"})
            results.append(entry)
            continue
        chosen = found[0]
        entry["chosen"] = {
            "trackName": chosen.get("trackName"),
            "artistName": chosen.get("artistName"),
            "collectionName": chosen.get("collectionName"),
            "trackViewUrl": chosen.get("trackViewUrl"),
        }
        added, detail = add_track_to_playlist(args.playlist, chosen.get("trackName", ""), chosen.get("artistName", ""))
        if added:
            entry.update({"ok": True, "method": "library-duplicate"})
            results.append(entry)
            continue
        add_catalog_song_to_library(chosen.get("trackViewUrl", ""))
        time.sleep(1.5)
        added2, detail2 = add_track_to_playlist(args.playlist, chosen.get("trackName", ""), chosen.get("artistName", ""))
        if added2:
            entry.update({"ok": True, "method": "catalog-add-then-duplicate"})
        else:
            entry.update({"ok": False, "error": "add_failed", "details": detail2 or detail})
        results.append(entry)

    print(json.dumps({"ok": True, "playlist": args.playlist, "results": results}, indent=2))


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)