SLOPSHOPPER

jr-record

Recording mode for demos and screen shares: /jrrec masks emails, money amounts, keys, phone numbers and IPs on screen. Claude still reads the real text.

newbandrowscommandtimer
★ 2v0.3.0MITupdated 2026-10-16jrlprost/claude-code-mods/plugins/jr-record
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jr-record
› 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 › /jrrec ⎿ jr-record: jr rec · on: emails, money, keys and phone numbers are masked on screen. Claude still reads the real text. ● REC masking on · /jrrec off ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
● REC masking on · /jrrec off ⟨Claude Code's own drawing⟩
README

jr-record

Recording mode for demos, screen shares and videos. /jrrec masks what you would not want on screen, everywhere the transcript draws text: your prompts, Claude's replies, command output, tool calls and their results.

● REC masking on · /jrrec off

Sent the invoice to █████: █████ due on the 30th.
STRIPE_SECRET_KEY=█████
Maskedonstrict
Emails✓✓
Money amounts (€, $, £, EUR, USD…)✓✓
API keys, tokens, private keys, NAME_KEY=value lines✓✓
Phone numbers✓✓
IP addresses✓✓
Home folder names in paths (/Users/█/…)✓
Any number of 4 digits or more✓

Only the drawing changes. Claude still reads the real text, your files are untouched, and the transcript on disk keeps everything. The mode is remembered across sessions, so a recording setup stays masked until you turn it off; a red ● REC band above the prompt shows it is on.

Commands

Command
/jrrecToggle on and off
/jrrec onMask emails, money, keys, phones, IPs
/jrrec strictAlso home paths and long numbers
/jrrec offShow everything again
/jrrec hide / /jrrec showHide or show the REC band, in every session; masking stays active

Install

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

Access

Rewrites what the terminal draws for messages and tool rows. Keeps the mode in the plugin's own store. Writes nothing else, runs nothing, sends nothing over the network.

