SLOPSHOPPER

jr-badge

A coloured project badge and its branch, on the right above the prompt, so you always know which window you are typing in. Hidden until you type /jrbadge. A…

newbandcommandprocesstimer
★ 2v0.3.0MITupdated 2026-10-16jrlprost/claude-code-mods/plugins/jr-badge
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jr-badge
› 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 › /jrbadge ⎿ jr-badge: jr badge · shown above the prompt (/jrbadge to hide it) APP ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
APP ⟨Claude Code's own drawing⟩
README

jr-badge

A coloured badge with the project and its branch, one quiet row on the right above the prompt. With several terminal windows open, you see at a glance which one you are typing in.

                                                         SHOP  feature/cart

Hidden by default: type /jrbadge to show it.

  • The colour is derived from the name, so a project keeps the same colour in every window.
  • In your home directory the badge says HOME.
  • Your own label and colour: add a line to the project's CLAUDE.md:
badge: CLIENT ACME PROD #ef4565
  • Refreshed after every turn, so a branch switch shows up.
  • /jrbadge shows or hides it in every session, and the choice survives a restart. /jrbadge on and /jrbadge off set it explicitly.

Install

/plugin install jr-badge --marketplace jrlprost/claude-code-mods

Access

Runs git rev-parse --show-toplevel and git branch --show-current. Reads the project's CLAUDE.md and the HOME variable. Remembers whether the badge is hidden. Sends nothing over the network.

