SLOPSHOPPER

spotify-claude-code-mod

Claude Code mod for Spotify: control the Spotify desktop app from Claude Code on macOS: now playing with progress, play/pause/next/previous, volume, shuffle…

panecommandprocessnetworktimer
★ 1v0.1.0MITupdated 2026-10-17gfb-47/spotify-claude-code-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · spotify-claude-code-mod
│ ┃ Spotify ✕ › fix the failing auth test and add an audit log call │ ┃ Spotify r: Refresh │ ┃ ⏺ Read(src/auth.ts) │ ┃ Nothing playing. Open Spotify, or search for ⎿ Read 6 lines │ ┃ something to start. ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ b: ⏮ Prev p: ▶ Play n: Next ⏭ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ s: Search… │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /spotify │ ⎿ spotify-claude-code-mod: Spotify pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Spotify
Spotify r: Refresh Nothing playing. Open Spotify, or search for something to start. b: ⏮ Prev p: ▶ Play n: Next ⏭ s: Search…
README

spotify-claude-code-mod: Claude Code Mod for Spotify (macOS)

spotify-claude-code-mod is a free, open-source Claude Code mod (plugin) for Spotify on macOS. Control the Spotify desktop app from your terminal without leaving your AI coding session: see what is playing with a progress bar, play, pause, skip, change the volume, toggle shuffle and repeat, play a Spotify link, or search for a song and pick from the results. Uses AppleScript (osascript) and the Spotify Web API. MIT licensed.

Keywords: Claude Code mod, Claude Code mods, Claude Code plugin, Spotify mod for Claude Code, Spotify, Spotify controller, Spotify CLI, music player, now playing, play pause skip, AppleScript, osascript, macOS, Spotify Web API, TypeScript.

Status: pre-release. The code is unit-tested (strict TypeScript, 31 tests) and the status script and album-art pipeline have been run against a live Spotify app, but play, volume, shuffle and search have not yet been tried end to end. Issues and pull requests are welcome.

Not affiliated with or endorsed by Spotify AB. "Spotify" is a trademark of Spotify AB.

Features

  • Now playing: title, artist, album, a progress bar with position and length, and a link to the track
  • Play, pause, next track, previous track
  • Volume up and down, shuffle and repeat toggles
  • Album art in terminals that can draw pictures (kitty and Ghostty); other terminals skip it
  • Play any Spotify link or URI (track, album, playlist, artist, episode)
  • Search by name and pick from up to four results
  • A pane inside Claude Code plus a /spotify slash command

Why it is safe

It is a safer alternative to Spotify mods and plugins for Claude Code that keep a background daemon and a shared state file. This one has neither:

  • Every osascript script is fixed text. The one value that varies (a Spotify URI) is passed as an argument that AppleScript reads as data, never pasted into the script.
  • Anything you type or Spotify returns must match a strict, anchored pattern (spotify:track:<22 chars> or an open.spotify.com link) before it gets near a script.
  • No shell is involved, no daemon, no state file, no microphone, no login. Album art is the one thing that touches disk: a small PNG in a private temp folder that is deleted right after it is read, fetched only from i.scdn.co.
  • It never launches Spotify; if the app is closed it tells you so.
  • The Spotify client secret lives in Claude Code secure storage and is only ever sent to Spotify's own https endpoints.

Install

claude plugin marketplace add gfb-47/spotify-claude-code-mod
claude plugin install spotify-claude-code-mod@spotify-claude-code-mod

macOS will ask once whether Claude Code may control Spotify. Allow it (System Settings > Privacy & Security > Automation).

Search (optional)

Playing links and the player controls work with no setup. Search needs a Spotify developer app:

  1. Create an app at https://developer.spotify.com/dashboard (any redirect URI, e.g. http://127.0.0.1).
  2. Run claude plugin configure spotify-claude-code-mod@spotify-claude-code-mod and enter the client ID and secret.

Search uses the client-credentials flow, so there is no user login and no access to your account.

Spotify has been tightening its developer rules: apps in development mode are limited in users and features, and the app owner may need a Premium account. Check https://developer.spotify.com for the current terms before you set this up. Everything except search works without it.

