SLOPSHOPPER

jr-outputs

/jroutputs: every file this session created or changed, newest first, with + and - line counts from git.

newpaneguardcommandprocess
★ 2v0.1.1MITupdated 2026-10-15jrlprost/claude-code-mods/plugins/jr-outputs
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jr-outputs
│ ┃ jr outputs ✕ › fix the failing auth test and add an audit log call │ ┃ jr outputs 2 created · 1 changed │ ┃ + src/cache.ts +0 -0 ⏺ Read(src/auth.ts) │ ┃ + src/audit.ts +0 -0 ⎿ Read 6 lines │ ┃ ~ src/auth.ts ⏺ Update(src/auth.ts) │ ┃ + created · ~ changed · counts from git when ⎿ Added 2 lines, removed 1 line │ ┃ the project is a repository ⏺ 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 │ │ › /jroutputs │ ⎿ jr-outputs: jr outputs · 3 files this session │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · jr outputs
jr outputs 2 created · 1 changed + src/cache.ts +0 -0 + src/audit.ts +0 -0 ~ src/auth.ts + created · ~ changed · counts from git when the project is a repository
README

jr-outputs

/jroutputs lists every file this session created or changed, newest first, so you know exactly what Claude touched.

jr outputs  2 created · 3 changed
 + src/billing/checkout.ts        +84 -0
 ~ src/billing/plans.ts           +12 -3   ×2
 ~ package.json                    +1 -0
 + docs/billing.md                +40 -0
 ~ src/app.tsx                     +2 -2
+ created · ~ changed · counts from git when the project is a repository
  • A file is recorded when a Write, Edit or NotebookEdit call on it succeeds.
  • + means the file did not exist before; ~ means it was changed. ×2 is how many times it was touched.
  • Line counts come from git diff --numstat HEAD. A new file outside git counts its lines.

Commands

Command
/jroutputsOpen the list

Install

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

Access

Watches Write, Edit and NotebookEdit calls (it never blocks them). Checks whether a file exists before it is written. Runs git diff --numstat when you open the list. Sends nothing over the network.

