SLOPSHOPPER

pumpfun-utilities

Your open pump.fun positions in a pane while Claude works, and a markdown trade journal that fills itself. Read-only, no keys, no trading

newpanecommandprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pumpfun-utilities
│ ┃ Positions ✕ › fix the failing auth test and add an audit log call │ ┃ positions failed (exit 0): │ ┃ updated never, every 30s [ Reveal values ][ ⏺ Read(src/auth.ts) │ ⎿ 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 │ │ › /positions │ ⎿ pumpfun-utilities: Positions pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Positions
positions failed (exit 0): updated never, every 30s [ Reveal values ][ Close ]
README

pumpfun-utilities

Two things for people who trade on pump.fun and use Claude Code.

A pane that shows your open positions and how much each is up or down, and opens by itself while Claude is working on something else. And a markdown trade journal that fills itself from on-chain data, with a skill that teaches Claude to help you keep it honest.

It is read-only. It uses your public wallet address, no private key, and it cannot sign, send or sell anything. There are no sell buttons and there will not be.

What you get

/positions opens the pane. It also opens on its own when a turn runs longer than 10 seconds, and closes when the turn ends. A header names the columns: Token, Value, PnL $, PnL %, Wallet. Each row is a position on any chain pump.fun tracks, with its unrealized profit or loss against your cost basis, in green or red, and the wallet it sits in. A total closes the list. Dollar values are hidden until you press Reveal, so a screen share shows percentages only. It refreshes while it is open.

/journal checks your wallet against the journal and prints what is new, what disagrees, and what is open. /journal write appends the new closed trades to this month's file, rebuilds an open-positions snapshot and saves the threads under your own callouts. /journal write 7 looks back 7 days instead of 30.

The pumpfun-journal skill is what Claude follows while you do this. The main rule: it never invents a thesis, an outcome or a mistake. Those lines stay not recorded until you say something.

The positions pane with values hidden: token, percentage and wallet only

The same pane after Reveal: value and profit or loss in dollars as well

These are demo positions (demo setting, or PUMPFUN_DEMO=1 claude): made up, no wallet needed. A first look takes one command.

Install

You need Claude Code 2.1.287 or later (the version that introduced mods) and Python 3.

claude plugin marketplace add KamiFin/pumpfun-utilities
claude plugin install pumpfun-utilities@pumpfun-utilities
claude plugin configure pumpfun-utilities@pumpfun-utilities

Then start a new session. If the pane never appears, mods may be switched off for installed plugins in your account's rollout. Setting CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 in the env block of ~/.claude/settings.json turns them on.

Settings

Nothing is hardcoded. Everything personal is a plugin setting, filled in with claude plugin configure or /config.

SettingRequiredWhat it is
walletyes, unless demoYour main wallet address. Public, read-only.
demonoShow made-up positions in the pane, with no wallet and no network call. For a first look or a screenshot that shows nothing personal.
mainLabelnoThe name shown for your main wallet in the Wallet column, for example your pump.fun username. Default main.
extraWalletsnoOther wallets as label=address,label=address.
journalDirnoFolder for the journal. Empty means ~/pumpfun-journal. Point it at an Obsidian vault if you like.
heliusKeynoA Helius API key, only for the rewards section of /journal. Marked sensitive, so it goes to secure storage, not settings.json.
refreshSecondsnoPane refresh, default 30. Below about 20 pump.fun starts answering 429.
minSecondsnoHow long a turn runs before the pane opens, default 10.
minUsdnoHide positions worth less than this, default 2.

The script also runs on its own, outside Claude Code, with the same settings as environment variables: PUMPFUN_WALLET, PUMPFUN_EXTRA_WALLETS, PUMPFUN_JOURNAL_DIR, PUMPFUN_HELIUS_KEY.

PUMPFUN_WALLET=<address> python3 scripts/pumpfun_journal.py           # dry run
PUMPFUN_WALLET=<address> python3 scripts/pumpfun_journal.py --write   # append new trades

The journal

One markdown file per month, one block per closed position, identified by ca: <mint>. It is append-only: the script never edits a block you or Claude wrote. A block looks like this:

## 2026-10-03 EXAMPLE "Example Coin" (Solana, pump.fun) [CLOSED, auto-populated: 2 buy(s), 1 sell(s)]
ca: <mint>
source: pump.fun trades API, auto-populated by pumpfun_journal.py, not manually verified
size: $150.22
entry: $41.2K mc
exit: $38.9K mc
pnl: $-13.72 (-9.1%)
thesis: not recorded
outcome: not recorded
mistake: not recorded

Entry and exit are rebuilt from the on-chain buy and sell legs, not from the API's own aggregate, which hides rebuys. Rewards and airdrops are reported separately and never mixed into trade profit. skills/pumpfun-journal/patterns-template.md is a starting point for a rules file you can check open positions against.

Things to know

The data comes from pump.fun's frontend API. It is public and needs no key, but it is not documented, can change without notice and rate-limits you. If it breaks, this breaks. Check pump.fun's terms of use before you rely on it.

A Claude Code mod runs with the same access Claude Code has on your machine. Read the source before you load one. This one is a few hundred lines, makes requests only to pump.fun and, if you set a key, Helius, and writes only to your journal folder.

This is a journal and a viewer, not advice. Nothing here tells you what to buy or sell.

Development

claude plugin validate .
claude plugin test .
python3 -m unittest discover -s tests

The mod is in hooks/, the journal script in scripts/, the skill in skills/. Contributions that keep it read-only are welcome.

Licence

MIT.

Source 3 files
hooks/register.tsx 193 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { columns, headerCells, parsePositions, rowCells, totalCells } from './parse'
5
6const PANE = 'positions-pane'
7
8const rows = atom({ plugin: 'pumpfun-utilities', key: 'rows' } as const, [])
9const failed = atom({ plugin: 'pumpfun-utilities', key: 'failed' } as const, [])
10const isBusy = atom({ plugin: 'pumpfun-utilities', key: 'isBusy' } as const, false)
11const problem = atom({ plugin: 'pumpfun-utilities', key: 'problem' } as const, null)
12const updatedAt = atom({ plugin: 'pumpfun-utilities', key: 'updatedAt' } as const, '')
13const isRevealed = atom({ plugin: 'pumpfun-utilities', key: 'isRevealed' } as const, false)
14const isOpen = atom({ plugin: 'pumpfun-utilities', key: 'isOpen' } as const, false)
15const isAuto = atom({ plugin: 'pumpfun-utilities', key: 'isAuto' } as const, false)
16const turn = atom({ plugin: 'pumpfun-utilities', key: 'turn' } as const, '')
17
18// Top-level on purpose: the validator only lets `$` be passed to functions declared here.
19// The script is read-only in this mode: no journal, no snapshot, no wallet address printed.
20type Settings = { env: Record<string, string>; minUsd: number; mainLabel: string }
21
22const SCRIPT = 'scripts/pumpfun_journal.py'
23
24async function refresh($: EngineInterface, cfg: Settings) {
25  // A run can outlast the interval; never stack a second one on top of it.
26  if (await read($, isBusy)) return
27
28  await update($, isBusy, () => true)
29
30  try {
31    const out = await $.process.run(
32      ['python3', `${$.plugin.root}/${SCRIPT}`, '--open-json', '--min-usd', String(cfg.minUsd)],
33      { env: cfg.env, timeoutMs: 45_000 },
34    )
35    const parsed = out.exitCode === 0 ? parsePositions(out.stdout) : null
36
37    if (parsed) {
38      await update($, rows, () => parsed.rows)
39      await update($, failed, () => parsed.failed)
40      await update($, problem, () => null)
41      await update($, updatedAt, () => new Date().toTimeString().slice(0, 5))
42    } else {
43      await update($, problem, () => `positions failed (exit ${out.exitCode}): ${out.stderr.trim().slice(0, 100)}`)
44    }
45  } catch {
46    await update($, problem, () => 'python3 or the journal script is not reachable')
47  } finally {
48    await update($, isBusy, () => false)
49  }
50}
51
52async function show($: EngineInterface, cfg: Settings, isAutomatic: boolean) {
53  await update($, isOpen, () => true)
54  await update($, isAuto, () => isAutomatic)
55  await $.ui.open({ id: PANE, title: 'Positions' })
56  await refresh($, cfg)
57}
58
59// The pane opens only if the turn that armed the timer is still the running one.
60async function openIfStillWorking($: EngineInterface, cfg: Settings, id: string) {
61  if ((await read($, turn)) === id && !(await read($, isOpen))) {
62    await show($, cfg, true)
63  }
64}
65
66export const register: Register = (on, options) => {
67  // Settings reach the script as environment variables, so no secret sits in a command line.
68  const env: Record<string, string> = { PUMPFUN_WALLET: String(options.wallet ?? '') }
69
70  if (options.demo) env.PUMPFUN_DEMO = '1'
71
72  if (options.extraWallets) env.PUMPFUN_EXTRA_WALLETS = String(options.extraWallets)
73  if (options.journalDir) env.PUMPFUN_JOURNAL_DIR = String(options.journalDir)
74  if (options.heliusKey) env.PUMPFUN_HELIUS_KEY = String(options.heliusKey)
75
76  const afterMs = Math.max(1, Number(options.minSeconds)) * 1000
77  const cfg: Settings = { env, minUsd: Number(options.minUsd), mainLabel: String(options.mainLabel || 'main') }
78  const everyMs = Math.max(5, Number(options.refreshSeconds)) * 1000
79
80  on('session.start', async ($, e, next) => {
81    await $.command.register({
82      name: 'positions',
83      description: 'Open the positions pane: open Solana positions and their profit or loss',
84    })
85    // Polls only while the pane is open, so an idle session makes no calls.
86    await $.command.register({
87      name: 'journal',
88      description: 'Check the pump.fun journal: /journal for a dry run, /journal write to append new trades, optional number of days',
89    })
90    $.clock.every(everyMs, async () => {
91      if (await read($, isOpen)) {
92        await refresh($, cfg)
93      }
94    })
95
96    return next(e)
97  })
98
99  on('turn.start', async ($, e, next) => {
100    await update($, turn, () => e.turnId)
101    $.clock.after(afterMs, () => openIfStillWorking($, cfg, e.turnId))
102
103    return next(e)
104  })
105
106  on('turn.complete', async ($, e, next) => {
107    if (!e.agentId) {
108      await update($, turn, () => '')
109
110      if ((await read($, isOpen)) && (await read($, isAuto))) {
111        await update($, isOpen, () => false)
112        await $.ui.close({ id: PANE })
113      }
114    }
115
116    return next(e)
117  })
118
119  on('command.run', { command: 'positions' }, async $ => {
120    await show($, cfg, false)
121
122    return { text: 'Positions pane opened.' }
123  })
124
125  on('command.run', { command: 'journal' }, async ($, e) => {
126    const words = e.args.split(/\s+/).filter(Boolean)
127    const days = words.find(word => /^\d{1,3}$/.test(word))
128    const args = [`${$.plugin.root}/${SCRIPT}`, ...(words.includes('write') ? ['--write'] : [])]
129
130    if (days) args.push('--since-days', days)
131
132    const out = await $.process.run(['python3', ...args], { env: cfg.env, timeoutMs: 600_000 })
133    const text = out.exitCode === 0 ? out.stdout : `journal failed (exit ${out.exitCode}): ${out.stderr.trim()}`
134
135    // The tail holds the verdict (NEW, MISMATCH, what was written); a long run is cut at the front.
136    return { text: text.length > 8000 ? `...\n${text.slice(-8000)}` : text }
137  })
138
139  on('ui.close', { id: PANE }, async ($, e, next) => {
140    await update($, isOpen, () => false)
141
142    return next(e)
143  })
144
145  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
146    const { Box, Button, Text } = $.ui.resolve(e)
147    const list = await read($, rows)
148    const trouble = await read($, problem)
149    const at = await read($, updatedAt)
150    const missing = await read($, failed)
151    const revealed = await read($, isRevealed)
152    const sum = { upnl: list.reduce((acc, row) => acc + row.upnl, 0) }
153    const tint = (n: number) => (n >= 0 ? 'green' : 'red')
154    const cols = columns(revealed)
155    // One row of fixed-width boxes; numbers sit at the right edge of their box.
156    const table = (cells: string[], color: string | undefined, isBold: boolean) => (
157      <Box columnGap={1}>
158        {cells.map((cell, i) => (
159          <Box key={cols[i].label} width={cols[i].width} justifyContent={cols[i].isRight ? 'flex-end' : 'flex-start'}>
160            <Text color={color} dimColor={color === undefined} bold={isBold}>
161              {cell}
162            </Text>
163          </Box>
164        ))}
165      </Box>
166    )
167
168    return (
169      <Box flexDirection="column">
170        {trouble && <Text color="red">{trouble}</Text>}
171        {missing.length > 0 && <Text color="yellow">Could not read: {missing.join(', ')}</Text>}
172        {list.length === 0 && !trouble && <Text dimColor>No open positions above the dust floor.</Text>}
173        {list.length > 0 && table(headerCells(revealed), undefined, false)}
174        {list.map(row => (
175          <Box key={`${row.wallet}-${row.chain}-${row.symbol}`}>
176            {table(rowCells(row, revealed, cfg.mainLabel), tint(row.upnl), false)}
177          </Box>
178        ))}
179        {list.length > 0 && table(totalCells(list, revealed), tint(sum.upnl), true)}
180        <Box>
181          <Text dimColor>updated {at || 'never'}, every {Math.round(everyMs / 1000)}s </Text>
182          <Button
183            key="reveal"
184            label={revealed ? 'Hide values' : 'Reveal values'}
185            onPress={() => update($, isRevealed, v => !v)}
186          />
187          <Button key="close" label="Close" onPress={() => $.ui.close({ id: PANE })} />
188        </Box>
189      </Box>
190    )
191  })
192}
193
hooks/parse.ts 94 lines
1import type { Position } from '../types'
2
3// Reads the JSON `python3 scripts/pumpfun-trades.py --open-json` prints:
4// {rows: [{symbol, mint, chain, wallet, value, cost, upnl, mc}], failed: [wallet labels]}.
5// Anything else is a failed run, not an empty list.
6export function parsePositions(text: string): { rows: Position[]; failed: string[] } | null {
7  let data: any
8
9  try {
10    data = JSON.parse(text)
11  } catch {
12    return null
13  }
14
15  if (!data || !Array.isArray(data.rows)) {
16    return null
17  }
18
19  const rows = data.rows
20    .filter((row: any) => row && typeof row.symbol === 'string' && typeof row.value === 'number')
21    .map((row: any) => ({
22      symbol: row.symbol,
23      chain: typeof row.chain === 'string' ? row.chain : '',
24      wallet: typeof row.wallet === 'string' ? row.wallet : '',
25      value: row.value,
26      cost: typeof row.cost === 'number' ? row.cost : 0,
27      upnl: typeof row.upnl === 'number' ? row.upnl : row.value - (row.cost ?? 0),
28    }))
29  const failed = Array.isArray(data.failed) ? data.failed.map(String) : []
30
31  return { rows, failed }
32}
33
34// The wallet column: the script calls the main wallet "main"; the user's own name for it is a setting.
35export function walletName(row: Position, mainLabel: string): string {
36  return row.wallet === 'main' || row.wallet === '' ? mainLabel : row.wallet
37}
38
39export type Column = { label: string; width?: number; isRight: boolean }
40
41// Columns are drawn as boxes of a fixed width in character cells, not as space-padded text: a
42// surface with a proportional font (the desktop app) lines boxes up where padding would not.
43// With values hidden only the percentage shows.
44export function columns(revealed: boolean): Column[] {
45  const wallet = { label: 'Wallet', isRight: false }
46  const token = { label: 'Token', width: 10, isRight: false }
47  const pct = { label: 'PnL %', width: 9, isRight: true }
48
49  return revealed
50    ? [token, { label: 'Value', width: 9, isRight: true }, { label: 'PnL $', width: 10, isRight: true }, pct, wallet]
51    : [token, pct, wallet]
52}
53
54function pick(revealed: boolean, symbol: string, value: string, pnl: string, pct: string, wallet: string): string[] {
55  return revealed ? [symbol, value, pnl, pct, wallet] : [symbol, pct, wallet]
56}
57
58export function headerCells(revealed: boolean): string[] {
59  return columns(revealed).map(column => column.label)
60}
61
62export function rowCells(row: Position, revealed: boolean, mainLabel: string): string[] {
63  return pick(revealed, row.symbol, money(row.value), money(row.upnl), percent(row.upnl, row.cost), walletName(row, mainLabel))
64}
65
66export function totalCells(rows: Position[], revealed: boolean): string[] {
67  const sum = totals(rows)
68
69  return pick(revealed, 'Total', money(sum.value), money(sum.upnl), percent(sum.upnl, sum.cost), '')
70}
71
72export function money(n: number): string {
73  const sign = n < 0 ? '-' : ''
74  const abs = Math.abs(n)
75
76  return `${sign}$${abs >= 1000 ? Math.round(abs).toLocaleString('en-US') : abs.toFixed(abs >= 100 ? 0 : 2)}`
77}
78
79// Profit or loss against what was paid; a position with no cost basis has no percent.
80export function percent(upnl: number, cost: number): string {
81  if (cost <= 0) return 'n/a'
82
83  const value = (upnl / cost) * 100
84
85  return `${value >= 0 ? '+' : ''}${value.toFixed(1)}%`
86}
87
88export function totals(rows: Position[]) {
89  const value = rows.reduce((sum, row) => sum + row.value, 0)
90  const cost = rows.reduce((sum, row) => sum + row.cost, 0)
91
92  return { value, cost, upnl: value - cost }
93}
94
types/index.d.ts 25 lines
1export type Position = {
2  symbol: string
3  chain: string
4  wallet: string
5  value: number
6  cost: number
7  upnl: number
8}
9
10declare module 'claude-code' {
11  interface PluginState {
12    'pumpfun-utilities': {
13      rows: Position[]
14      failed: string[]
15      isBusy: boolean
16      problem: string | null
17      updatedAt: string
18      isRevealed: boolean
19      isOpen: boolean
20      isAuto: boolean
21      turn: string
22    }
23  }
24}
25