SLOPSHOPPER

rickroll

Never gonna give you up: a /rickroll command and a very tempting link above the prompt

newpanebandcommandtoastprocess
v0.1.0no licenseupdated 2026-10-20narze/claude-code-mod-rickroll
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · rickroll
│ ┃ Free Claude Max upgrade ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ ♪ Downloading Rick... │ rickroll │ │ ⏺ Read(src/auth.ts) │ Never gonna give you up, never gonna let │ │ ⎿ Read 6 lines │ you down │ │ ⏺ 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 │ │ › /rickroll │ ⎿ rickroll: You just got rickrolled. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Free Claude Max upgrade
♪ Downloading Rick...
Source 2 files
hooks/register.tsx 223 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Video } from '../types'
5
6export const URL = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
7
8const PANE = 'rickroll'
9const FPS = 15
10
11const isRolled = atom({ plugin: 'rickroll', key: 'isRolled' } as const, false)
12const video = atom({ plugin: 'rickroll', key: 'video' } as const, {
13  phase: 'idle',
14} as Video)
15
16const framePath = (dir: string, frame: number) =>
17  `${dir}/f_${String(frame).padStart(5, '0')}.png`
18
19const run = async ($: EngineInterface, argv: string[]) => {
20  const ran = await $.process.run(argv, { timeoutMs: 600_000 })
21  if (ran.exitCode !== 0) {
22    throw new Error(`${argv[0]} failed: ${ran.stderr.trim().slice(-200)}`)
23  }
24
25  return ran.stdout
26}
27
28// Downloads the video once and cuts it into PNG frames the terminal reads.
29const prepare = async ($: EngineInterface) => {
30  const home = (await run($, ['printenv', 'HOME'])).trim()
31  const dir = `${home}/.cache/claude-rickroll`
32  const mp4 = `${dir}/video.mp4`
33
34  // The marker names the cut, so a new cut replaces old frames.
35  const done = `${dir}/done-4x3`
36
37  if (!(await $.fs.exists(done))) {
38    await run($, ['mkdir', '-p', dir])
39
40    if (!(await $.fs.exists(mp4))) {
41      await update($, video, (): Video => ({ phase: 'preparing', step: 'Downloading Rick...' }))
42      const args = [
43        '-q', '--no-warnings', '-S', 'res:360,ext:mp4:m4a', '--merge-output-format', 'mp4',
44        '-o', mp4, URL,
45      ]
46      // YouTube often breaks an older yt-dlp: fall back to the newest one.
47      try {
48        await run($, ['yt-dlp', ...args])
49      } catch {
50        await run($, ['uvx', '--from', 'yt-dlp[default]', 'yt-dlp', ...args])
51      }
52    }
53
54    await update($, video, (): Video => ({ phase: 'preparing', step: 'Warming up the dance moves...' }))
55    await run($, [
56      'ffmpeg', '-y', '-loglevel', 'error', '-i', mp4,
57      // The upload is 4:3 with black bars on the sides: cut them off.
58      '-vf', `crop=ih*4/3:ih,fps=${FPS},scale=320:-2`, framePath(dir, 0).replace('00000', '%05d'),
59    ])
60    await $.fs.write(done, '')
61  }
62
63  const frames = (await $.fs.list(dir)).filter(entry =>
64    /^f_\d{5}\.png$/.test(entry.name),
65  ).length
66
67  return { dir, mp4, frames }
68}
69
70// The playback of this module copy; a reload starts it over.
71let ticker: Timer | undefined
72let sound: AsyncIterator<unknown> | undefined
73
74function stop() {
75  ticker?.cancel()
76  ticker = undefined
77  void sound?.return?.()
78  sound = undefined
79}
80
81async function play($: EngineInterface) {
82  stop()
83  await update($, video, (): Video => ({ phase: 'preparing', step: 'Loading...' }))
84  try {
85    const { dir, mp4, frames } = await prepare($)
86    const startedAt = await $.clock.now()
87    await update($, video, (): Video => ({ phase: 'playing', dir, frames, frame: 1 }))
88
89    const stream = $.process.spawn({
90      argv: ['ffplay', '-nodisp', '-autoexit', '-loglevel', 'quiet', mp4],
91    })[Symbol.asyncIterator]()
92    sound = stream
93    void (async () => {
94      try {
95        while (!(await stream.next()).done);
96      } catch {
97        $.ui.toast('No ffplay found: Rick dances in silence')
98      }
99    })()
100
101    ticker = $.clock.every(1000 / FPS, async () => {
102      const frame = Math.floor(((await $.clock.now()) - startedAt) / (1000 / FPS)) + 1
103      if (frame > frames) {
104        stop()
105        await update($, video, (): Video => ({ phase: 'done' }))
106        return
107      }
108      await $.ui.blit({
109        requestId: PANE,
110        key: 'video',
111        source: { file: framePath(dir, frame), format: 'png' },
112      })
113    })
114  } catch (error) {
115    stop()
116    const reason = error instanceof Error ? error.message : String(error)
117    await update($, video, (): Video => ({ phase: 'failed', reason }))
118  }
119}
120
121async function roll($: EngineInterface) {
122  // Open first, while the press or command is still behind the call: an asked
123  // pane is placed at any width, an unasked one waits for a wide terminal.
124  const opened = await $.ui.open({ id: PANE, title: 'Free Claude Max upgrade' })
125  if (!opened.isPlaced) $.ui.toast(`Rick is waiting off stage: ${opened.reason}`)
126  $.ui.toast('Never gonna give you up, never gonna let you down')
127  await update($, isRolled, () => true)
128  void play($)
129}
130
131export const register: Register = on => {
132  on('session.start', async ($, e, next) => {
133    await $.command.register({
134      name: 'rickroll',
135      description: 'Claim your free Claude upgrade',
136    })
137
138    return next(e)
139  })
140
141  on('command.run', { command: 'rickroll' }, async $ => {
142    await roll($)
143
144    return { text: 'You just got rickrolled.' }
145  })
146
147  on('ui.close', async ($, e, next) => {
148    if (e.id === PANE) {
149      stop()
150      void update($, video, (): Video => ({ phase: 'idle' })).catch(() => {})
151    }
152
153    return next(e)
154  })
155
156  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
157    const { Box, Text } = $.ui.resolve(e)
158    const now = await read($, video)
159
160    // Only the terminal reads the frames from disk; elsewhere Rick sings.
161    if (now.phase === 'playing' && e.surface !== 'terminal') {
162      return <Text color="magenta">♪ Never gonna give you up (turn up the sound)</Text>
163    }
164
165    if (now.phase !== 'playing' || e.surface !== 'terminal') {
166      const words =
167        now.phase === 'preparing' ? now.step
168        : now.phase === 'failed' ? `Rick could not make it: ${now.reason}`
169        : now.phase === 'done' ? 'You just got rickrolled.'
170        : 'Never gonna give you up'
171
172      return (
173        <Box flexDirection="column">
174          <Text color="magenta">♪ {words}</Text>
175        </Box>
176      )
177    }
178
179    const { Image } = $.ui.resolve(e)
180    // Cells are about twice as tall as wide: 4:3 pixels are 8:3 cells.
181    const width = Math.max(8, e.props.bodyColumns)
182    const height = Math.max(3, e.props.scroll.bodyRows)
183    const columns = Math.min(255, width, Math.floor((height * 8) / 3))
184    const rows = Math.min(255, Math.max(1, Math.round((columns * 3) / 8)))
185
186    return (
187      <Box
188        key="stage"
189        width={width}
190        height={height}
191        justifyContent="center"
192        alignItems="center"
193      >
194        <Image
195          key="video"
196          source={{ file: framePath(now.dir, now.frame), format: 'png' }}
197          columns={columns}
198          rows={rows}
199          alt="Never gonna give you up (your terminal cannot draw pictures)"
200        />
201      </Box>
202    )
203  })
204
205  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
206    if (e.props.hasSurvey) return next(e)
207
208    if (await read($, isRolled)) return next(e)
209
210    const { Box, Button } = $.ui.resolve(e)
211
212    return (
213      <Box>
214        <Button
215          key="claim"
216          label="🎁 Claim your free Claude Max upgrade"
217          onPress={() => void roll($)}
218        />
219      </Box>
220    )
221  })
222}
223
types/index.d.ts 13 lines
1export type Video =
2  | { phase: 'idle' }
3  | { phase: 'preparing'; step: string }
4  | { phase: 'playing'; dir: string; frames: number; frame: number }
5  | { phase: 'done' }
6  | { phase: 'failed'; reason: string }
7
8declare module 'claude-code' {
9  interface PluginState {
10    rickroll: { isRolled: boolean; video: Video }
11  }
12}
13