Source 6 files
hooks/register.tsx 96 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { JrOutput } from '../types'
5import { parseNumstat, record, relative, touchedPath } from './outputs'
6import { gradientText } from './jr/draw'
7import { GRADIENTS } from './jr/gradient'
8import { repoIsSafe, safeGit } from './jr/git'
9
10const PANE = 'jr-outputs'
11const outputs = atom({ plugin: 'jr-outputs', key: 'outputs' } as const, [])
12const root = atom({ plugin: 'jr-outputs', key: 'root' } as const, '')
13
14async function ensureRoot($: EngineInterface) {
15  if (!(await read($, root))) {
16    const dir = await $.session.root()
17    await update($, root, () => dir)
18  }
19  return read($, root)
20}
21
22/** Fills in +/- line counts from git for the listed files; created files outside git count their lines. */
23async function withLineCounts($: EngineInterface) {
24  const dir = await ensureRoot($)
25  const list = await read($, outputs)
26  // A repo whose config or attributes could make git run a program gets no git counts.
27  const diff = (await repoIsSafe($, dir))
28    ? await $.process.run(safeGit(['-C', dir, 'diff', '--numstat', 'HEAD', '--', ...list.map(o => o.path)])).catch(() => null)
29    : null
30  const counts = diff && diff.exitCode === 0 ? parseNumstat(diff.stdout) : new Map<string, [number, number]>()
31  const counted: JrOutput[] = []
32  for (const o of list) {
33    let pair = counts.get(relative(dir, o.path))
34    if (!pair && o.isCreated) {
35      const text = await $.fs.read(o.path).catch(() => '')
36      pair = [text ? text.split('\n').length : 0, 0]
37    }
38    counted.push(pair ? { ...o, added: pair[0], removed: pair[1] } : o)
39  }
40  await update($, outputs, () => counted)
41}
42
43export const register: Register = on => {
44  on('session.start', async ($, e, next) => {
45    const started = await next(e)
46    await $.command.register({ name: 'jroutputs', description: 'Files this session created or changed, newest first' })
47    await ensureRoot($)
48    return started
49  })
50
51  on('tool.call', async ($, e, next) => {
52    const file = touchedPath(String(e.tool), e as unknown as Record<string, unknown>)
53    if (!file) return next(e)
54    const existed = await $.fs.exists(file).catch(() => true)
55    const ran = await next(e)
56    if (!ran.isError && ran.deny === undefined) {
57      const at = await $.clock.now()
58      await update($, outputs, list => record(list ?? [], file, existed, at))
59    }
60    return ran
61  }).catch(($, e, next) => next(e))
62
63  on('command.run', { command: 'jroutputs' }, async $ => {
64    await withLineCounts($)
65    const count = (await read($, outputs)).length
66    await $.ui.open({ id: PANE, title: 'jr outputs', rows: Math.min(count + 3, 30) })
67    return { text: `jr outputs · ${count} file${count === 1 ? '' : 's'} this session` }
68  })
69
70  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
71    const { Box, Text } = $.ui.resolve(e)
72    const list = await read($, outputs)
73    const dir = await read($, root)
74    const width = Math.min(Math.max(26, ...list.map(o => relative(dir, o.path).length)), 60) + 2
75    return (
76      <Box flexDirection="column">
77        <Text>
78          {gradientText(Text, 'jr outputs', GRADIENTS.brand)}
79          <Text dimColor>{`  ${list.filter(o => o.isCreated).length} created · ${list.filter(o => !o.isCreated).length} changed`}</Text>
80        </Text>
81        {list.length === 0 && <Text dimColor>Nothing written yet in this session.</Text>}
82        {list.map(o => (
83          <Text wrap="truncate-start">
84            <Text color={o.isCreated ? '#2cb67d' : '#ffb86b'} bold>{o.isCreated ? ' + ' : ' ~ '}</Text>
85            <Text>{relative(dir, o.path).padEnd(width)}</Text>
86            {o.added !== undefined && <Text color="#2cb67d">{` +${o.added}`}</Text>}
87            {o.removed !== undefined && <Text color="#ef4565">{` -${o.removed}`}</Text>}
88            {o.edits > 1 && <Text dimColor>{`  ×${o.edits}`}</Text>}
89          </Text>
90        ))}
91        <Text dimColor>+ created · ~ changed · counts from git when the project is a repository</Text>
92      </Box>
93    )
94  })
95}
96
hooks/outputs.ts 29 lines
1import type { JrOutput } from '../types'
2
3/** The file a Write, Edit or NotebookEdit call touches, or null for any other tool. */
4export const touchedPath = (tool: string, input: Record<string, unknown>) => {
5  if (tool === 'Write' || tool === 'Edit' || tool === 'MultiEdit') return typeof input.file_path === 'string' ? input.file_path : null
6  if (tool === 'NotebookEdit') return typeof input.notebook_path === 'string' ? input.notebook_path : null
7  return null
8}
9
10/** Records one touch: a file seen again keeps its first "created" verdict and moves to the top. */
11export const record = (list: readonly JrOutput[], path: string, existed: boolean, at: number): JrOutput[] => {
12  const prev = list.find(o => o.path === path)
13  const entry: JrOutput = prev ? { ...prev, at, edits: prev.edits + 1 } : { path, isCreated: !existed, at, edits: 1 }
14  return [entry, ...list.filter(o => o.path !== path)]
15}
16
17/** `git diff --numstat` lines into a map of path (relative to the repo) → [added, removed]. */
18export const parseNumstat = (stdout: string) => {
19  const out = new Map<string, [number, number]>()
20  for (const line of stdout.split('\n')) {
21    const [a, r, ...rest] = line.split('\t')
22    if (!rest.length) continue
23    out.set(rest.join('\t'), [Number(a) || 0, Number(r) || 0])
24  }
25  return out
26}
27
28export const relative = (root: string, file: string) => (file.startsWith(`${root}/`) ? file.slice(root.length + 1) : file)
29
hooks/jr/draw.tsx 35 lines
1// Drawing helpers shared by every jr mod. Synced into each plugin's hooks/jr/.
2import type { Elements } from 'claude-code'
3
4import { bar, paint } from './gradient'
5
6type TextElement = Elements['terminal']['Text']
7
8/** `text` in a gradient, one color per character, bold by default. */
9export const gradientText = (Text: TextElement, text: string, stops: readonly string[], bold = true) => (
10  <Text bold={bold}>
11    {paint(text, stops).map(({ char, color }) => (
12      <Text color={color}>{char}</Text>
13    ))}
14  </Text>
15)
16
17/** A gauge: filled cells in the gradient, empty cells dim. */
18export const gauge = (Text: TextElement, ratio: number, width: number, stops: readonly string[]) => (
19  <Text>
20    {bar(ratio, width, stops).map(cell =>
21      cell.empty ? (
22        <Text color={cell.color} dimColor>
23          {cell.char}
24        </Text>
25      ) : (
26        <Text color={cell.color}>{cell.char}</Text>
27      ),
28    )}
29  </Text>
30)
31
32/** The jr tag that opens every band line: a small gradient label. */
33export const tag = (Text: TextElement, label: string, stops: readonly string[]) =>
34  gradientText(Text, `${label} `, stops)
35
hooks/jr/gradient.ts 75 lines
1// Shared look of every jr mod: one palette, gradients drawn one cell at a time.
2// Synced into each plugin's hooks/jr/ by scripts/sync-shared.sh: edit it here only.
3
4export type Rgb = readonly [number, number, number]
5
6const hexToRgb = (hex: string): Rgb => {
7  const n = Number.parseInt(hex.replace('#', ''), 16)
8  return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
9}
10
11const rgbToHex = ([r, g, b]: Rgb) =>
12  `#${[r, g, b].map(v => Math.round(v).toString(16).padStart(2, '0')).join('')}`
13
14export const GRADIENTS = {
15  // The jr signature: violet to pink to amber.
16  brand: ['#7f5af0', '#ff5ca8', '#ffb86b'],
17  // Calm to alarm, for gauges where full is bad.
18  heat: ['#2cb67d', '#f4d35e', '#ff8c42', '#ef4565'],
19  // Cool, for neutral figures.
20  ice: ['#3da9fc', '#7f5af0'],
21  // Money and growth.
22  money: ['#2cb67d', '#a8e6cf'],
23} as const satisfies Record<string, readonly string[]>
24
25export type GradientName = keyof typeof GRADIENTS
26
27/** The color at `t` (0..1) along the stops. */
28export const colorAt = (stops: readonly string[], t: number): string => {
29  if (stops.length === 1) return stops[0]!
30  const clamped = Math.min(1, Math.max(0, t))
31  const span = clamped * (stops.length - 1)
32  const i = Math.min(stops.length - 2, Math.floor(span))
33  const a = hexToRgb(stops[i]!)
34  const b = hexToRgb(stops[i + 1]!)
35  const local = span - i
36  return rgbToHex([0, 1, 2].map(k => a[k]! + (b[k]! - a[k]!) * local) as unknown as Rgb)
37}
38
39/** One color per character of `text`, spread across the stops. */
40export const paint = (text: string, stops: readonly string[]) => {
41  const chars = [...text]
42  return chars.map((char, i) => ({ char, color: colorAt(stops, chars.length > 1 ? i / (chars.length - 1) : 0) }))
43}
44
45const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
46
47/**
48 * A bar `width` cells wide filled to `ratio` (0..1), with eighth-cell precision.
49 * Each filled cell takes the gradient color of its position along the whole bar,
50 * so a nearly full heat bar ends red and a low one stays green.
51 */
52export const bar = (ratio: number, width: number, stops: readonly string[]) => {
53  const filled = Math.min(1, Math.max(0, ratio)) * width
54  const full = Math.floor(filled)
55  const partial = EIGHTHS[Math.round((filled - full) * 7)] ?? ''
56  const cells: { char: string; color: string; empty?: true }[] = []
57  for (let i = 0; i < width; i++) {
58    const color = colorAt(stops, width > 1 ? i / (width - 1) : 0)
59    if (i < full) cells.push({ char: '█', color })
60    else if (i === full && partial) cells.push({ char: partial, color })
61    else cells.push({ char: '·', color, empty: true })
62  }
63  return cells
64}
65
66export const formatTokens = (n: number) =>
67  n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 10_000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`
68
69export const formatDuration = (ms: number) => {
70  const minutes = Math.max(0, Math.round(ms / 60_000))
71  const h = Math.floor(minutes / 60)
72  const m = minutes % 60
73  return h > 0 ? `${h}h${String(m).padStart(2, '0')}` : `${m}min`
74}
75
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 16 lines
1export type JrOutput = {
2  path: string
3  /** The file did not exist before the session touched it. */
4  isCreated: boolean
5  at: number
6  edits: number
7  added?: number
8  removed?: number
9}
10
11declare module 'claude-code' {
12  interface PluginState {
13    'jr-outputs': { outputs: JrOutput[]; root: string }
14  }
15}
16