SLOPSHOPPER

now-playing

What Spotify is playing, one line above the prompt: track, progress and the lyric being sung, with ⏮ ⏸ ⏭ buttons. /music controls it too. macOS.

newbandcommandprocessnetworktimer
A shopper browsing a rack in a slop shop
README

claude-code-mods-collection

A private reference collection of public Claude Code mods, with a security-oriented analysis.

  • ANALYSIS.md: what each mod can reach, which to install, which to avoid
  • SOURCES.md: upstream repo and pinned commit for every folder in mods/

Nothing here is installed or executed. All code is by its upstream authors under their own licences (MIT, one Apache-2.0); each folder keeps its original LICENSE.

Source 3 files
hooks/register.tsx 308 lines
1// Now Playing: what is Spotify playing?
2//   One line above the prompt: the track and artist, a progress bar, the time, and the
3//   lyric line being sung (synced lyrics from LRCLIB, fetched once per track). Paused, the
4//   line dims; Spotify closed, it goes away. The mod never opens Spotify itself.
5//   ⏮ ⏸ ⏭ at the end of the line are buttons: click them, or ctrl+x tab to the band and
6//   press b, p or n. /music, /music next and /music prev do the same from the prompt.
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface, Register, Timer } from 'claude-code'
9
10import type { Lyrics, Track } from '../types'
11import { bandsFile, parseOrder, stackBands } from './bands'
12
13const PLAYING_POLL_MS = 3_000 // between polls the position moves on with the clock
14const IDLE_POLL_MS = 5_000 // paused or closed
15const LYRICS_TIMEOUT_MS = 5_000
16const BAR = 12
17const LYRIC_LEAD_S = 0.3 // a line shows a moment early, as karaoke apps do
18const LRCLIB = 'https://lrclib.net/api'
19const AGENT = 'now-playing (https://github.com/hamzafer/claude-code-mods)'
20
21// One osascript run: closed, stopped, or the player's state and track, a field per line.
22// The `is running` check comes first, so asking never launches Spotify.
23const READ_SCRIPT = `if application "Spotify" is running then
24  tell application "Spotify"
25    set s to player state as string
26    if s is "stopped" then return "stopped"
27    set t to current track
28    return s & linefeed & (id of t) & linefeed & (name of t) & linefeed & (artist of t) & linefeed & (album of t) & linefeed & (duration of t) & linefeed & (player position as string)
29  end tell
30end if
31return "closed"`
32
33export const ACTIONS = { toggle: 'playpause', next: 'next track', prev: 'previous track' } as const
34export type Action = keyof typeof ACTIONS
35
36// Held by the host, so the line survives a hot reload of this file.
37const track = atom({ plugin: 'now-playing', key: 'track' } as const, null as Track | null)
38const lyrics = atom({ plugin: 'now-playing', key: 'lyrics' } as const, null as Lyrics | null)
39const now = atom({ plugin: 'now-playing', key: 'now' } as const, 0)
40
41let polling: Promise<void> | null = null // a slow osascript must not let two polls overlap
42// Module-level, so a hot reload (which runs register again) can cancel the old loop.
43let timers: Timer[] = []
44
45let bandOrder: Promise<string[]> | undefined // read once per session: restart to apply
46
47export const register: Register = (on, options) => {
48  const wantsLyrics = options.lyrics !== false
49  let lastPoll = 0
50  let isMac = false
51
52  on('session.start', async ($, e, next) => {
53    const r = await next(e)
54    for (const t of timers) t.cancel()
55    timers = []
56    // Off macOS there is no Spotify to ask: no command, no polling, nothing runs again.
57    const uname = await $.process.run(['uname', '-s'], { timeoutMs: 3_000 }).catch(() => null)
58    isMac = uname?.stdout.trim() === 'Darwin'
59    if (!isMac) return r
60    await $.command.register({ name: 'music', description: 'Spotify: play or pause. /music next, /music prev skip' }).catch(() => {}) // a name Claude Code already has is refused: start anyway
61    lastPoll = await $.clock.now()
62    timers = [
63      $.clock.every(250, () => {
64        void (async () => {
65          const t = await $.clock.now()
66          const cur = await read($, track)
67          // Playing, the line moves on without running anything: 4 times a second while a lyric shows, else each second.
68          const l = cur?.state === 'playing' ? await read($, lyrics) : null
69          const hasLyrics = !!l && l.id === cur?.id && l.lines.length > 0
70          if (cur?.state === 'playing' && (hasLyrics || t - (await read($, now)) >= 1000)) await update($, now, () => t)
71          if (t - lastPoll >= (cur?.state === 'playing' ? PLAYING_POLL_MS : IDLE_POLL_MS)) {
72            lastPoll = t
73            await poll($, wantsLyrics)
74          }
75        })().catch(() => {})
76      }),
77    ]
78    void poll($, wantsLyrics).catch(() => {})
79    return r
80  })
81
82  on('session.end', async ($, e, next) => {
83    for (const t of timers) t.cancel()
84    timers = []
85    return next(e)
86  })
87
88  on('command.run', { command: 'music' }, async ($, e) => {
89    const action = actionOf(e.args)
90    if (!action) return { text: 'Usage: /music (play or pause), /music next, /music prev' }
91    const ok = await control($, action, wantsLyrics).catch(() => false)
92    if (!ok) return { text: 'Spotify is not running. Open it first; /music never starts it' }
93    const t = await read($, track)
94    return { text: t ? `${t.state === 'playing' ? '🎵' : '⏸'} ${t.name} · ${t.artist}` : 'Done' }
95  })
96
97  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
98    const rest = await next(e) // what other mods and Claude Code draw here stays
99    const t = await read($, track)
100    const tick = await read($, now) // subscribes the line to the clock
101    if (e.props.hasSurvey || !t) return rest
102    const l = await read($, lyrics)
103    const { Box, Button, Text } = $.ui.resolve(e)
104    const press = (action: Action) => () => void control($, action, wantsLyrics).catch(() => false)
105    const pos = positionAt(t, Math.max(tick, t.at))
106    const line = l && l.id === t.id ? lyricAt(l, pos + LYRIC_LEAD_S) : ''
107    const sung = isRtl(line) ? '' : line // a terminal draws right-to-left scripts garbled, cached lyrics included
108    const isPaused = t.state !== 'playing'
109    const b = bar(pos, t.duration, BAR)
110    const mine = (
111      <Box flexDirection="column">
112        <Box paddingX={1} flexDirection="row">
113          {/* Narrow, the end of this Text goes first (the time, then the bar); the buttons stay. */}
114          <Box flexShrink={1}>
115            <Text wrap="truncate-end">
116              {/* A leading emoji is drawn plain: some terminals shift or clip a bold one. */}
117              <Text dimColor={isPaused}>{isPaused ? '⏸' : '🎵'}</Text>
118              <Text bold={!isPaused} dimColor={isPaused}>{` ${t.name}`}</Text>
119              <Text dimColor>{` · ${t.artist}`}</Text>
120              {!isPaused && <Text color="#1db954">{`  ${b.done}`}</Text>}
121              {!isPaused && <Text dimColor>{b.left}</Text>}
122              {!isPaused && <Text dimColor>{` ${clock(pos)}/${clock(t.duration)}`}</Text>}
123            </Text>
124          </Box>
125          <Box flexShrink={0} flexDirection="row" gap={0} marginLeft={2}>
126            <Button key="prev" hotkey="b" label="⏮" onPress={press('prev')} />
127            <Button key="toggle" hotkey="p" label={isPaused ? '▶' : '⏸'} onPress={press('toggle')} />
128            <Button key="next" hotkey="n" label="⏭" onPress={press('next')} />
129          </Box>
130        </Box>
131        {/* The lyric gets a line of its own, so the buttons never cut it. */}
132        {!isPaused && sung !== '' && (
133          <Box paddingX={1}>
134            <Text dimColor italic wrap="truncate-end">{`   ♪ ${sung}`}</Text>
135          </Box>
136        )}
137      </Box>
138    )
139    return stackBands(rest, 'now-playing', mine, 'above', await (bandOrder ??= readBandOrder($).catch(() => []))) // above what other mods draw, unless the band order says otherwise
140  })
141}
142
143// The band order file, as the claude-code-mods checklist saves it; [] on any error.
144async function readBandOrder($: EngineInterface): Promise<string[]> {
145  const file = bandsFile(await $.env.get('CLAUDE_CONFIG_DIR'), await $.env.get('HOME'))
146  return file && (await $.fs.exists(file)) ? parseOrder(await $.fs.read(file)) : []
147}
148
149// Forgets the cached band order, for tests.
150export function resetBandOrder() {
151  bandOrder = undefined
152}
153
154// Reads Spotify's state; on a new track, fetches its lyrics once. `fresh` waits out a poll
155// already running and reads again, so a control action sees its own result.
156async function poll($: EngineInterface, wantsLyrics: boolean, fresh = false) {
157  if (polling && !fresh) return
158  while (polling) await polling.catch(() => {})
159  polling = pollOnce($, wantsLyrics)
160  try {
161    await polling
162  } finally {
163    polling = null
164  }
165}
166
167async function pollOnce($: EngineInterface, wantsLyrics: boolean) {
168  const before = await read($, track)
169  // Closed last time: a cheap pgrep before any osascript, so an idle session runs almost nothing.
170  if (!before) {
171    const pg = await $.process.run(['pgrep', '-x', 'Spotify'], { timeoutMs: 3_000 }).catch(() => null)
172    if (!pg || pg.exitCode !== 0) return
173  }
174  const asked = await $.clock.now()
175  const out = await $.process.run(['osascript', '-e', READ_SCRIPT], { timeoutMs: 5_000 }).catch(() => null)
176  if (!out || out.exitCode !== 0) return
177  // Spotify read its position partway through the call, not when the answer arrived.
178  const t = parseTrack(out.stdout, asked + ((await $.clock.now()) - asked) / 2)
179  await update($, track, () => t)
180  if (t) await update($, now, () => t.at)
181  if (t && wantsLyrics && t.id !== before?.id) void loadLyrics($, t).catch(() => {})
182}
183
184// Sends one command to Spotify, then reads it back. False when Spotify is not running.
185async function control($: EngineInterface, action: Action, wantsLyrics: boolean) {
186  const script = `if application "Spotify" is running then
187  tell application "Spotify" to ${ACTIONS[action]}
188  return "ok"
189end if
190return "closed"`
191  const r = await $.process.run(['osascript', '-e', script], { timeoutMs: 5_000 })
192  if (r.exitCode === 0 && r.stdout.trim() === 'closed') await update($, track, () => null) // it quit since the last poll
193  if (r.exitCode !== 0 || r.stdout.trim() !== 'ok') return false
194  await poll($, wantsLyrics, true)
195  return true
196}
197
198// Fetches synced lyrics from LRCLIB: the exact match first, then a search. Silent on any error.
199async function loadLyrics($: EngineInterface, t: Track) {
200  const q = (p: Record<string, string>) => new URLSearchParams(p).toString()
201  const seconds = String(Math.round(t.duration))
202  let synced = await getJson($, `${LRCLIB}/get?${q({ track_name: t.name, artist_name: t.artist, album_name: t.album, duration: seconds })}`).then(
203    (j: any) => (typeof j?.syncedLyrics === 'string' ? j.syncedLyrics : ''),
204  )
205  // Terminals can't draw right-to-left scripts (Urdu, Arabic, Persian, Hebrew) joined and in order,
206  // so look for a romanized version; with none, show no lyric rather than garbled text.
207  if (!synced || isRtl(synced)) {
208    const list = await getJson($, `${LRCLIB}/search?${q({ track_name: t.name, artist_name: t.artist })}`)
209    synced = pickSynced(Array.isArray(list) ? list : [], t.duration)
210  }
211  if ((await read($, track))?.id !== t.id) return // the track changed while we waited
212  await update($, lyrics, () => ({ id: t.id, lines: parseLrc(synced) }))
213}
214
215async function getJson($: EngineInterface, url: string): Promise<unknown> {
216  const timeout = $.clock.sleep(LYRICS_TIMEOUT_MS).then(() => null)
217  const fetched = $.http
218    .fetch(url, { headers: { 'User-Agent': AGENT, 'Lrclib-Client': AGENT } })
219    .then(r => (r.ok ? JSON.parse(r.text) : null))
220    .catch(() => null)
221  return Promise.race([fetched, timeout])
222}
223
224export function actionOf(args: string): Action | null {
225  const a = args.trim().toLowerCase()
226  if (a === '' || a === 'play' || a === 'pause' || a === 'toggle') return 'toggle'
227  if (a === 'next' || a === 'skip') return 'next'
228  if (a === 'prev' || a === 'previous' || a === 'back') return 'prev'
229  return null
230}
231
232// osascript's answer as a track; null when Spotify is closed or stopped.
233export function parseTrack(stdout: string, at: number): Track | null {
234  const f = stdout.replace(/\n$/, '').split('\n')
235  if (f.length < 7) return null
236  const [state, id, name, artist, album, ms, position] = f as [string, string, string, string, string, string, string]
237  if (state !== 'playing' && state !== 'paused') return null
238  const duration = Number(ms) / 1000
239  const pos = Number(position.replace(',', '.')) // some locales print 38,7
240  return {
241    state,
242    id,
243    name: name || 'Unknown track',
244    artist: artist || 'Unknown artist',
245    album,
246    duration: Number.isFinite(duration) ? duration : 0,
247    position: Number.isFinite(pos) ? pos : 0,
248    at,
249  }
250}
251
252// Where playback is at `at`, moved on from the last poll while playing.
253export function positionAt(t: Track, at: number) {
254  const p = t.state === 'playing' ? t.position + Math.max(0, at - t.at) / 1000 : t.position
255  return t.duration > 0 ? Math.min(p, t.duration) : p
256}
257
258// `[mm:ss.xx] words` lines, sorted, blank lines kept as pauses.
259export function parseLrc(text: string): [number, string][] {
260  const lines: [number, string][] = []
261  for (const raw of text.split('\n')) {
262    const stamps = [...raw.matchAll(/\[(\d+):(\d+(?:[.:]\d+)?)\]/g)]
263    if (stamps.length === 0) continue
264    const words = raw.replace(/\[[^\]]*\]/g, '').trim()
265    for (const s of stamps) lines.push([Number(s[1]) * 60 + Number(s[2]!.replace(':', '.')), words])
266  }
267  return lines.sort((a, b) => a[0] - b[0])
268}
269
270export function lyricAt(l: Lyrics, seconds: number) {
271  let line = ''
272  for (const [at, words] of l.lines) {
273    if (at > seconds) break
274    line = words
275  }
276  return line
277}
278
279// The search result with synced lyrics whose length is closest to the track's (within 5 s),
280// skipping right-to-left scripts the terminal would draw garbled.
281export function pickSynced(list: any[], duration: number) {
282  const ok = list
283    .filter(x => typeof x?.syncedLyrics === 'string' && x.syncedLyrics && !isRtl(x.syncedLyrics) && Math.abs(Number(x.duration) - duration) <= 5)
284    .sort((a, b) => Math.abs(a.duration - duration) - Math.abs(b.duration - duration))
285  return ok[0]?.syncedLyrics ?? ''
286}
287
288// True when most letters are in a right-to-left script (Hebrew, Arabic, Urdu, Persian).
289export function isRtl(text: string) {
290  const letters = text.replace(/\[[^\]]*\]/g, '').match(/\p{L}/gu) ?? []
291  if (letters.length === 0) return false
292  const rtl = letters.filter(c => /[\u0590-\u08FF\uFB1D-\uFDFF\uFE70-\uFEFF]/.test(c)).length
293  return rtl / letters.length > 0.3
294}
295
296export function bar(pos: number, duration: number, width: number) {
297  const n = duration > 0 ? Math.round(Math.min(1, Math.max(0, pos / duration)) * width) : 0
298  return { done: '━'.repeat(n), left: '─'.repeat(width - n) }
299}
300
301export function clock(seconds: number) {
302  const s = Math.max(0, Math.floor(seconds))
303  const h = Math.floor(s / 3600)
304  const m = Math.floor((s % 3600) / 60)
305  const ss = String(s % 60).padStart(2, '0')
306  return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
307}
308
hooks/bands.ts 73 lines
1// Band order: every band mod ships this same file (canonical copy: shared/bands.ts in the repo).
2//   The order file, <configDir>/claude-code-mods/bands.json, lists mod names top to bottom
3//   (the last sits right above the prompt). Each band mod wraps its band in a Box keyed
4//   `band:<name>` and returns one column keyed `bands`; the next band mod unpacks that
5//   column, adds its own band and sorts again, so the last one to draw sorts them all.
6//   Edit shared/bands.ts, then copy it over every mod's hooks/bands.ts (a repo test checks).
7//   Reading the file needs $, which Claude Code follows only within one file, so each mod
8//   reads it in its own register file with bandsFile and parseOrder, once per session.
9import type { RenderElement, RenderNode } from 'claude-code'
10
11const COLUMN = 'bands'
12const PREFIX = 'band:'
13
14// Where the checklist saves the order: CLAUDE_CONFIG_DIR, or ~/.claude. Undefined with neither.
15export function bandsFile(configDir: string | undefined, home: string | undefined): string | undefined {
16  const dir = configDir ? configDir.replace(/\/+$/, '') : home ? `${home}/.claude` : ''
17  return dir ? `${dir}/claude-code-mods/bands.json` : undefined
18}
19
20// The names in an order file; [] when it is no JSON or not { order: string[] }.
21export function parseOrder(text: string): string[] {
22  try {
23    const order = (JSON.parse(text) as { order?: unknown } | null)?.order
24    return Array.isArray(order) && order.every(n => typeof n === 'string') ? order : []
25  } catch {
26    return []
27  }
28}
29
30/**
31 * One column of bands: `mine` wrapped in a Box keyed `band:<name>`, placed
32 * `at` (above or below) what `next(e)` returned, then sorted by `order`.
33 * With an order, items that are no band come first, then bands not in the
34 * order (in their current order), then the listed ones in file order. With
35 * no order nothing moves, so the screen looks as it did before.
36 */
37export function stackBands(rest: RenderElement | null | undefined, name: string, mine: RenderNode, at: 'above' | 'below', order: string[]): RenderElement {
38  const own: RenderElement = { type: 'Box', props: { key: PREFIX + name, flexDirection: 'column' }, children: [mine] }
39  const others = unpack(rest)
40  const items = at === 'above' ? [own, ...others] : [...others, own]
41  return { type: 'Box', props: { key: COLUMN, flexDirection: 'column' }, children: order.length ? sorted(items, order) : items }
42}
43
44// Another band mod's column gives up its items; anything else is one item.
45function unpack(rest: RenderElement | null | undefined): RenderNode[] {
46  if (!rest) return []
47  if (!isColumn(rest)) return [rest]
48  const kids = rest.children ?? []
49  const owner = rest.part?.plugin
50  // The engine stamped the column with its owner: that stamp belongs on the owner's own band.
51  return kids.map(k => (owner && typeof k !== 'string' && bandName(k) === owner && !('part' in k && k.part) ? { ...k, part: rest.part } : k))
52}
53
54function isColumn(el: RenderElement): el is RenderElement & { type: 'Box' } {
55  return el.type === 'Box' && el.props?.key === COLUMN
56}
57
58function bandName(node: RenderNode): string | undefined {
59  if (typeof node === 'string' || node.type !== 'Box') return undefined
60  const key = node.props?.key
61  return typeof key === 'string' && key.startsWith(PREFIX) ? key.slice(PREFIX.length) : undefined
62}
63
64function sorted(items: RenderNode[], order: string[]): RenderNode[] {
65  const rank = (n: RenderNode) => {
66    const name = bandName(n)
67    if (name === undefined) return -2 // not a band: first
68    const i = order.indexOf(name)
69    return i < 0 ? -1 : i // a band not in the order: on top of the listed ones
70  }
71  return items.map((n, i) => ({ n, i, r: rank(n) })).sort((a, b) => a.r - b.r || a.i - b.i).map(x => x.n)
72}
73
types/index.d.ts 22 lines
1export type Track = {
2  state: 'playing' | 'paused'
3  id: string // Spotify's track URI
4  name: string
5  artist: string
6  album: string
7  duration: number // seconds
8  position: number // seconds, at `at`
9  at: number // when it was read, ms since the epoch
10}
11
12export type Lyrics = {
13  id: string // the track they belong to
14  lines: [number, string][] // seconds into the track, the line sung from then
15}
16
17declare module 'claude-code' {
18  interface PluginState {
19    'now-playing': { track: Track | null; lyrics: Lyrics | null; now: number }
20  }
21}
22