Source 4 files
hooks/register.tsx 97 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { badgeOf } from './badge'
5import { safeGit } from './jr/git'
6
7const badge = atom({ plugin: 'jr-badge', key: 'badge' } as const, null)
8// Hidden until the person asks for it.
9const isHidden = atom({ plugin: 'jr-badge', key: 'isHidden' } as const, true)
10const live = { isScheduled: false, isLoaded: false }
11
12/** Hidden or shown is one choice for every session, kept across restarts; read outside a drawing, which cannot write state. */
13async function ensureLoaded($: EngineInterface) {
14  if (live.isLoaded) return
15  live.isLoaded = true
16  if ((await $.store.get('isHidden')) === false) await update($, isHidden, () => false)
17}
18
19async function git($: EngineInterface, cwd: string, args: string[]) {
20  try {
21    const { exitCode, stdout } = await $.process.run(safeGit(['-C', cwd, ...args]))
22    return exitCode === 0 && stdout.trim() ? stdout.trim() : null
23  } catch {
24    return null
25  }
26}
27
28async function claudeMdOf($: EngineInterface, root: string) {
29  try {
30    return await $.fs.read(`${root}/CLAUDE.md`)
31  } catch {
32    return null
33  }
34}
35
36async function refresh($: EngineInterface) {
37  const cwd = await $.session.cwd()
38  const [home, repoRoot, branch] = await Promise.all([$.env.get('HOME'), git($, cwd, ['rev-parse', '--show-toplevel']), git($, cwd, ['branch', '--show-current'])])
39  const root = repoRoot ?? cwd
40  const claudeMd = await claudeMdOf($, root)
41  await update($, badge, () => badgeOf({ cwd, home: home ?? null, root, branch, claudeMd }))
42}
43
44export const register: Register = on => {
45  on('session.start', async ($, e, next) => {
46    const started = await next(e)
47    await $.command.register({ name: 'jrbadge', description: 'Show or hide the project badge (hidden by default): /jrbadge toggles, /jrbadge on or off' })
48    await ensureLoaded($)
49    await refresh($)
50    return started
51  })
52
53  on('command.run', { command: 'jrbadge' }, async ($, e) => {
54    await ensureLoaded($)
55    const arg = e.args.trim().toLowerCase()
56    const hide = arg === 'off' || arg === 'hide' ? true : arg === 'on' || arg === 'show' ? false : !(await read($, isHidden))
57    await $.store.set('isHidden', hide)
58    await update($, isHidden, () => hide)
59    return { text: hide ? 'jr badge · hidden (/jrbadge to bring it back)' : 'jr badge · shown above the prompt (/jrbadge to hide it)' }
60  })
61
62  // The branch or the folder can change during a turn.
63  on('turn.complete', async ($, e, next) => {
64    const done = await next(e)
65    if (e.agentId === undefined) await refresh($)
66    return done
67  })
68
69  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
70    const below = await next(e)
71    const b = await read($, badge)
72    // Installed mid-session: no session.start, so load the choice and the badge after this draw.
73    if ((!b || !live.isLoaded) && !live.isScheduled) {
74      live.isScheduled = true
75      $.clock.after(0, () => {
76        void ensureLoaded($)
77        void refresh($)
78      })
79    }
80    if (await read($, isHidden)) return below
81    if (!b || e.props.hasSurvey) return below
82    const { Box, Text } = $.ui.resolve(e)
83    // One quiet row on the right: the project chip and its branch, no path.
84    return (
85      <Box flexDirection="column">
86        <Box justifyContent="flex-end">
87          <Text wrap="truncate-end">
88            <Text backgroundColor={b.color} color="black" bold>{` ${b.label} `}</Text>
89            {b.branch && <Text dimColor>{` ${b.branch}`}</Text>}
90          </Text>
91        </Box>
92        {below}
93      </Box>
94    )
95  })
96}
97
hooks/badge.ts 25 lines
1import type { JrBadge } from '../types'
2
3// A stable colour per project, so each terminal window is recognisable at a glance.
4const PALETTE = ['#61afef', '#98c379', '#e5c07b', '#56b6c2', '#e06c75', '#d19a66', '#4fd6be', '#ff9e64', '#c678dd', '#7f5af0']
5
6export const colorOf = (name: string) => {
7  let hash = 0
8  for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) >>> 0
9  return PALETTE[hash % PALETTE.length]!
10}
11
12/** A `badge: LABEL #hex` line in CLAUDE.md, colour optional. */
13export const badgeLine = (claudeMd: string | null) => {
14  const m = claudeMd?.match(/^\s*badge:\s*(.+?)\s*(#[0-9a-fA-F]{6})?\s*$/m)
15  return m ? { label: m[1]!.trim(), color: m[2] ?? null } : null
16}
17
18export const badgeOf = (args: { cwd: string; home: string | null; root: string; branch: string | null; claudeMd: string | null }): JrBadge => {
19  const { cwd, home, root, branch, claudeMd } = args
20  const declared = badgeLine(claudeMd)
21  const project = root.split('/').filter(Boolean).pop() ?? root
22  const label = declared?.label ?? (home && cwd === home ? 'HOME' : project.toUpperCase())
23  return { label, branch, color: declared?.color ?? colorOf(label) }
24}
25
hooks/jr/git.ts 83 lines
1// git in a repository the mod does not trust. A repository's config can make git run programs:
2// fsmonitor, external diff and textconv drivers, clean/smudge filters, ssh and credential helpers,
3// pagers, editors, aliases, or an include pulling any of these from another file.
4// Two layers: config overrides on every call, and repoIsSafe() before any command that reads the
5// working tree. repoIsSafe asks git itself which config it would use there (so there is no parser
6// of ours to fool) and refuses when an entry able to run a program comes from anywhere but the
7// person's own setup (what git reads outside any repository).
8// Synced into each plugin's hooks/jr/ by scripts/sync-shared.sh.
9import type { EngineInterface } from 'claude-code'
10
11const SAFE_CONFIG = [
12  '-c', 'core.fsmonitor=false',
13  '-c', 'core.hooksPath=/dev/null',
14  '-c', 'core.pager=cat',
15  '-c', 'core.editor=true',
16  '-c', 'core.sshCommand=false',
17  '-c', 'core.askPass=false',
18  '-c', 'credential.helper=',
19  '-c', 'diff.external=',
20  '-c', 'core.untrackedCache=false',
21  '-c', 'protocol.ext.allow=never',
22  // Never step into submodules: each has its own config, which repoIsSafe does not read.
23  '-c', 'submodule.recurse=false',
24  '-c', 'diff.ignoreSubmodules=all',
25  '-c', 'status.submoduleSummary=false',
26]
27
28/** `git <args>` with command-running config switched off. Diffs never run external drivers. */
29export const safeGit = (args: readonly string[]) => {
30  const rest = [...args]
31  if (rest[0] === '-C') rest.splice(0, 2)
32  const cwd = args[0] === '-C' ? ['-C', args[1] ?? '.'] : []
33  const extra = rest[0] === 'diff' ? ['--no-ext-diff', '--no-textconv', '--ignore-submodules=all'] : []
34  return ['git', ...SAFE_CONFIG, ...cwd, ...(rest.length ? [rest[0]!, ...extra, ...rest.slice(1)] : [])]
35}
36
37export type GitConfigEntry = { origin: string; key: string; value: string }
38
39/** Parses `git config --list --show-origin -z`: records of origin NUL key LF value NUL. */
40export const parseConfigListing = (listing: string): GitConfigEntry[] => {
41  const parts = listing.split('\0')
42  const entries: GitConfigEntry[] = []
43  for (let i = 0; i + 1 < parts.length; i += 2) {
44    const origin = parts[i]!
45    const record = parts[i + 1]!
46    const nl = record.indexOf('\n')
47    entries.push({ origin, key: (nl < 0 ? record : record.slice(0, nl)).toLowerCase(), value: nl < 0 ? '' : record.slice(nl + 1) })
48  }
49  return entries
50}
51
52// The last part of a key (git keys are section[.subsection].name, already lower-cased by git
53// for section and name) that makes git run a program, plus whole sections that do.
54const RISKY_NAME = /\.(fsmonitor|sshcommand|askpass|editor|pager|hookspath|textconv|command|clean|smudge|process|driver|helper|program|external|packobjectshook|gitproxy)$/
55const RISKY_SECTION = /^(alias|include|includeif)\./
56
57/** Entries able to run a program that come from somewhere other than the trusted origins. */
58export const riskyEntries = (entries: readonly GitConfigEntry[], trustedOrigins: ReadonlySet<string>) =>
59  entries.filter(e => !trustedOrigins.has(e.origin) && !e.origin.startsWith('command line:') && (RISKY_NAME.test(e.key) || RISKY_SECTION.test(e.key)))
60
61async function listing($: EngineInterface, args: readonly string[]) {
62  try {
63    const out = await $.process.run(args)
64    return out.exitCode === 0 ? out.stdout : null
65  } catch {
66    return null
67  }
68}
69
70/**
71 * True only when the config git would use at `root` holds nothing able to run a program beyond
72 * what the person's own global and system config set up. Any failure counts as unsafe.
73 */
74export async function repoIsSafe($: EngineInterface, root: string) {
75  const local = await listing($, safeGit(['-C', root, 'config', '--list', '--show-origin', '-z']))
76  if (local === null) return false
77  // What git reads outside any repository is the person's own setup: global, system, and the
78  // config the git build ships (Apple's git keeps it next to Xcode, not in /etc/gitconfig).
79  const own = await listing($, safeGit(['-C', '/', 'config', '--list', '--show-origin', '-z']))
80  const trusted = new Set(parseConfigListing(own ?? '').map(e => e.origin))
81  return riskyEntries(parseConfigListing(local), trusted).length === 0
82}
83
types/index.d.ts 8 lines
1export type JrBadge = { label: string; branch: string | null; color: string }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'jr-badge': { badge: JrBadge | null; isHidden: boolean }
6  }
7}
8