SLOPSHOPPER

repo

Pick a GitHub repo at session start or with /repo: fuzzy-match your repos, clone if missing, cd into it, and keep it as the session's active repo.

newpanecommandstatuspromptprocess
★ 1v0.2.0no licenseupdated 2026-10-11erte-del/claude-mods/mods/repo
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · repo
│ ┃ repo ✕ › fix the failing auth test and add an audit log call │ ┃ repo -> : name, number, owner/repo or git UR │ ⏺ 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 │ │ › /repo │ ⎿ repo: Type a repo in the repo pane. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · repo
repo -> : name, number, owner/repo or git URL (list = all)
README

claude-mods

<img width="391" height="379" alt="image" src="https://github.com/user-attachments/assets/6a3cb2d9-57cb-47e2-84c0-6cfc547499c3" />

Small mods for Claude Code: a live sidebar with one-click buttons for the things you do every session, and a repo picker. Works on Windows, macOS and Linux.

ModWhat you get
sidebarModel badge, context-window gauge, session cost, a 5-hour / weekly usage dial, and buttons for New chat, Commit & Push and Handoff
commit-pushOne click to stage, commit (with a Claude-written message) and push
handoffOne click to save a handoff.md, clear the chat, and continue from it
repoPick a GitHub repo, clone it if needed, and move the session into it

Install

In Claude Code:

/plugin marketplace add erte-del/claude-mods
/plugin install sidebar@claude-mods
/plugin install repo@claude-mods
/reload-plugins

Or from a terminal:

claude plugin marketplace add erte-del/claude-mods
claude plugin install sidebar@claude-mods --scope user
claude plugin install repo@claude-mods --scope user

Installing sidebar also installs commit-push and handoff, because its buttons call them. repo is separate, since it opens a picker when a session starts; skip it if you don't want that.

Start a new session or run /reload-plugins and the sidebar appears. It docks beside the transcript on terminals 110 columns or wider, and otherwise shows as a bar above the prompt.

Requirements: Claude Code and git. repo also uses the GitHub CLI (gh, signed in with gh auth login) to list your repos.

What each mod does

sidebar

Live status, redrawn after every turn:

  • the current model
  • how full the context window is
  • what the session has cost so far
  • how much of your 5-hour and weekly limits you've used, as a dial, with time until each resets

Buttons, top to bottom: + New chat runs /clear. ⇡ Commit & Push and ⇄ Handoff run the mods below. ↻ Reload plugins runs /reload-plugins.

commit-push

Click ⇡ Commit & Push, or type /commit-push.

  1. Checks the current repo. If nothing changed, it says so and stops.
  2. Stages everything (git add -A).
  3. Sends the staged diff to Claude (Haiku) for a one-line conventional-commit message such as fix: handle empty repo list in picker. If that fails it falls back to naming the changed files.
  4. Commits, then pushes. If the branch has no upstream yet, it runs git push -u origin <branch>.
  5. Shows the commit message and the result in the sidebar. A failed push shows git's own error text plus a hint (network, sign-in, rejected, protected branch).

It never force-pushes. It stops and leaves it to you when the branch is behind its upstream, there are unresolved merge conflicts, a merge or rebase is in progress, or HEAD is detached. Your files are only staged when it gets as far as committing.

It works on whatever folder the session is in, so it follows repo.

handoff

Click ⇄ Handoff, or type /handoff. Use it when your context is getting full.

  1. Asks Claude to write handoff.md in the project root: what you're doing, current state, the exact next step, decisions made, gotchas and open questions. An existing handoff.md is overwritten.
  2. Waits for the turn to finish and the file to appear.
  3. Runs /clear.
  4. Tells the fresh chat to read handoff.md and carry on from its next step.

If you interrupt, the turn errors, or Claude finishes without writing the file, it stops without clearing anything. The prompt is in mods/handoff/hooks/register.tsx if you want to change what goes in the file.

repo

