SLOPSHOPPER

pokeball

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

newbandtoastprompttimer
★ 1v0.1.0no licenseupdated 2026-10-22FutureProg/claude-plugins/Pokeball
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pokeball
› fix the failing auth test and add an audit log call ╭─────────────────────────────╮ │ pokeball │ ⏺ Read(src/auth.ts) │ Gotcha! Task #1 was caught! │ ⎿ 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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Claude Plugins

Small mods for Claude Code (terminal and desktop), written as plugins of function hooks that hot-reload in a session.

FolderWhat 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 lightdarkauto 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.

Installing as a plugin

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>".

Loading them (development)

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>.

Loading it in every session

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" }

Notes on the assets (private repo)

Pokepal/frames, Pokepal/anim and Pokepal/sounds hold art and audio from the Pokémon games. They are here for personal use only

  • Animation frames were rendered from the Spine 4.0.63 skeletons for Pokémon Sleep (as shown on the RaenonX Pokémon Sleep Wiki) with tools/spine-render and the spine-webgl 4.0 runtime. Render with premultiplied alpha off, or the pieces get bright outlines.
  • Voice clips are the Scarlet/Violet sounds (Pumpkaboo's from Z-A), from a personal collection.
  • Desktop pictures are palette APNGs, because the desktop's SVG source is capped at 131,072 characters.

Sources

Poképal

Source 2 files
hooks/register.tsx 132 lines
1import 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}
132
types/index.d.ts 8 lines
1export type Phase = 'idle' | 'working' | 'caught'
2
3declare module 'claude-code' {
4  interface PluginState {
5    pokeball: { phase: Phase; caught: number; frame: number }
6  }
7}
8