Source 3 files
hooks/register.tsx 130 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { JrRecordMode } from '../types'
5import { maskText, maskValue, parseMode } from './mask'
6
7const mode = atom({ plugin: 'jr-record', key: 'mode' } as const, 'off')
8const isLoaded = atom({ plugin: 'jr-record', key: 'isLoaded' } as const, false)
9const bandHidden = atom({ plugin: 'jr-record', key: 'isBandHidden' } as const, false)
10
11const bandState = { isLoaded: false }
12
13/** True while the band is hidden; the saved choice is read once per load of the mod. */
14async function isBandHidden($: EngineInterface) {
15  if (!bandState.isLoaded) {
16    bandState.isLoaded = true
17    if ((await $.store.get('isBandHidden')) === true) await update($, bandHidden, () => true)
18  }
19  return read($, bandHidden)
20}
21
22/** Hides or shows the band for every session, kept across restarts. */
23async function setBand($: EngineInterface, hide: boolean, name: string, command: string) {
24  await $.store.set('isBandHidden', hide)
25  await update($, bandHidden, () => hide)
26  return { text: hide ? `${name} · hidden (${command} to bring it back)` : `${name} · shown` }
27}
28const scheduled = { load: false, command: false }
29
30async function registerCommand($: EngineInterface) {
31  if (scheduled.command) return
32  scheduled.command = true
33  await $.command.register({ name: 'jrrec', description: 'Recording mode: mask emails, money, keys and phone numbers on screen (on, off, strict; hide or show the band)' })
34}
35
36/** The mode survives sessions: a recording setup stays masked until you turn it off. */
37async function load($: EngineInterface) {
38  const saved = (await $.store.get('mode')) as JrRecordMode | undefined
39  await update($, mode, () => saved ?? 'off')
40  await update($, isLoaded, () => true)
41}
42
43async function setMode($: EngineInterface, next: JrRecordMode) {
44  await $.store.set('mode', next)
45  await update($, mode, () => next)
46  await update($, isLoaded, () => true)
47}
48
49export const register: Register = on => {
50  on('session.start', async ($, e, next) => {
51    const started = await next(e)
52    await registerCommand($)
53    await load($)
54    return started
55  })
56
57  on('command.run', { command: 'jrrec' }, async ($, e) => {
58    if (!(await read($, isLoaded))) await load($)
59    const word = e.args.trim().toLowerCase()
60    if (word === 'hide' || word === 'show') {
61      const answer = await setBand($, word === 'hide', 'jr rec band', '/jrrec show')
62      return word === 'hide' && (await read($, mode)) !== 'off' ? { text: `${answer.text}; masking stays active, /jrrec off to stop it` } : answer
63    }
64    const chosen = parseMode(e.args, await read($, mode))
65    if (!chosen) return { text: 'jr rec · usage: /jrrec, /jrrec on, /jrrec off, /jrrec strict, /jrrec hide, /jrrec show' }
66    await setMode($, chosen)
67    return {
68      text:
69        chosen === 'off'
70          ? 'jr rec · off: the screen shows everything again.'
71          : `jr rec · ${chosen}: emails, money, keys and phone numbers are masked on screen${chosen === 'strict' ? ', plus home paths and long numbers' : ''}. Claude still reads the real text.`,
72    }
73  })
74
75  // Installed mid-session: read the saved mode on the first draw.
76  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
77    const below = await next(e)
78    if (!(await read($, isLoaded)) && !scheduled.load) {
79      scheduled.load = true
80      $.clock.after(0, () => {
81        void load($)
82        void registerCommand($)
83      })
84    }
85    // Never write state while drawing: the saved choice is loaded just after.
86    if (!bandState.isLoaded) {
87      $.clock.after(0, () => {
88        void isBandHidden($)
89      })
90    }
91    if (await read($, bandHidden)) return below
92    const current = await read($, mode)
93    if (current === 'off' || e.props.hasSurvey) return below
94    const { Box, Text } = $.ui.resolve(e)
95    return (
96      <Box flexDirection="column">
97        <Text wrap="truncate-end">
98          <Text color="#ef4565" bold>{'● REC '}</Text>
99          <Text>{current === 'strict' ? 'masking on, strict' : 'masking on'}</Text>
100          <Text dimColor>{' · /jrrec off'}</Text>
101        </Text>
102        {below}
103      </Box>
104    )
105  })
106
107  // Every text the transcript draws: the drawing changes, the stored message does not.
108  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
109    const m = await read($, mode)
110    return next(m === 'off' ? e : { ...e, props: { ...e.props, text: maskText(e.props.text, m) } })
111  })
112  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
113    const m = await read($, mode)
114    return next(m === 'off' ? e : { ...e, props: { ...e.props, text: maskText(e.props.text, m) } })
115  })
116  on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
117    const m = await read($, mode)
118    return next(m === 'off' ? e : { ...e, props: { ...e.props, text: maskText(e.props.text, m) } })
119  })
120  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
121    const m = await read($, mode)
122    if (m === 'off') return next(e)
123    return next({ ...e, props: { ...e.props, input: maskValue(e.props.input, m), output: maskValue(e.props.output, m) } })
124  })
125  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
126    const m = await read($, mode)
127    return next(m === 'off' ? e : { ...e, props: { ...e.props, output: maskValue(e.props.output, m) } })
128  })
129}
130
hooks/mask.ts 59 lines
1// What gets masked on screen while recording. The model's context is never touched.
2
3export type Mode = 'off' | 'on' | 'strict'
4
5const BLOCK = '█████'
6
7const RULES: { kind: string; pattern: RegExp; strictOnly?: true }[] = [
8  { kind: 'key', pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g },
9  {
10    kind: 'key',
11    pattern: /\b(?:sk-ant-[A-Za-z0-9_-]{16,}|[sr]k_(?:live|test)_[0-9A-Za-z]{12,}|sk-(?:proj-)?[A-Za-z0-9_-]{20,}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|(?:AKIA|ASIA)[0-9A-Z]{16}|xox[abposr]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{35}|eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,})/g,
12  },
13  { kind: 'key', pattern: /\b([A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASS|PWD))\s*=\s*\S+/g },
14  { kind: 'email', pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g },
15  { kind: 'money', pattern: /(?:[$€£¥]\s?\d[\d\s.,]*\d|[$€£¥]\s?\d|\b\d[\d\s.,]*\s?(?:€|EUR|USD|GBP|\$|k€|K€)(?![A-Za-z]))/g },
16  { kind: 'phone', pattern: /(?:\+\d{1,3}[\s.-]?)?(?:\(?\d{1,4}\)?[\s.-]?){2,4}\d{2,4}(?=\s|$|[,;)])/g },
17  { kind: 'ip', pattern: /\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b/g },
18  { kind: 'home', pattern: /\/(?:Users|home)\/[^/\s]+/g, strictOnly: true },
19  { kind: 'number', pattern: /\b\d{4,}\b/g, strictOnly: true },
20]
21
22const looksLikePhone = (m: string) => {
23  const digits = m.replace(/\D/g, '')
24  return digits.length >= 9 && digits.length <= 15 && /[\s.+()-]/.test(m.trim())
25}
26
27export const maskText = (text: string, mode: Mode) => {
28  if (mode === 'off') return text
29  let out = text
30  for (const rule of RULES) {
31    if (rule.strictOnly && mode !== 'strict') continue
32    out = out.replace(rule.pattern, (match, name?: string) => {
33      if (rule.kind === 'phone' && !looksLikePhone(match)) return match
34      if (rule.kind === 'key' && typeof name === 'string' && match.includes('=')) return `${name}=${BLOCK}`
35      if (rule.kind === 'home') return '/Users/█'
36      return BLOCK
37    })
38  }
39  return out
40}
41
42/** Masks every string inside a value, keeping its shape (tool inputs and outputs). */
43export const maskValue = (value: unknown, mode: Mode): unknown => {
44  if (mode === 'off') return value
45  if (typeof value === 'string') return maskText(value, mode)
46  if (Array.isArray(value)) return value.map(v => maskValue(v, mode))
47  if (value && typeof value === 'object') {
48    return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([k, v]) => [k, maskValue(v, mode)]))
49  }
50  return value
51}
52
53export const parseMode = (arg: string, current: Mode): Mode | null => {
54  const a = arg.trim().toLowerCase()
55  if (a === '') return current === 'off' ? 'on' : 'off'
56  if (a === 'on' || a === 'off' || a === 'strict') return a
57  return null
58}
59
types/index.d.ts 8 lines
1export type JrRecordMode = 'off' | 'on' | 'strict'
2
3declare module 'claude-code' {
4  interface PluginState {
5    'jr-record': { mode: JrRecordMode; isLoaded: boolean; isBandHidden: boolean }
6  }
7}
8