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.

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=█████
| Masked | on | strict |
|---|---|---|
| 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.
| Command | |
|---|---|
/jrrec | Toggle on and off |
/jrrec on | Mask emails, money, keys, phones, IPs |
/jrrec strict | Also home paths and long numbers |
/jrrec off | Show everything again |
/jrrec hide / /jrrec show | Hide or show the REC band, in every session; masking stays active |
/plugin install jr-record --marketplace jrlprost/claude-code-mods
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.
hooks/register.tsx 130 lines1import { 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}
130hooks/mask.ts 59 lines1// 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}
59types/index.d.ts 8 lines1export 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