Use

  • /spotify opens or closes the Spotify pane (Prev, Play/Pause, Next, Vol, Shuffle, Repeat, Search, Refresh). The pane refreshes every 15 seconds.
  • /spotify pause, /spotify play, /spotify next, /spotify prev, /spotify shuffle, /spotify repeat
  • /spotify https://open.spotify.com/track/... plays that link.
  • /spotify some song name searches and plays the first track. The pane's Search lets you pick from the results instead.

Use it from another Claude Code mod

This mod adds a $.spotify object that other plugins can call, so a mod with its own Music tab does not need its own Spotify code. Install this mod, then in the other plugin:

const track = await $.spotify.current() // title, artist, album, progress, volume, shuffle, repeat, cover link
await $.spotify.control('next') // resume | pause | next | prev | shuffle | repeat | volume-up | volume-down
const { tracks, problem } = await $.spotify.find('lo-fi beats') // up to four results to pick from
await $.spotify.playLink(tracks[0].uri)
const png = await $.spotify.cover(track.artworkUrl) // small base64 PNG, or null

The types are in types/index.d.ts. Call it inside a try: the engine only allows $.spotify.method(...) spelled out in place, and a plugin that lists this mod under dependencies is not loaded at all when it is missing, so for an optional integration leave dependencies out and catch the error. Search uses the credentials set on this mod.

Develop

scripts/verify.sh

Runs tsc (strict, no unused code), the layer check (scripts/check-layers.sh), claude plugin validate and claude plugin test. Set CLAUDE_CODE_TYPES and TSC if they are not found automatically.

PathWhat lives there
hooks/register.tsxThe only file that uses $
hooks/host.tsThe port services use: run a process, fetch a URL, read a file
hooks/services/music.tsUse cases: transport, play a link, now playing, album art, search
hooks/lib/music.tsPure rules: URI validation, argv builders, response parsing
hooks/ui/music.tsxThe pane view

License

MIT