Opens a picker when a session starts with no repo chosen, or type /repo.

  • Type part of a name to fuzzy-search your repos (matches on name, then description), list to see them all, then a number to pick.
  • Or type a full name, owner/repo, or a git URL.
  • It looks for an existing checkout in the usual places (~/repos, ~/Documents, ~/Desktop, ~/code, ~/projects, ~/dev, ~/src, ~/GitHub and a few more) and clones the repo if it can't find one.
  • Then it moves the session into that folder and tells Claude which repo is active.

Settings

Change these in /config (each mod's settings are listed under its name).

ModSettingDefaultWhat it does
sidebarbudgetUsd5The session cost that fills the cost bar
sidebarasciioffDraw bars with # and - and skip the dial, for terminals that can't show block characters
repoowneryour gh loginWhose repos to list: another user or an organization
repocloneDir~/reposWhere missing repos are cloned
repoopenOnStartonOpen the picker when a session starts with no active repo

Updating and removing

claude plugin marketplace update claude-mods
claude plugin uninstall sidebar@claude-mods

Each mod lives in its own folder under mods/ with its tests next to the code (claude plugin test mods/<name>).

Source 2 files
hooks/register.tsx 330 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { ActiveRepo, Repo } from '../types'
5
6const PANE = 'repo'
7const SHOWN = 100
8const LIST_LIMIT = 200
9const CACHE_MS = 5 * 60 * 1000
10
11const active = atom({ plugin: 'repo', key: 'active' } as const, null as ActiveRepo | null)
12const matches = atom({ plugin: 'repo', key: 'matches' } as const, [] as Repo[])
13const status = atom({ plugin: 'repo', key: 'status' } as const, '')
14const busy = atom({ plugin: 'repo', key: 'busy' } as const, false)
15
16type Options = { owner?: string; cloneDir?: string; openOnStart?: boolean }
17let opts: Options = {}
18
19// The repo list and the gh login, cached for a few minutes (module-level: a reload just refetches).
20let cache: { owner: string; at: number; repos: Repo[] } | null = null
21let ghLogin: string | null = null
22
23type Out = { code: number; out: string; err: string }
24
25async function run($: any, argv: string[], cwd?: string, timeoutMs = 60000): Promise<Out> {
26  try {
27    const r = await $.process.run(argv, { cwd, timeoutMs, env: { GIT_TERMINAL_PROMPT: '0' } })
28    return { code: r.exitCode, out: String(r.stdout ?? '').trim(), err: String(r.stderr ?? '').trim() }
29  } catch (e: any) {
30    return { code: 127, out: '', err: String(e?.message ?? e) }
31  }
32}
33
34async function home($: any): Promise<string> {
35  const h: string | undefined = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME'))
36  return (h ?? '').replace(/[\\/]+$/, '')
37}
38
39const join = (base: string, ...parts: string[]) => {
40  const sep = base.includes('\\') ? '\\' : '/'
41  return [base.replace(/[\\/]+$/, ''), ...parts].join(sep)
42}
43
44async function tilde($: any, path: string): Promise<string> {
45  const h = await home($)
46  return h && path.toLowerCase().startsWith(h.toLowerCase()) ? `~${path.slice(h.length)}` : path
47}
48
49// Whose repos to list: the `owner` setting, else whoever is logged in to gh.
50async function owner($: any): Promise<string> {
51  if (opts.owner?.trim()) return opts.owner.trim()
52  if (ghLogin) return ghLogin
53  const r = await run($, ['gh', 'api', 'user', '--jq', '.login'])
54  if (r.code === 127) throw new Error('the GitHub CLI (gh) is not installed or not on PATH')
55  if (r.code !== 0 || !r.out) throw new Error(`gh is not logged in (run: gh auth login)${r.err ? ` — ${r.err}` : ''}`)
56  ghLogin = r.out
57  return ghLogin
58}
59
60async function listRepos($: any): Promise<Repo[]> {
61  const who = await owner($)
62  const now = await $.clock.now()
63  if (cache && cache.owner === who && now - cache.at < CACHE_MS) return cache.repos
64  const r = await run($, ['gh', 'repo', 'list', who, '--limit', String(LIST_LIMIT), '--json', 'name,description,isPrivate'])
65  if (r.code !== 0) {
66    if (/auth|login|token/i.test(r.err)) throw new Error(`gh is not authenticated (run: gh auth login) — ${r.err}`)
67    throw new Error(`gh repo list failed: ${r.err || r.out}`)
68  }
69  const repos: Repo[] = (JSON.parse(r.out || '[]') as any[]).map(x => ({
70    name: String(x.name),
71    description: String(x.description ?? ''),
72    isPrivate: Boolean(x.isPrivate),
73  }))
74  cache = { owner: who, at: now, repos }
75  return repos
76}
77
78// Lower is better; null means no match.
79export function score(q: string, repo: Repo): number | null {
80  const name = repo.name.toLowerCase()
81  const desc = repo.description.toLowerCase()
82  if (name === q) return 0
83  const i = name.indexOf(q)
84  if (i === 0) return 1
85  if (i > 0) return 2 + i / 100
86  // Fuzzy: query letters appear in order in the name; tighter spans score better.
87  let pos = -1
88  let first = -1
89  let ok = true
90  for (const c of q) {
91    if (c === ' ') continue
92    pos = name.indexOf(c, pos + 1)
93    if (pos < 0) {
94      ok = false
95      break
96    }
97    if (first < 0) first = pos
98  }
99  if (ok && first >= 0) return 3 + (pos - first) / 100
100  if (desc && desc.includes(q)) return 4
101  const words = q.split(' ').filter(Boolean)
102  if (words.length > 1 && words.every(w => `${name} ${desc}`.includes(w))) return 5
103  return null
104}
105
106export function rank(query: string, repos: Repo[]): Repo[] {
107  const q = query.trim().toLowerCase()
108  return repos
109    .map(r => ({ r, s: q ? score(q, r) : 6 }))
110    .filter((x): x is { r: Repo; s: number } => x.s !== null)
111    .sort((a, b) => a.s - b.s || a.r.name.localeCompare(b.r.name))
112    .map(x => x.r)
113}
114
115// owner/repo plus a clone URL (when one was given) from a name, owner/repo, or git URL.
116export async function parseTarget(
117  target: string,
118  defaultOwner: () => Promise<string>,
119): Promise<{ full: string; url: string | null }> {
120  const t = target.trim()
121  if (/^(https?:\/\/|git@|ssh:\/\/)/.test(t)) {
122    const m = /[:/]([^/:]+)\/([^/]+?)(\.git)?\/?$/.exec(t)
123    if (!m) throw new Error(`can't read owner/repo from URL: ${t}`)
124    return { full: `${m[1]}/${m[2]}`, url: t }
125  }
126  if (/^[\w.-]+\/[\w.-]+$/.test(t)) return { full: t, url: null }
127  if (/^[\w.-]+$/.test(t)) return { full: `${await defaultOwner()}/${t}`, url: null }
128  throw new Error(`not a repo name, owner/repo or git URL: ${t}`)
129}
130
131async function exists($: any, path: string): Promise<boolean> {
132  try {
133    return await $.fs.exists(path)
134  } catch {
135    return false
136  }
137}
138
139// An existing checkout in the usual places, else a fresh clone into the clone folder.
140async function openRepo($: any, target: string): Promise<{ full: string; path: string; cloned: boolean }> {
141  const { full, url } = await parseTarget(target, () => owner($))
142  const name = full.split('/')[1] as string
143  const h = await home($)
144  const cloneDir = opts.cloneDir?.trim() ? opts.cloneDir.trim().replace(/^~(?=$|[\\/])/, h) : join(h, 'repos')
145  const roots = [
146    cloneDir,
147    ...['Documents', 'Desktop', 'code', 'Code', 'projects', 'Projects', 'dev', 'src', 'git', 'GitHub', 'workspace'].map(d => join(h, d)),
148    join(h, 'Documents', 'GitHub'),
149    join(h, 'source', 'repos'),
150    join(h, 'OneDrive', 'Documents'),
151    join(h, 'OneDrive', 'Desktop'),
152    h,
153  ]
154  for (const root of roots) {
155    const p = join(root, name)
156    if (await exists($, join(p, '.git'))) return { full, path: p, cloned: false }
157  }
158
159  const path = join(cloneDir, name)
160  if (await exists($, path)) throw new Error(`${path} exists but is not a git checkout; move it or set another clone folder`)
161  const gh = url ? null : await run($, ['gh', 'repo', 'clone', full, path], undefined, 600000)
162  if (gh && gh.code === 0) return { full, path, cloned: true }
163  // No gh (or a URL was given): plain git.
164  const source = url ?? `https://github.com/${full}.git`
165  const g = await run($, ['git', 'clone', source, path], undefined, 600000)
166  if (g.code !== 0) throw new Error(`clone of ${full} failed: ${g.err || gh?.err || 'unknown error'}`)
167  return { full, path, cloned: true }
168}
169
170// Moves the session into the folder: the built-in /cd when this build has it, else a shell cd.
171async function cdInto($: any, path: string): Promise<void> {
172  const commands: { name: string }[] = await $.command.list()
173  if (commands.some(c => c.name === 'cd')) {
174    await $.command.run({ command: 'cd', args: path })
175  } else {
176    await $.tool.call({ tool: 'Bash', command: `cd "${path.replace(/\\/g, '/')}"`, description: 'Enter the selected repo' })
177  }
178}
179
180async function select($: any, target: string, fromCommand: boolean): Promise<string> {
181  await update($, busy, () => true)
182  await update($, status, () => `opening ${target}…`)
183  let r: { full: string; path: string; cloned: boolean }
184  try {
185    r = await openRepo($, target)
186  } catch (err: any) {
187    await update($, busy, () => false)
188    const msg = `repo: ${String(err?.message ?? err)}`
189    await update($, status, () => msg)
190    return msg
191  }
192  await update($, busy, () => false)
193  await update($, active, () => ({ full: r.full, path: r.path }))
194  await update($, matches, () => [])
195  await update($, status, () => '')
196  const line = `-> now in ${r.full} (${await tilde($, r.path)})${r.cloned ? ' — freshly cloned' : ''}`
197  $.ui.status(`repo: ${r.full}`)
198  // A command hook can't wait on another command, so the cd is queued for when the session is idle.
199  const cd = cdInto($, r.path).catch((err: any) =>
200    $.ui.log(`repo: couldn't cd into ${r.path}: ${String(err?.message ?? err)}`),
201  )
202  if (!fromCommand) {
203    await cd
204    $.ui.log(line)
205    await $.ui.close({ id: PANE })
206  }
207  return line
208}
209
210// One entry from the prompt: a number picks from the list, a URL / owner/repo / exact name selects, else filter.
211async function handle($: any, raw: string, fromCommand: boolean): Promise<string> {
212  const v = raw.trim()
213  if (!v) return ''
214  const list = await read($, matches)
215  if (/^\d+$/.test(v) && list.length > 0) {
216    const n = Number(v)
217    if (n >= 1 && n <= list.length) return select($, (list[n - 1] as Repo).name, fromCommand)
218  }
219  if (/^(https?:\/\/|git@|ssh:\/\/)/.test(v) || /^[\w.-]+\/[\w.-]+$/.test(v)) return select($, v, fromCommand)
220
221  // "list" shows every repo instead of filtering on the word.
222  const listAll = v.toLowerCase() === 'list'
223  await update($, busy, () => true)
224  let found: Repo[]
225  try {
226    found = rank(listAll ? '' : v, await listRepos($))
227  } catch (err: any) {
228    await update($, busy, () => false)
229    const msg = `repo: couldn't list your GitHub repos — ${String(err?.message ?? err)}. Type owner/repo or a git URL instead.`
230    await update($, status, () => msg)
231    return msg
232  }
233  await update($, busy, () => false)
234  const exact = listAll ? undefined : found.find(x => x.name.toLowerCase() === v.toLowerCase())
235  if (exact) return select($, exact.name, fromCommand)
236  await update($, matches, () => found)
237  const msg = found.length === 0 ? `no repo matches "${v}"` : ''
238  await update($, status, () => msg)
239  if (fromCommand) {
240    return found.length === 0
241      ? msg
242      : found.slice(0, SHOWN).map((x, i) => `${i + 1}. ${x.name}${x.description ? ` - ${x.description}` : ''}`).join('\n') +
243          '\n\nPick with /repo <number>, narrow with /repo <more letters>, or use the repo pane.'
244  }
245  return msg
246}
247
248async function openPicker($: any): Promise<void> {
249  await $.ui.open({ id: PANE, title: 'repo', focus: true, closeOnEscape: true, rows: 12 })
250}
251
252export const register: Register = (on, options) => {
253  opts = (options ?? {}) as Options
254
255  on('session.start', async ($, e, next) => {
256    await $.command.register({
257      name: 'repo',
258      description: 'Pick the GitHub repo to work in (fuzzy match, clone if missing, cd into it)',
259    })
260    const started = await next(e)
261    const current = await read($, active)
262    if (current) $.ui.status(`repo: ${current.full}`)
263    else if (e.isInteractive && opts.openOnStart !== false) await openPicker($)
264    return started
265  })
266
267  on('command.run', { command: 'repo' }, async ($, e) => {
268    if (!e.args.trim()) {
269      await update($, matches, () => [])
270      await update($, status, () => '')
271      await openPicker($)
272      return { text: 'Type a repo in the repo pane.' }
273    }
274    const text = await handle($, e.args, true)
275    const now = await read($, active)
276    return now && text.startsWith('->')
277      ? { text, context: [`The active repo is now ${now.full}, checked out at ${now.path}. Work in that folder.`] }
278      : { text }
279  })
280
281  // Keeps the model aware of which repo "the repo" means.
282  on('prompt.compose', async ($, e, next) => {
283    const composed = await next(e)
284    const repo = await read($, active)
285    if (!repo) return composed
286    return {
287      sections: [
288        ...composed.sections,
289        {
290          id: 'repo:active',
291          scope: 'session',
292          text: `# Active repo\nThe user picked GitHub repo ${repo.full} for this session; it is checked out at ${repo.path}. Unless told otherwise, "the repo" means this one and work happens in that folder.`,
293        },
294      ],
295    }
296  })
297
298  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
299    const { Box, Text, Input } = $.ui.resolve(e)
300    const list = await read($, matches)
301    const msg = await read($, status)
302    const working = await read($, busy)
303    const current = await read($, active)
304    const width = Math.max(20, (e.viewport?.columns ?? 80) - 4)
305    const clip = (s: string) => (s.length > width ? `${s.slice(0, width - 1)}…` : s)
306
307    return (
308      <Box flexDirection="column">
309        {current && <Text dimColor>{clip(`active: ${current.full}`)}</Text>}
310        <Input
311          key="q"
312          label="repo -> "
313          placeholder="name, number, owner/repo or git URL (list = all)"
314          autoFocus
315          onSubmit={(value: string) => void handle($, value, false)}
316        />
317        {working && <Text dimColor>working…</Text>}
318        {msg !== '' && <Text color={msg.startsWith('repo:') ? 'red' : undefined}>{clip(msg)}</Text>}
319        {list.slice(0, SHOWN).map((r, i) => (
320          <Text key={r.name}>
321            {clip(`${i + 1}. ${r.name}${r.description ? ` - ${r.description}` : ''}`)}
322          </Text>
323        ))}
324        {list.length > SHOWN && <Text dimColor>{`…${list.length - SHOWN} more, type more letters`}</Text>}
325        {list.length > 0 && <Text dimColor>number to pick · more letters to narrow · full name to select</Text>}
326      </Box>
327    )
328  })
329}
330
types/index.d.ts 14 lines
1export type Repo = { name: string; description: string; isPrivate: boolean }
2export type ActiveRepo = { full: string; path: string }
3
4declare module 'claude-code' {
5  interface PluginState {
6    repo: {
7      active: ActiveRepo | null
8      matches: Repo[]
9      status: string
10      busy: boolean
11    }
12  }
13}
14