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

hooks/register.tsx 223 lines1import { 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}
223types/index.d.ts 13 lines1export 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