Source 7 files
hooks/register.tsx 134 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Spotify } from '../types'
5import type { Host } from './host'
6import { errorText } from './lib/format'
7import { parseSpotifyTarget } from './lib/music'
8import { controlSpotify, coverArt, currentTrack, findTracks, playOrSearch, playSpotifyLink, spotifyConfigFrom, type SpotifyConfig } from './services/music'
9import { musicView, type MusicActions } from './ui/music'
10
11const PANE = 'spotify-claude-code-mod'
12const PANE_TITLE = 'Spotify'
13const REFRESH_MS = 15000
14const TRANSPORT_WORDS = ['play', 'pause', 'next', 'prev', 'shuffle', 'repeat']
15
16// The engine reads plugin and key as literals, so they stay spelled out.
17const nowPlaying = atom({ plugin: 'spotify-claude-code-mod', key: 'nowPlaying' } as const, null)
18const note = atom({ plugin: 'spotify-claude-code-mod', key: 'note' } as const, null)
19const cover = atom({ plugin: 'spotify-claude-code-mod', key: 'cover' } as const, null)
20
21// The one place `$` becomes the services' Host.
22function hostOf($: EngineInterface): Host {
23  return {
24    run: (argv, init) => $.process.run(argv, init),
25    fetch: (url, init) => $.http.fetch(url, init),
26    readBase64: async path => (await $.fs.read(path, { as: 'bytes' })).base64,
27  }
28}
29
30async function refresh($: EngineInterface): Promise<void> {
31  const host = hostOf($)
32  const next = await currentTrack(host)
33  await update($, nowPlaying, () => next)
34
35  // The cover is fetched once per picture, not on every refresh.
36  const url = next?.artworkUrl ?? null
37  if (url === ((await read($, cover))?.url ?? null)) return
38  const png = url ? await coverArt(host, url).catch(() => null) : null
39  await update($, cover, () => (url && png ? { url, png } : null))
40}
41
42// What other plugins get as `$.spotify`: the same services the pane uses, so there is one implementation.
43function spotifyOf(host: Host, config: SpotifyConfig | null): Spotify {
44  return {
45    current: () => currentTrack(host),
46    control: action => controlSpotify(host, action),
47    playLink: link => playSpotifyLink(host, link),
48    find: query => findTracks(host, config, query),
49    cover: url => coverArt(host, url),
50  }
51}
52
53// One action: a short note while it runs, the outcome when it is done.
54async function act($: EngineInterface, task: (host: Host) => Promise<string>): Promise<string> {
55  await update($, note, () => 'Spotify…')
56  const text = await task(hostOf($)).catch(error => `Spotify did not answer: ${errorText(error)}`)
57  await update($, note, () => text)
58  await refresh($)
59
60  return text
61}
62
63async function isShown($: EngineInterface): Promise<boolean> {
64  return (await $.ui.panes()).some(pane => pane.id === PANE && pane.isShown)
65}
66
67function actionsOf($: EngineInterface, config: SpotifyConfig | null): MusicActions {
68  return {
69    refresh: () => refresh($),
70    transport: async action => void (await act($, host => controlSpotify(host, action))),
71    search: async () => {
72      const query = (await $.ui.ask('What should Spotify play? Pick one, or type a search or a Spotify link under Other.', { options: ['lo-fi beats', 'deep focus'], header: 'Spotify' })).trim()
73      if (!query) return
74      if (parseSpotifyTarget(query)) return void (await act($, host => playSpotifyLink(host, query)))
75      await act($, async host => {
76        const { tracks, problem } = await findTracks(host, config, query)
77        if (!tracks.length) return problem ?? 'No track found.'
78        const options = tracks.map(track => track.label)
79        const choice = options.length > 1 ? (await $.ui.ask('Which one?', { options, header: 'Spotify' })).trim() : (options[0] ?? '')
80        const picked = tracks.find(track => track.label === choice)
81
82        return picked ? playSpotifyLink(host, picked.uri) : 'Nothing was played.'
83      })
84    },
85  }
86}
87
88export const register: Register = (on, options) => {
89  const config = spotifyConfigFrom(options)
90
91  on('engine.create', async (_, e, next) => {
92    const built = await next(e)
93    // The engine only lets `built` be called in place, so the host is spelled out here rather than via hostOf.
94    const host: Host = {
95      run: (argv, init) => built.process.run(argv, init),
96      fetch: (url, init) => built.http.fetch(url, init),
97      readBase64: async path => (await built.fs.read(path, { as: 'bytes' })).base64,
98    }
99
100    return { ...built, spotify: spotifyOf(host, config) }
101  })
102
103  on('session.start', async ($, e, next) => {
104    await $.command.register({ name: 'spotify', description: 'Open the Spotify pane, or: spotify <play|pause|next|prev|shuffle|repeat|link|search words>' })
105    void refresh($)
106    $.clock.every(REFRESH_MS, () => void refresh($))
107
108    return next(e)
109  })
110
111  on('command.run', { command: 'spotify' }, async ($, e) => {
112    const arg = e.args.trim()
113    if (!arg) {
114      if (await isShown($)) {
115        await $.ui.close({ id: PANE })
116
117        return { text: 'Spotify pane closed.' }
118      }
119      await $.ui.open({ id: PANE, title: PANE_TITLE, focus: true })
120
121      return { text: 'Spotify pane opened.' }
122    }
123    if (TRANSPORT_WORDS.includes(arg)) return { text: await act($, host => controlSpotify(host, arg === 'play' ? 'resume' : arg)) }
124
125    return { text: await act($, host => playOrSearch(host, config, arg)) }
126  })
127
128  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
129    const [track, art, text] = await Promise.all([read($, nowPlaying), read($, cover), read($, note)])
130
131    return musicView($.ui.resolve(e), track, art?.png ?? null, text, actionsOf($, config), e.props.bodyColumns)
132  })
133}
134
hooks/host.ts 10 lines
1import type { HttpInit, HttpResponse, ProcessRunInit, ProcessRunResult } from 'claude-code'
2
3// What the services need from the engine. The engine only lets `$` travel within register.tsx,
4// so register.tsx builds one of these from `$` and the services stay plain, testable code.
5export type Host = {
6  run: (argv: string[], init?: ProcessRunInit) => Promise<ProcessRunResult>
7  fetch: (url: string, init?: HttpInit) => Promise<HttpResponse>
8  readBase64: (path: string) => Promise<string>
9}
10
hooks/lib/format.ts 16 lines
1export function clip(text: string, width: number): string {
2  return text.length > width ? `${text.slice(0, Math.max(1, width - 1))}…` : text
3}
4
5export function errorText(error: unknown): string {
6  return error instanceof Error ? error.message : String(error)
7}
8
9export function parseJson(text: string): unknown {
10  try {
11    return JSON.parse(text)
12  } catch {
13    return text
14  }
15}
16
hooks/lib/music.ts 203 lines
1import type { SpotifyFoundTrack, SpotifyNowPlaying } from '../../types'
2
3// Spotify's catalogue is outside this mod, so titles and artists are shown as text only.
4function text(value: unknown, fallback: string): string {
5  return typeof value === 'string' && value.trim() ? value.replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, 120) : fallback
6}
7
8// Driving the Spotify desktop app with osascript. Nothing a person, a file or a web response says is
9// ever pasted into a script: the scripts below are fixed text, and the one thing that varies (a Spotify
10// URI) travels as an argument that AppleScript reads as data.
11export type SpotifyTarget = { kind: string; uri: string }
12
13const KINDS = 'track|album|playlist|artist|episode'
14const URI = new RegExp(`^spotify:(${KINDS}):([A-Za-z0-9]{22})$`)
15const LINK = new RegExp(`^https://open\\.spotify\\.com/(?:intl-[a-z]{2}/)?(${KINDS})/([A-Za-z0-9]{22})(?:[?#].*)?$`)
16
17// Accepts a Spotify URI or an open.spotify.com link and nothing else; the id is rebuilt from the match.
18export function parseSpotifyTarget(raw: string): SpotifyTarget | null {
19  const match = URI.exec(raw.trim()) ?? LINK.exec(raw.trim())
20  const kind = match?.[1]
21  const id = match?.[2]
22
23  return kind && id ? { kind, uri: `spotify:${kind}:${id}` } : null
24}
25
26// Each action is a few fixed lines that run inside `tell application "Spotify"`.
27const TRANSPORT: Record<string, string[]> = {
28  resume: ['play'],
29  pause: ['pause'],
30  next: ['next track'],
31  prev: ['previous track'],
32  shuffle: ['set shuffling to not shuffling'],
33  repeat: ['set repeating to not repeating'],
34  'volume-up': ['set level to (sound volume) + 10', 'if level > 100 then set level to 100', 'set sound volume to level'],
35  'volume-down': ['set level to (sound volume) - 10', 'if level < 0 then set level to 0', 'set sound volume to level'],
36}
37
38function script(lines: string[], ...args: string[]): string[] {
39  return ['osascript', ...lines.flatMap(line => ['-e', line]), ...args]
40}
41
42// Prints "closed" instead of launching Spotify when it is not running.
43export function transportArgv(action: string): string[] | null {
44  if (!Object.hasOwn(TRANSPORT, action)) return null
45
46  return script([
47    'if application "Spotify" is not running then return "closed"',
48    'tell application "Spotify"',
49    ...(TRANSPORT[action] ?? []),
50    'end tell',
51    'return "ok"',
52  ])
53}
54
55export function playArgv(target: SpotifyTarget): string[] {
56  return script(
57    [
58      'on run argv',
59      'set trackUri to item 1 of argv',
60      'if application "Spotify" is not running then return "closed"',
61      'tell application "Spotify" to play track trackUri',
62      'return "ok"',
63      'end run',
64    ],
65    target.uri,
66  )
67}
68
69// One line of fields split by the ASCII unit separator, which no title or artist can contain.
70const FIELDS = 11
71const SEPARATOR = '\u001f'
72
73export function statusArgv(): string[] {
74  return script([
75    'if application "Spotify" is not running then return ""',
76    'tell application "Spotify"',
77    'if player state is stopped then return ""',
78    'set sep to character id 31',
79    'set song to current track',
80    'set art to ""',
81    'try',
82    'set art to artwork url of song',
83    'end try',
84    'return (name of song) & sep & (artist of song) & sep & (album of song) & sep & (player state as text) & sep & (((player position) as integer) as text) & sep & (((duration of song) div 1000) as text) & sep & (sound volume as text) & sep & (shuffling as text) & sep & (repeating as text) & sep & art & sep & (spotify url of song)',
85    'end tell',
86  ])
87}
88
89const ARTWORK = /^https:\/\/i\.scdn\.co\/image\/[a-f0-9]{40}$/
90
91function whole(value: string | undefined, max: number): number {
92  return value && /^\d{1,7}$/.test(value.trim()) ? Math.min(Number(value), max) : 0
93}
94
95// The page for a track, rebuilt from the validated id rather than echoed back.
96export function webLink(target: SpotifyTarget): string {
97  return `https://open.spotify.com/${target.kind}/${target.uri.slice(target.uri.lastIndexOf(':') + 1)}`
98}
99
100export function parseTrack(stdout: string): SpotifyNowPlaying | null {
101  const fields = stdout.trim().split(SEPARATOR)
102  if (fields.length !== FIELDS) return null
103  const title = text(fields[0], '')
104  if (!title) return null
105  const target = parseSpotifyTarget(fields[10] ?? '')
106  const artwork = (fields[9] ?? '').trim()
107
108  return {
109    title,
110    artist: text(fields[1], 'Spotify'),
111    album: text(fields[2], ''),
112    isPlaying: fields[3]?.trim() === 'playing',
113    positionSec: whole(fields[4], 359999),
114    durationSec: whole(fields[5], 359999),
115    volume: whole(fields[6], 100),
116    isShuffling: fields[7]?.trim() === 'true',
117    isRepeating: fields[8]?.trim() === 'true',
118    artworkUrl: ARTWORK.test(artwork) ? artwork : null,
119    url: target?.kind === 'track' ? webLink(target) : null,
120  }
121}
122
123// Album art: fetched with fixed commands into a private temp folder, shrunk to a small PNG, then removed.
124// Only a validated Spotify image link and a folder the system just made ever reach these arguments.
125const TEMP_DIR = /^\/[\w./-]*\/tmp\.[A-Za-z0-9]{6,12}$/
126
127// Only kitty and Ghostty draw pictures, so nothing is downloaded for any other terminal.
128export function drawsImages(termProgram: string, term: string): boolean {
129  return /ghostty/i.test(termProgram) || /kitty|ghostty/i.test(term)
130}
131
132export function mktempArgv(): string[] {
133  return ['mktemp', '-d']
134}
135
136export function tempDirOf(stdout: string): string | null {
137  const dir = stdout.trim()
138
139  return TEMP_DIR.test(dir) && !dir.includes('..') ? dir : null
140}
141
142export function fetchArtArgv(url: string, dir: string): string[] | null {
143  return ARTWORK.test(url) ? ['curl', '--fail', '--silent', '--max-time', '10', '--max-filesize', '2000000', '--output', `${dir}/cover.jpg`, url] : null
144}
145
146export function shrinkArtArgv(dir: string): string[] {
147  return ['sips', '-s', 'format', 'png', '-Z', '200', `${dir}/cover.jpg`, '--out', `${dir}/cover.png`]
148}
149
150export function artPath(dir: string): string {
151  return `${dir}/cover.png`
152}
153
154export function cleanupArgv(dir: string): string[] {
155  return ['rm', '-r', dir]
156}
157
158export function searchQuery(raw: string): string {
159  return raw.replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 100)
160}
161
162export function searchUrl(query: string): string {
163  return `https://api.spotify.com/v1/search?type=track&limit=4&q=${encodeURIComponent(query)}`
164}
165
166export function accessTokenOf(body: unknown): string | null {
167  const token = (body as { access_token?: unknown } | null)?.access_token
168
169  return typeof token === 'string' && /^[\w.~+/-]+=*$/.test(token) ? token : null
170}
171
172type SearchItem = { uri?: unknown; name?: unknown; artists?: { name?: unknown }[] }
173
174// The hits worth offering: each URI is checked like any other link, so a bad response cannot reach a script.
175export function tracksOf(body: unknown): SpotifyFoundTrack[] {
176  const items = (body as { tracks?: { items?: unknown } } | null)?.tracks?.items
177  if (!Array.isArray(items)) return []
178
179  return items.slice(0, 4).flatMap((item: SearchItem) => {
180    const target = typeof item?.uri === 'string' ? parseSpotifyTarget(item.uri) : null
181    if (target?.kind !== 'track') return []
182    const artists = Array.isArray(item.artists) ? item.artists.map(artist => text(artist?.name, '')).filter(Boolean) : []
183
184    return [{ uri: target.uri, label: text(artists.length ? `${text(item.name, 'Track')} - ${artists.join(', ')}` : item.name, 'Track').slice(0, 70) }]
185  })
186}
187
188const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
189
190// HTTP Basic credentials for Spotify's token endpoint. Ids and secrets are ASCII, so bytes are char codes.
191export function basicAuth(id: string, secret: string): string {
192  const bytes = Array.from(`${id}:${secret}`, char => char.charCodeAt(0) & 255)
193  let out = ''
194  for (let at = 0; at < bytes.length; at += 3) {
195    const n = ((bytes[at] ?? 0) << 16) | ((bytes[at + 1] ?? 0) << 8) | (bytes[at + 2] ?? 0)
196    out += BASE64.charAt((n >> 18) & 63) + BASE64.charAt((n >> 12) & 63)
197    out += at + 1 < bytes.length ? BASE64.charAt((n >> 6) & 63) : '='
198    out += at + 2 < bytes.length ? BASE64.charAt(n & 63) : '='
199  }
200
201  return `Basic ${out}`
202}
203
hooks/services/music.ts 120 lines
1import type { SpotifyNowPlaying, SpotifySearch } from '../../types'
2import type { Host } from '../host'
3import { parseJson } from '../lib/format'
4import {
5  accessTokenOf,
6  artPath,
7  basicAuth,
8  cleanupArgv,
9  drawsImages,
10  fetchArtArgv,
11  mktempArgv,
12  parseSpotifyTarget,
13  parseTrack,
14  playArgv,
15  searchQuery,
16  searchUrl,
17  shrinkArtArgv,
18  statusArgv,
19  tempDirOf,
20  tracksOf,
21  transportArgv,
22} from '../lib/music'
23
24export type SpotifyConfig = { id: string; secret: string }
25
26const TOKEN_URL = 'https://accounts.spotify.com/api/token'
27const COMMAND_MS = 10000
28
29// The secret only ever goes to Spotify's own https endpoints, and never into the store, a file or a note.
30export function spotifyConfigFrom(options: Record<string, unknown>): SpotifyConfig | null {
31  const id = typeof options.spotify_client_id === 'string' ? options.spotify_client_id.trim() : ''
32  const secret = typeof options.spotify_client_secret === 'string' ? options.spotify_client_secret.trim() : ''
33
34  return id && secret ? { id, secret } : null
35}
36
37async function command(host: Host, argv: string[]): Promise<string | null> {
38  const result = await host.run(argv, { timeoutMs: COMMAND_MS }).catch(() => null)
39
40  return result && result.exitCode === 0 ? result.stdout.trim() : null
41}
42
43function outcome(answer: string | null, done: string): string {
44  if (answer === null) return 'Spotify did not answer. Is the desktop app installed, and is Claude Code allowed to control it?'
45
46  return answer === 'closed' ? 'Spotify is not running. Open it first.' : done
47}
48
49export async function controlSpotify(host: Host, action: string): Promise<string> {
50  const argv = transportArgv(action)
51  if (!argv) return `Unknown Spotify action: ${action}`
52
53  return outcome(await command(host, argv), 'Done.')
54}
55
56export async function playSpotifyLink(host: Host, raw: string): Promise<string> {
57  const target = parseSpotifyTarget(raw)
58  if (!target) return 'That is not a Spotify link or URI.'
59
60  return outcome(await command(host, playArgv(target)), `Playing ${target.kind}.`)
61}
62
63export async function currentTrack(host: Host): Promise<SpotifyNowPlaying | null> {
64  const answer = await command(host, statusArgv())
65
66  return answer ? parseTrack(answer) : null
67}
68
69// The cover as a small PNG (base64), or null when any step fails. The temp folder is always removed.
70export async function coverArt(host: Host, url: string): Promise<string | null> {
71  const [program, term] = await Promise.all([command(host, ['printenv', 'TERM_PROGRAM']), command(host, ['printenv', 'TERM'])])
72  if (!drawsImages(program ?? '', term ?? '')) return null
73  const dir = tempDirOf((await command(host, mktempArgv())) ?? '')
74  const download = dir ? fetchArtArgv(url, dir) : null
75  if (!dir || !download) return null
76
77  try {
78    if ((await command(host, download)) === null || (await command(host, shrinkArtArgv(dir))) === null) return null
79
80    return await host.readBase64(artPath(dir))
81  } catch {
82    return null
83  } finally {
84    await command(host, cleanupArgv(dir))
85  }
86}
87
88// Looks the query up with Spotify's client-credentials flow (no user login).
89export async function findTracks(host: Host, config: SpotifyConfig | null, raw: string): Promise<SpotifySearch> {
90  const query = searchQuery(raw)
91  if (!query) return { tracks: [], problem: 'Type something to search for.' }
92  if (!config) return { tracks: [], problem: 'Add a Spotify client id and secret in the plugin settings to search.' }
93
94  const token = await host.fetch(TOKEN_URL, {
95    method: 'POST',
96    headers: { Authorization: basicAuth(config.id, config.secret), 'Content-Type': 'application/x-www-form-urlencoded' },
97    body: 'grant_type=client_credentials',
98  })
99  const accessToken = token.ok ? accessTokenOf(parseJson(token.text)) : null
100  if (!accessToken) return { tracks: [], problem: `Spotify refused the client id and secret (${token.status}).` }
101
102  const found = await host.fetch(searchUrl(query), { headers: { Authorization: `Bearer ${accessToken}` } })
103  if (!found.ok) return { tracks: [], problem: `Spotify search failed (${found.status}).` }
104  const tracks = tracksOf(parseJson(found.text))
105
106  return { tracks, problem: tracks.length ? null : `No track found for "${query}".` }
107}
108
109// The slash command's search: plays the first hit.
110export async function searchAndPlay(host: Host, config: SpotifyConfig | null, raw: string): Promise<string> {
111  const { tracks, problem } = await findTracks(host, config, raw)
112
113  return tracks[0] ? playSpotifyLink(host, tracks[0].uri) : (problem ?? 'No track found.')
114}
115
116// What the Search box does: a pasted Spotify link plays as is, anything else is searched for.
117export async function playOrSearch(host: Host, config: SpotifyConfig | null, raw: string): Promise<string> {
118  return parseSpotifyTarget(raw) ? playSpotifyLink(host, raw) : searchAndPlay(host, config, raw)
119}
120
hooks/ui/music.tsx 83 lines
1import type { BoxProps, ButtonProps, ElementConstructor, ImageProps, LinkProps, TextProps } from 'claude-code'
2
3import type { SpotifyNowPlaying } from '../../types'
4import { clip } from '../lib/format'
5
6// The elements the view draws with, from `$.ui.resolve(e)`. Views never see `$`.
7// Image only exists on the terminal surface, and only draws in kitty and Ghostty.
8export type Kit = {
9  Box: ElementConstructor<BoxProps>
10  Text: ElementConstructor<TextProps>
11  Button: ElementConstructor<ButtonProps>
12  Link: ElementConstructor<LinkProps>
13  Image?: ElementConstructor<ImageProps>
14}
15
16export type MusicActions = {
17  refresh: () => Promise<void>
18  transport: (action: string) => Promise<void>
19  search: () => Promise<void>
20}
21
22const COVER_COLUMNS = 14
23const COVER_ROWS = 7
24
25function clock(seconds: number): string {
26  return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
27}
28
29function progress(track: SpotifyNowPlaying, columns: number): string {
30  const done = track.durationSec > 0 ? Math.min(columns, Math.round((track.positionSec / track.durationSec) * columns)) : 0
31
32  return `${clock(track.positionSec)} ${'█'.repeat(done)}${'░'.repeat(columns - done)} ${clock(track.durationSec)}`
33}
34
35export function musicView(ui: Kit, track: SpotifyNowPlaying | null, cover: string | null, note: string | null, actions: MusicActions, width: number) {
36  const { Box, Text, Button, Link, Image } = ui
37  const control = (key: string, label: string, action: string, hotkey: string) => (
38    <Button key={`music-${key}`} plain hotkey={hotkey} label={label} onPress={() => actions.transport(action)} />
39  )
40  const textWidth = width - (cover ? COVER_COLUMNS + 2 : 0)
41
42  return (
43    <Box flexDirection="column" gap={1}>
44      <Box justifyContent="space-between">
45        <Text bold>Spotify</Text>
46        <Button key="music-refresh" plain dimColor hotkey="r" label="Refresh" onPress={actions.refresh} />
47      </Box>
48      {track ? (
49        <Box columnGap={2}>
50          {cover && Image && <Image key="music-cover" source={{ png: cover }} columns={COVER_COLUMNS} rows={COVER_ROWS} alt=" " />}
51          <Box flexDirection="column">
52            <Text color={track.isPlaying ? 'green' : undefined}>
53              {track.isPlaying ? '▶' : '❚❚'} {clip(track.title, textWidth - 3)}
54            </Text>
55            <Text dimColor>{clip(track.artist, textWidth)}</Text>
56            {track.album && <Text dimColor>{clip(track.album, textWidth)}</Text>}
57            <Text>{progress(track, Math.max(8, Math.min(30, textWidth - 12)))}</Text>
58            {track.url && <Link href={track.url} label="Open in Spotify" />}
59          </Box>
60        </Box>
61      ) : (
62        <Text dimColor>Nothing playing. Open Spotify, or search for something to start.</Text>
63      )}
64      <Box columnGap={2}>
65        {control('prev', '⏮ Prev', 'prev', 'b')}
66        {control('toggle', track?.isPlaying ? '❚❚ Pause' : '▶ Play', track?.isPlaying ? 'pause' : 'resume', 'p')}
67        {control('next', 'Next ⏭', 'next', 'n')}
68      </Box>
69      {track && (
70        <Box columnGap={2}>
71          {control('vol-down', '− Vol', 'volume-down', 'd')}
72          <Text dimColor>{`${track.volume}%`}</Text>
73          {control('vol-up', 'Vol +', 'volume-up', 'u')}
74          {control('shuffle', `⇄ Shuffle ${track.isShuffling ? 'on' : 'off'}`, 'shuffle', 'z')}
75          {control('repeat', `↻ Repeat ${track.isRepeating ? 'on' : 'off'}`, 'repeat', 'e')}
76        </Box>
77      )}
78      <Button key="music-search" plain hotkey="s" label="Search…" onPress={actions.search} />
79      {note && <Text dimColor>{clip(note, width * 2)}</Text>}
80    </Box>
81  )
82}
83
types/index.d.ts 46 lines
1export type SpotifyNowPlaying = {
2  title: string
3  artist: string
4  album: string
5  isPlaying: boolean
6  positionSec: number
7  durationSec: number
8  volume: number
9  isShuffling: boolean
10  isRepeating: boolean
11  artworkUrl: string | null
12  url: string | null
13}
14
15export type SpotifyFoundTrack = { uri: string; label: string }
16
17// A search never throws: `problem` says why there is nothing to pick from.
18export type SpotifySearch = { tracks: SpotifyFoundTrack[]; problem: string | null }
19
20export type SpotifyAction = 'resume' | 'pause' | 'next' | 'prev' | 'shuffle' | 'repeat' | 'volume-up' | 'volume-down'
21
22// What `$.spotify` gives another plugin. Every call answers with plain data or a sentence to show, and
23// none of them launches Spotify: a closed app is reported, not started.
24export type Spotify = {
25  current: () => Promise<SpotifyNowPlaying | null>
26  control: (action: SpotifyAction) => Promise<string>
27  playLink: (link: string) => Promise<string>
28  find: (query: string) => Promise<SpotifySearch>
29  // The cover as a small base64 PNG, or null (also null in terminals that cannot draw it).
30  cover: (url: string) => Promise<string | null>
31}
32
33declare module 'claude-code' {
34  interface EngineInterface {
35    spotify: Spotify
36  }
37
38  interface PluginState {
39    'spotify-claude-code-mod': {
40      nowPlaying: SpotifyNowPlaying | null
41      note: string | null
42      cover: { url: string; png: string } | null
43    }
44  }
45}
46