SLOPSHOPPER

spotify

The song Spotify is playing, beside the spinner while Claude works

spinnercommandprocesstimer
v0.1.0no licenseupdated 2026-10-17whirlwin/claude-code-spotify
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · spotify
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /spotify ⎿ spotify: The spinner no longer shows what Spotify plays. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-code-spotify

A Claude Code mod that puts the song Spotify is playing in the spinner. While Claude works, the spinner line reads:

✳ Thinking… ♫ Riding the Winds of Eternity · Rhapsody (6s · thinking with xhigh effort)

The spinner's own word, elapsed time, tokens and effort stay where they are; the song goes after the ellipsis, cut short on a narrow terminal. It only shows while Spotify is playing: paused, closed or not installed, the spinner is left alone. It picks up a new song within five seconds, and is gone the moment the turn ends.

With the halloween mod, the haunted Clawd says his costume's word, then the song, as long as this mod's hook runs before halloween's (see How it works):

▗▄▄▄▄▄▄▄▖ ▟▙
▗▟▙███▟▙▖▘▖▘ Conjuring… ♫ Riding the Winds of Eternity · Rhapsody (3s)
 ▝▛▛▀▜▜▘ ▝

It asks the Spotify desktop app, so it needs macOS. Elsewhere it does nothing.

Install

/plugin install spotify --marketplace whirlwin/claude-code-spotify

Answer y to add the marketplace, then pick a scope.

The first time a song should show, macOS asks whether your terminal may control Spotify; allow it. If you said no, turn it back on in System Settings, Privacy & Security, Automation, under your terminal app.

Update

/plugin update installs from the local clone of the marketplace and does not fetch it first, so refresh the clone before updating:

claude plugin marketplace update claude-code-spotify
claude plugin update spotify@claude-code-spotify

Then run /reload-plugins in an open session, or restart Claude Code.

Use

CommandWhat it does
/spotifyhide the song, or bring it back; says what is playing, or why Spotify could not be asked

How it works

While a turn runs, the mod asks Spotify what is playing through JXA (osascript -l JavaScript): a second after the turn starts, then every five seconds. Between turns it asks nothing. JXA rather than AppleScript because Application('Spotify') neither launches Spotify nor, where it is not installed, opens a "Where is Spotify?" dialog, and a tell application "Spotify" block does one or the other.

The song goes into the spinner's suffix rather than a tree of the mod's own, so the engine still draws everything else on the line, and a mod that draws its own spinner, as halloween does, still draws the suffix it is handed. That needs this mod's hook to run before the other one's. Both are plugins a person installs, and the engine does not promise an order between those: loaded with claude --plugin-dir, this mod runs first. If the song does not show beside Clawd, the engine ordered them the other way round, and halloween would have to pass its spinner on for the song to show.

In the desktop app the song follows the step the spinner row describes.

Develop

claude --plugin-dir .
claude plugin validate .
claude plugin test .

The plugin is named spotify rather than after the repository because Claude Code reserves plugin names starting with claude-. It is not affiliated with Spotify.

