SLOPSHOPPER

ship

A band above the prompt with Commit / Push / Open PR buttons when the repo has unshipped work

newbandguardprocesstimer
★ 3v0.1.0no licenseupdated 2026-10-06ewels/dotfiles/claude-mods/ship
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ship
› fix the failing auth test and add an audit log call ⏺ 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 : 2 changed · branch not pushed [ Commit ][ Commit + push ][ Open PR ] ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
: 2 changed · branch not pushed [ Commit ][ Commit + push ][ Open PR ] ⟨Claude Code's own drawing⟩
README

dotfiles

Personal dotfiles for sharing setups and configs between machines.

Feel free to steal or use as you like.. Let me know if I'm missing any good tricks!

Source 2 files
hooks/register.tsx 125 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { RepoState } from '../types'
5
6const repo = atom({ plugin: 'ship', key: 'repo' } as const, null)
7const DEFAULT_BRANCHES = new Set(['main', 'master', 'dev'])
8
9let dir = ''
10let sessionCwd = ''
11let isBusy = false
12let prBranch: string | undefined
13let hasPr = false
14
15// Claude often runs `cd other/repo && git ...`; follow it there.
16async function repoDir($: EngineInterface, command: string, fallback: string) {
17  const m = command.match(/(?:^|&&|;)\s*cd\s+("[^"]+"|'[^']+'|[^\s;&]+)/) ?? command.match(/\bgit\s+-C\s+("[^"]+"|'[^']+'|\S+)/)
18  if (!m?.[1]) return fallback
19  let p = m[1].replace(/^['"]|['"]$/g, '')
20  if (p.startsWith('~')) p = ((await $.env.get('HOME')) ?? '') + p.slice(1)
21  return p.startsWith('/') ? p : `${fallback}/${p}`
22}
23
24// Labels the repo when it isn't the session's own folder, so you can tell what you're looking at.
25async function repoName($: EngineInterface) {
26  if (dir === sessionCwd) return ''
27  const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd: dir })
28  return top.stdout.trim().split('/').pop() ?? ''
29}
30
31async function refresh($: EngineInterface, isPrStale = false) {
32  if (isBusy || !dir) return
33  isBusy = true
34  try {
35    const st = await $.process.run(['git', 'status', '--porcelain=v2', '--branch'], { cwd: dir })
36    if (st.exitCode !== 0) {
37      await update($, repo, () => null)
38      return
39    }
40    const next: RepoState = { name: await repoName($), branch: '', changed: 0, ahead: 0, hasUpstream: false, hasPr: false }
41    for (const line of st.stdout.split('\n')) {
42      if (line.startsWith('# branch.head ')) next.branch = line.slice(14)
43      else if (line.startsWith('# branch.upstream ')) next.hasUpstream = true
44      else if (line.startsWith('# branch.ab ')) next.ahead = Number(line.slice(12).split(' ')[0])
45      else if (line && !line.startsWith('#')) next.changed++
46    }
47    if (isPrStale || prBranch !== next.branch) {
48      const view = await $.process.run(['gh', 'pr', 'view', '--json', 'state', '-q', '.state'], { cwd: dir, timeoutMs: 10_000 }).catch(() => undefined)
49      hasPr = view?.exitCode === 0 && view.stdout.trim() === 'OPEN'
50      prBranch = next.branch
51    }
52    next.hasPr = hasPr
53    await update($, repo, prev => (JSON.stringify(prev) === JSON.stringify(next) ? prev : next))
54  } finally {
55    isBusy = false
56  }
57}
58
59// Submitted as the user's own words, so a "never commit unless asked" rule is still honoured.
60function ship($: EngineInterface, text: string) {
61  return $.prompt.submit({ text, asUser: true })
62}
63
64export const register: Register = on => {
65  on('session.start', async ($, e, next) => {
66    const started = await next(e)
67    sessionCwd = await $.session.cwd()
68    dir = sessionCwd
69    void refresh($)
70    $.clock.every(15_000, () => void refresh($))
71    return started
72  })
73
74  on('tool.call', async ($, e, next) => {
75    const ran = await next(e)
76    if (e.tool === 'Bash' && /\b(git|gh)\b/.test(e.command)) {
77      dir = await repoDir($, e.command, await $.session.cwd())
78      void refresh($, /\bgh\s+pr\b/.test(e.command))
79    } else if (e.tool === 'Edit' || e.tool === 'Write') {
80      void refresh($)
81    }
82    return ran
83  }).catch(($, e, next) => next(e))
84
85  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
86    const r = await read($, repo)
87    const unpushed = r ? r.ahead > 0 || !r.hasUpstream : false
88    const canOpenPr = r ? !r.hasPr && !DEFAULT_BRANCHES.has(r.branch) && r.branch !== '(detached)' : false
89    if (!r || e.props.hasSurvey || e.props.isWorking || (r.changed === 0 && !unpushed)) return next(e)
90
91    const { Box, Button, Text } = $.ui.resolve(e)
92    const summary = [
93      r.changed ? `${r.changed} changed` : '',
94      r.ahead ? `${r.ahead} unpushed` : !r.hasUpstream ? 'branch not pushed' : '',
95    ].filter(Boolean).join(' · ')
96    const below = await next(e)
97
98    return (
99      <Box flexDirection="column">
100        <Box>
101          <Text dimColor>
102            {r.name ? `${r.name} ` : ''}
103            {r.branch}: {summary}{' '}
104          </Text>
105          {r.changed > 0 && <Button key="ship-commit" label="Commit" onPress={() => ship($, 'please commit')} />}
106          <Button
107            key="ship-push"
108            label={r.changed > 0 ? 'Commit + push' : 'Push'}
109            variant="primary"
110            onPress={() => ship($, r.changed > 0 ? 'please commit and push' : 'please push')}
111          />
112          {canOpenPr && (
113            <Button
114              key="ship-pr"
115              label="Open PR"
116              onPress={() => ship($, r.changed > 0 ? 'please commit to this branch, push and open a PR' : 'please push and open a PR')}
117            />
118          )}
119        </Box>
120        {below}
121      </Box>
122    )
123  })
124}
125
types/index.d.ts 8 lines
1export type RepoState = { name: string; branch: string; changed: number; ahead: number; hasUpstream: boolean; hasPr: boolean }
2
3declare module 'claude-code' {
4  interface PluginState {
5    ship: { repo: RepoState | null }
6  }
7}
8