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.

<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.
| Mod | What you get |
|---|---|
| sidebar | Model badge, context-window gauge, session cost, a 5-hour / weekly usage dial, and buttons for New chat, Commit & Push and Handoff |
| commit-push | One click to stage, commit (with a Claude-written message) and push |
| handoff | One click to save a handoff.md, clear the chat, and continue from it |
| repo | Pick a GitHub repo, clone it if needed, and move the session into it |
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.
Live status, redrawn after every turn:
Buttons, top to bottom: + New chat runs /clear. ⇡ Commit & Push and ⇄ Handoff run the mods below. ↻ Reload plugins runs /reload-plugins.
Click ⇡ Commit & Push, or type /commit-push.
git add -A).fix: handle empty repo list in picker. If that fails it falls back to naming the changed files.git push -u origin <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.
Click ⇄ Handoff, or type /handoff. Use it when your context is getting full.
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./clear.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.
Opens a picker when a session starts with no repo chosen, or type /repo.
list to see them all, then a number to pick.owner/repo, or a git URL.~/repos, ~/Documents, ~/Desktop, ~/code, ~/projects, ~/dev, ~/src, ~/GitHub and a few more) and clones the repo if it can't find one.Change these in /config (each mod's settings are listed under its name).
| Mod | Setting | Default | What it does |
|---|---|---|---|
| sidebar | budgetUsd | 5 | The session cost that fills the cost bar |
| sidebar | ascii | off | Draw bars with # and - and skip the dial, for terminals that can't show block characters |
| repo | owner | your gh login | Whose repos to list: another user or an organization |
| repo | cloneDir | ~/repos | Where missing repos are cloned |
| repo | openOnStart | on | Open the picker when a session starts with no active repo |
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>).
hooks/register.tsx 330 lines1import { 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}
330types/index.d.ts 14 lines1export 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