Source 3 files
hooks/register.ts 99 lines
1import { atom, read, update } from 'claude-code'
2import type { ProcessRunResult, Register } from 'claude-code'
3
4import { NOW_PLAYING_ARGV, POLL_MS, TICK_MS, isSameTrack, label, labelRoom, parseTrack } from './spotify'
5import type { Track } from './spotify'
6
7const isHidden = atom({ plugin: 'spotify', key: 'isHidden' } as const, false)
8
9const asError = (error: unknown): Error => (error instanceof Error ? error : new Error(String(error)))
10
11export const register: Register = on => {
12  let track: Track | null = null
13  let isTurnRunning = false
14  let isPolling = false
15  let ticksToPoll = 0
16  // Why the last poll could not ask Spotify, for /spotify to say; null when it could.
17  let problem: string | null = null
18
19  // Takes in what osascript answered; true when the song to show changed.
20  function hear(answer: ProcessRunResult | Error): boolean {
21    let playing: Track | null = null
22    if (answer instanceof Error) {
23      // No osascript (not macOS), or it ran past the engine's 30 s limit.
24      problem = answer.message
25    } else if (answer.exitCode !== 0) {
26      problem = answer.stderr.trim() || `osascript exited with ${answer.exitCode}`
27    } else {
28      problem = null
29      playing = parseTrack(answer.stdout)
30    }
31    if (isSameTrack(track, playing)) return false
32    track = playing
33    return true
34  }
35
36  on('session.start', async ($, e, next) => {
37    await $.command.register({
38      name: 'spotify',
39      description: 'Show or hide the song Spotify is playing beside the spinner',
40    })
41    // Only while a turn runs: between turns there is no spinner to put it in.
42    // No timeout of our own on osascript: the first run waits on the macOS
43    // Automation prompt, and killing it would raise the prompt again.
44    $.clock.every(TICK_MS, () => {
45      if (!isTurnRunning || isPolling || --ticksToPoll > 0) return
46      ticksToPoll = POLL_MS / TICK_MS
47      isPolling = true
48      void (async () => {
49        try {
50          if (await read($, isHidden)) return
51          if (hear(await $.process.run(NOW_PLAYING_ARGV).catch(asError))) $.ui.invalidate('ui.render')
52        } finally {
53          isPolling = false
54        }
55      })()
56    })
57    return next(e)
58  })
59
60  on('turn.start', async ($, e, next) => {
61    isTurnRunning = true
62    ticksToPoll = 0
63    return next(e)
64  })
65
66  on('turn.complete', async ($, e, next) => {
67    // A subagent's turn ends here too, inside the main turn, so only the main
68    // loop's end stops the polls. The song is dropped rather than shown stale
69    // when the next turn starts.
70    if (e.agentId === undefined) {
71      isTurnRunning = false
72      track = null
73    }
74    return next(e)
75  })
76
77  on('command.run', { command: 'spotify' }, async $ => {
78    const hidden = await update($, isHidden, was => !was)
79    if (!hidden) hear(await $.process.run(NOW_PLAYING_ARGV).catch(asError))
80    $.ui.invalidate('ui.render')
81    if (hidden) return { text: 'The spinner no longer shows what Spotify plays.' }
82    if (track !== null) return { text: `The spinner shows what Spotify plays: ${label(track, Infinity)}` }
83    if (problem !== null) return { text: `The spinner shows what Spotify plays, but Spotify could not be asked: ${problem}` }
84    return { text: 'The spinner shows what Spotify plays, once something plays.' }
85  })
86
87  // The song goes after the engine's ellipsis rather than in place of the
88  // word, so the word, elapsed time and tokens stay, and a mod drawing its
89  // own spinner (halloween) still draws the suffix it is handed.
90  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
91    if (track === null || (await read($, isHidden))) return next(e)
92    const text = e.props.message ?? e.props.word
93    // The engine leaves its ellipsis off a text that already ends in one.
94    const ellipsis = text.endsWith('…') ? '' : e.props.suffix
95    const suffix = `${ellipsis} ${label(track, labelRoom(e.viewport?.columns))}`
96    return next({ ...e, props: { ...e.props, suffix } })
97  })
98}
99
hooks/spotify.ts 68 lines
1export type Track = { name: string; artist: string }
2
3// Often enough to catch a skip within a few seconds, rarely enough that the
4// osascript it starts (about 0.1 s) goes unnoticed.
5export const POLL_MS = 5000
6// How soon a turn that has just started asks: the clock checks every tick
7// whether a poll is due.
8export const TICK_MS = 1000
9
10// JXA rather than AppleScript: `Application('Spotify')` neither launches
11// Spotify nor, where it is not installed, opens a "Where is Spotify?" dialog,
12// and a `tell application "Spotify"` block does one or the other.
13const NOW_PLAYING = `
14const spotify = Application('Spotify')
15if (!spotify.running() || spotify.playerState() !== 'playing') {
16  'null'
17} else {
18  const track = spotify.currentTrack
19  JSON.stringify({ name: track.name(), artist: track.artist() })
20}`
21
22export const NOW_PLAYING_ARGV = ['osascript', '-l', 'JavaScript', '-e', NOW_PLAYING] as const
23
24// What the spinner draws besides the label (its glyph, the word, elapsed
25// time, tokens), so a long title is cut rather than wrapping the line.
26const SPINNER_COLUMNS = 60
27const MIN_LABEL = 16
28const MAX_LABEL = 60
29const LABEL_WITHOUT_VIEWPORT = 40
30
31/**
32 * The track osascript printed, or null when nothing is playing or the output
33 * is not a track (it comes from another process, so nothing is assumed).
34 */
35export function parseTrack(stdout: string): Track | null {
36  let value: unknown
37  try {
38    value = JSON.parse(stdout)
39  } catch {
40    return null
41  }
42  if (typeof value !== 'object' || value === null) return null
43  const { name, artist } = value as Record<string, unknown>
44  if (typeof name !== 'string' || name.trim() === '') return null
45  return { name: name.trim(), artist: typeof artist === 'string' ? artist.trim() : '' }
46}
47
48export function isSameTrack(a: Track | null, b: Track | null): boolean {
49  return a?.name === b?.name && a?.artist === b?.artist
50}
51
52/**
53 * How many columns the label may take on a terminal this wide.
54 */
55export function labelRoom(columns: number | undefined): number {
56  if (columns === undefined) return LABEL_WITHOUT_VIEWPORT
57  return Math.min(MAX_LABEL, Math.max(MIN_LABEL, columns - SPINNER_COLUMNS))
58}
59
60/**
61 * `♫ Song · Artist`, cut with an ellipsis to fit `room` columns.
62 */
63export function label(track: Track, room: number): string {
64  const text = track.artist === '' ? track.name : `${track.name} · ${track.artist}`
65  const chars = [...`♫ ${text}`]
66  return chars.length <= room ? chars.join('') : `${chars.slice(0, room - 1).join('').trimEnd()}…`
67}
68
types/index.d.ts 8 lines
1export type IsHidden = boolean
2
3declare module 'claude-code' {
4  interface PluginState {
5    'spotify': { isHidden: IsHidden }
6  }
7}
8