A Pokéball above the prompt: it wobbles while Claude works and clicks shut when a turn completes.

Small mods for Claude Code (terminal and desktop), written as plugins of function hooks that hot-reload in a session.
| Folder | What it is | ||
|---|---|---|---|
Pokepal/ | A buddy for your session, with four to pick from: Fuecoco, Mimikyu, Pumpkaboo and Drifloon (/pokepal for a random one, /pokepal go <name or national dex number> for a specific one; /pokepal open-panel shows the side pane with buttons, which stays closed by default; /pokepal sleep / wake put him to sleep or wake him; `/pokepal bg light | dark | auto sets the strip's colour on desktop (auto follows the theme setting); /pokepal off turns him off for good, /pokepal on` back on). A side pane plus a mini version above the prompt. Moods follow the session (walks while Claude works, cheers when a turn is done, dozes off when you're away), with voice clips. |
Pokeball/ | A Pokéball above the prompt that rocks while Claude works and clicks shut when a turn finishes. | ||
tools/spine-render/ | The scripts used to turn Pokémon Sleep Spine animations into frames (see below): renderall.html renders every animation of the listed Pokémon, process.py crops and packs them, mkapng.py makes the desktop animations. |
This repo is a plugin marketplace. From a terminal session:
/plugin marketplace add FutureProg/claude-plugins
/plugin install pokepal@nicks-plugins
/plugin install pokeball@nicks-plugins
Or from a shell: claude plugin marketplace add FutureProg/claude-plugins, then claude plugin install pokepal@nicks-plugins. The repo is private, so you need GitHub access set up on your machine. To try it from a local checkout, use claude plugin marketplace add "<path to this repo>".
In a terminal session:
claude --plugin-dir "/Users/nick/Dev/Claude Plugins/Pokepal"
In the desktop app, load the plugin-authoring skill once per session, then link the folders into that session's hot-reload folder:
ln -s "/Users/nick/Dev/Claude Plugins/Pokepal" ~/.claude/dev-mods/<session-id>/pokepal
ln -s "/Users/nick/Dev/Claude Plugins/Pokeball" ~/.claude/dev-mods/<session-id>/pokeball
The desktop's hot-reload watcher misses edits made through symlinks, so use real copies and keep them in sync after each edit:
tools/sync-dev.sh [session-id] # copies Pokepal/ (and Pokeball/ if it is already there) into the newest ~/.claude/dev-mods/<session-id>/
Check a mod with claude plugin validate <folder>.
Put the folder in CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json (colon-separated for several folders). Every new session, terminal or desktop app, then loads it:
"env": { "CLAUDE_CODE_PLUGIN_DIRS": "/Users/nick/Dev/Claude Plugins/Pokepal" }
Pokepal/frames, Pokepal/anim and Pokepal/sounds hold art and audio from the Pokémon games. They are here for personal use only
tools/spine-render and the spine-webgl 4.0 runtime. Render with premultiplied alpha off, or the pieces get bright outlines.hooks/register.tsx 132 lines1import type { Register } from 'claude-code'
2
3// pre-rotated frames (centre, left, right) per animation step: the wobble, then a pause
4const WOBBLE = ['c', 'l', 'l', 'c', 'r', 'r', 'c', 'l', 'l', 'c', 'r', 'r', 'c', 'c', 'c', 'c', 'c', 'c']
5const TICK_MS = 110
6// desktop: try the sprite inside the SVG (set false to go back to the vector ball)
7const DESKTOP_PNG = true
8
9// the ball's row under whatever another mod drew above the prompt, if anything
10function stack(Box: any, below: any, mine: any) {
11 return below ? (
12 <Box flexDirection="column">
13 {below}
14 {mine}
15 </Box>
16 ) : (
17 mine
18 )
19}
20
21export const register: Register = on => {
22 let timer: { cancel: () => void } | undefined
23 let sprite: Record<string, string> | undefined
24 let png: string | undefined
25
26 on('prompt.submit', async ($, e, next) => {
27 await $.state.set({ plugin: 'pokeball', key: 'phase' } as const, 'working')
28 await $.state.set({ plugin: 'pokeball', key: 'frame' } as const, 0)
29
30 timer?.cancel()
31 let frame = 0
32 timer = $.clock.every(TICK_MS, () => {
33 frame += 1
34 $.state.set({ plugin: 'pokeball', key: 'frame' } as const, frame)
35 })
36
37 return next(e)
38 })
39
40 on('turn.complete', async ($, e, next) => {
41 timer?.cancel()
42 timer = undefined
43
44 const { value = 0 } = await $.state.get({ plugin: 'pokeball', key: 'caught' } as const)
45 await $.state.set({ plugin: 'pokeball', key: 'caught' } as const, value + 1)
46 await $.state.set({ plugin: 'pokeball', key: 'phase' } as const, 'caught')
47 $.ui.toast(`Gotcha! Task #${value + 1} was caught!`)
48
49 return next(e)
50 })
51
52 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
53 // other mods draw here too (Fuecoco's mini band): keep what is below and stack the ball under it
54 const below = await next(e)
55
56 if (e.props.hasSurvey) {
57 return below
58 }
59
60 if (!sprite && e.surface === 'terminal') {
61 sprite = {}
62 for (const name of ['c', 'l', 'r']) {
63 sprite[name] = (await $.fs.read(`${$.plugin.root}/frames/${name}.png`, { as: 'bytes' })).base64
64 }
65 }
66
67 const { value: phase = 'idle' } = await $.state.get({ plugin: 'pokeball', key: 'phase' } as const)
68 const { value: caught = 0 } = await $.state.get({ plugin: 'pokeball', key: 'caught' } as const)
69
70 const isWorking = phase === 'working'
71 const msg = isWorking ? 'The ball shakes…' : phase === 'caught' ? 'Gotcha!' : 'A wild task appeared!'
72
73 if (e.surface === 'terminal') {
74 const { Box, Text, Image } = $.ui.resolve(e)
75 // only the terminal reads the frame: desktop's SVG animates itself, and a redraw per tick makes it blink
76 const { value: frame = 0 } = await $.state.get({ plugin: 'pokeball', key: 'frame' } as const)
77 const tilt = isWorking ? WOBBLE[frame % WOBBLE.length] : 'c'
78
79 const mine = (
80 <Box alignItems="center">
81 <Image key="ball" source={{ png: sprite?.[tilt] ?? '' }} columns={6} rows={3} alt="(●)" />
82 <Text dimColor>
83 {' '}
84 {msg} · caught: {caught}
85 </Text>
86 </Box>
87 )
88
89 return stack(Box, below, mine)
90 }
91
92 if (e.surface === 'desktop') {
93 const { Box, Text, Svg } = $.ui.resolve(e)
94 // SMIL rocks the ball about its base: tip left, tip right, rest; the viewBox has room for the swing
95 const rock = isWorking
96 ? '<animateTransform attributeName="transform" type="rotate" values="0 28 55;-14 28 55;0 28 55;14 28 55;0 28 55;0 28 55" keyTimes="0;.15;.3;.45;.6;1" dur="1.4s" repeatCount="indefinite"/>'
97 : ''
98 const button = phase === 'caught' ? '#ffd030' : '#f4f4f4'
99 const vector =
100 `<circle cx="28" cy="28" r="27" fill="#202020"/>` +
101 `<path d="M2 28A26 26 0 0 1 54 28Z" fill="#e03030"/>` +
102 `<path d="M2 28A26 26 0 0 0 54 28Z" fill="#f4f4f4"/>` +
103 `<rect x="1" y="24" width="54" height="8" fill="#202020"/>` +
104 `<circle cx="28" cy="28" r="10" fill="#202020"/><circle cx="28" cy="28" r="6" fill="${button}"/>`
105
106 png ??= (await $.fs.read(`${$.plugin.root}/sprite.png`, { as: 'bytes' })).base64
107 // both href spellings, so whichever the frame honours draws it
108 const uri = `data:image/png;base64,${png}`
109 const picture = `<image href="${uri}" xlink:href="${uri}" x="0" y="0" width="56" height="56" style="image-rendering:pixelated" />`
110
111 const source =
112 `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="-12 -2 80 60" width="80" height="60"><g>${rock}` +
113 (DESKTOP_PNG ? picture : vector) +
114 `</g></svg>`
115
116 const mine = (
117 <Box>
118 <Svg source={source} alt="Pokéball" width={40} height={30} />
119 <Text dimColor>
120 {' '}
121 {msg} · caught: {caught}
122 </Text>
123 </Box>
124 )
125
126 return stack(Box, below, mine)
127 }
128
129 return below
130 })
131}
132types/index.d.ts 8 lines1export type Phase = 'idle' | 'working' | 'caught'
2
3declare module 'claude-code' {
4 interface PluginState {
5 pokeball: { phase: Phase; caught: number; frame: number }
6 }
7}
8