SLOPSHOPPER

jr-mod-audit

/jrmodaudit owner/repo: clones a mod, runs the official validator on each plugin in it, reads its source, and shows what it can reach with a green, amber or…

newpanebandcommandprocesstimer
★ 2v0.1.1MITupdated 2026-10-15jrlprost/claude-code-mods/plugins/jr-mod-audit
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jr-mod-audit
│ ┃ jr mod audit ✕ › fix the failing auth test and add an audit log call │ ┃ jr mod audit │ ┃ Give a GitHub owner/repo, a git URL or a ⏺ Read(src/auth.ts) │ ┃ local folder. ⎿ 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 │ │ › /jrmodaudit │ ⎿ jr-mod-audit: jr mod audit · Give a GitHub owner/repo, a git URL │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · jr mod audit
jr mod audit Give a GitHub owner/repo, a git URL or a local folder.
README

jr-mod-audit

A mod runs inside your session and can reach your files, your network and your shell. /jrmodaudit looks at one before you install it.

jr mod audit  someone/claude-mods
● spy-mod  read the code before installing
    · reads local data or runs programs, and talks to the network
    · network: collect.example.net
    calls $.fs.read $.http.fetch $.ui.toast
● pretty-band  low reach
    calls $.state.get $.ui.resolve

For each plugin it finds in the repository, it:

  1. runs claude plugin validate --json, the official validator, and reads the calls it reports: every $ call the hooks module makes;
  2. reads the source of the hooks modules for the hosts it names and the programs it runs;
  3. gives a verdict.
VerdictWhen
🔴 read the code before installingFails validation; or reads files, the environment or the conversation, or runs programs, and talks to the network; or runs a shell, curl, python, node or osascript
🟡 check what it doesTalks to the network, runs programs, writes files or environment, sends prompts, starts agents or calls tools
🟢 low reachNone of the above: draws, keeps its own state

A red verdict is a reason to read the code, not proof of harm: a mod that checks a registry or posts to your own Slack has to read and send.

Commands

Command
/jrmodaudit owner/repoShallow-clone a GitHub repository and audit every plugin in it
/jrmodaudit https://…Any git URL
/jrmodaudit ~/pathA folder on your machine

Clones go to /tmp/jr-mod-audit.

Install

/plugin install jr-mod-audit --marketplace jrlprost/claude-code-mods

Access

Runs git clone, find and claude plugin validate. Reads the cloned files. Writes clones to /tmp/jr-mod-audit. Reads HOME to expand ~. Sends nothing over the network itself, beyond the clone.

Source 7 files
hooks/register.tsx 154 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { JrModAuditPlugin, JrModAuditReport } from '../types'
5import { assess, parseCalls, parseTarget, RISK_LABEL, scanSource } from './audit'
6import { gradientText } from './jr/draw'
7import { GRADIENTS } from './jr/gradient'
8import { safeGit } from './jr/git'
9import { printable } from './jr/text'
10
11const PANE = 'jr-mod-audit'
12const WORK = '/tmp/jr-mod-audit'
13const SOURCE = /\.(ts|tsx|js|jsx|mjs|cjs|mts|cts)$/
14const report = atom({ plugin: 'jr-mod-audit', key: 'report' } as const, null)
15const registered = { command: false }
16
17async function registerCommand($: EngineInterface) {
18  if (registered.command) return
19  registered.command = true
20  await $.command.register({ name: 'jrmodaudit', description: 'Audit a mod before installing it: /jrmodaudit owner/repo, a git URL or a folder' })
21}
22
23const RISK_COLOR = { green: '#2cb67d', amber: '#f4d35e', red: '#ef4565' } as const
24
25async function lines($: EngineInterface, argv: string[]) {
26  const { exitCode, stdout } = await $.process.run(argv, { timeoutMs: 120_000 })
27  return exitCode === 0 ? stdout.split('\n').filter(Boolean) : []
28}
29
30async function auditPlugin($: EngineInterface, dir: string): Promise<JrModAuditPlugin> {
31  const { stdout } = await $.process.run(['claude', 'plugin', 'validate', dir, '--json'], { timeoutMs: 60_000 })
32  let isValid = false
33  let errors: string[] = []
34  let notes: string[] = []
35  let name = dir.split('/').pop() ?? dir
36  try {
37    const json = JSON.parse(stdout) as { success: boolean; manifest?: { errors?: string[]; notes?: string[] }; contents?: { errors?: string[]; notes?: string[] }[] }
38    isValid = json.success
39    errors = [...(json.manifest?.errors ?? []), ...(json.contents ?? []).flatMap(c => c.errors ?? [])].map(String)
40    notes = [...(json.manifest?.notes ?? []), ...(json.contents ?? []).flatMap(c => c.notes ?? [])].map(String)
41  } catch {
42    errors = ['the validator gave no report']
43  }
44  try {
45    name = (JSON.parse(await $.fs.read(`${dir}/.claude-plugin/plugin.json`)) as { name?: string }).name ?? name
46  } catch {
47    // keep the folder name
48  }
49  let source = ''
50  for (const file of (await lines($, ['find', dir, '-type', 'f', '-not', '-path', '*/node_modules/*', '-not', '-path', '*/tests/*'])).filter(f => SOURCE.test(f)).slice(0, 60)) {
51    try {
52      source += `\n${await $.fs.read(file)}`
53    } catch {
54      // unreadable or over 4 MiB: skipped
55    }
56  }
57  const calls = parseCalls(notes)
58  const { hosts, commands } = scanSource(source)
59  const { risk, why } = assess(calls, hosts, commands, isValid)
60  return { name, dir, isValid, errors, calls, hosts, commands, risk, why }
61}
62
63/** Every string in the report made printable: names, errors and validator lines come from the audited repo. */
64const printableDeep = <T,>(value: T): T => {
65  if (typeof value === 'string') return printable(value, 300) as T
66  if (Array.isArray(value)) return value.map(printableDeep) as T
67  if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, printableDeep(v)])) as T
68  return value
69}
70
71async function runAudit($: EngineInterface, arg: string): Promise<JrModAuditReport> {
72  const target = parseTarget(arg, (await $.env.get('HOME')) ?? '')
73  if (!target) return { target: arg, status: 'error', error: 'Give a GitHub owner/repo, a git URL or a local folder.', plugins: [] }
74  let root = target.source
75  if (target.kind !== 'local') {
76    root = `${WORK}/${await $.clock.now()}`
77    await $.process.run(['mkdir', '-p', WORK])
78    const clone = await $.process.run(safeGit(['clone', '--depth', '1', '--quiet', '-c', 'core.symlinks=false', target.source, root]), { timeoutMs: 120_000 })
79    if (clone.exitCode !== 0) return { target: arg, status: 'error', error: `git clone failed: ${clone.stderr.trim().slice(0, 200)}`, plugins: [] }
80  }
81  const manifests = await lines($, ['find', root, '-path', '*/.claude-plugin/plugin.json', '-not', '-path', '*/node_modules/*'])
82  const plugins: JrModAuditPlugin[] = []
83  for (const manifest of manifests.slice(0, 40)) plugins.push(await auditPlugin($, manifest.replace(/\/\.claude-plugin\/plugin\.json$/, '')))
84  if (plugins.length === 0) return { target: arg, status: 'error', error: 'No plugin found (no .claude-plugin/plugin.json).', plugins }
85  const order = { red: 0, amber: 1, green: 2 }
86  plugins.sort((a, b) => order[a.risk] - order[b.risk] || a.name.localeCompare(b.name))
87  return { target: arg, status: 'done', plugins }
88}
89
90const summaryText = (r: JrModAuditReport) =>
91  r.status === 'error'
92    ? `jr mod audit · ${r.error}`
93    : `jr mod audit · ${r.target}: ${r.plugins.map(p => `${p.risk === 'red' ? '🔴' : p.risk === 'amber' ? '🟡' : '🟢'} ${p.name}`).join(', ')}`
94
95export const register: Register = on => {
96  on('session.start', async ($, e, next) => {
97    const started = await next(e)
98    await registerCommand($)
99    return started
100  })
101
102  // Installed mid-session, the mod saw no session.start: its command is registered on the first draw.
103  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
104    if (!registered.command) {
105      $.clock.after(0, () => {
106        void registerCommand($)
107      })
108    }
109    return next(e)
110  })
111
112  on('command.run', { command: 'jrmodaudit' }, async ($, e) => {
113    await update($, report, (): JrModAuditReport => ({ target: e.args.trim(), status: 'running', plugins: [] }))
114    await $.ui.open({ id: PANE, title: 'jr mod audit', rows: 24 })
115    // Everything in the report comes from the audited repository: printable text only.
116    const done = printableDeep(await runAudit($, e.args))
117    await update($, report, () => done)
118    return { text: summaryText(done) }
119  })
120
121  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
122    const { Box, Text } = $.ui.resolve(e)
123    const r = await read($, report)
124    if (!r) return <Text dimColor>Run /jrmodaudit owner/repo to audit a mod.</Text>
125    return (
126      <Box flexDirection="column">
127        <Box>
128          {gradientText(Text, 'jr mod audit', GRADIENTS.brand)}
129          <Text dimColor>{`  ${r.target}`}</Text>
130        </Box>
131        {r.status === 'running' && <Text dimColor>Cloning and validating…</Text>}
132        {r.status === 'error' && <Text color={RISK_COLOR.red}>{r.error}</Text>}
133        {r.plugins.map(p => (
134          <Box flexDirection="column">
135            <Text>
136              <Text color={RISK_COLOR[p.risk]} bold>{'● '}</Text>
137              <Text bold>{p.name}</Text>
138              <Text color={RISK_COLOR[p.risk]}>{`  ${RISK_LABEL[p.risk]}`}</Text>
139              {!p.isValid && <Text color={RISK_COLOR.red}>{'  validation failed'}</Text>}
140            </Text>
141            {p.why.map(w => (
142              <Text dimColor wrap="truncate-end">{`    · ${w}`}</Text>
143            ))}
144            {p.calls.length > 0 && <Text dimColor wrap="truncate-end">{`    calls ${p.calls.join(' ')}`}</Text>}
145            {p.errors.slice(0, 3).map(err => (
146              <Text color={RISK_COLOR.red} wrap="truncate-end">{`    ✘ ${err}`}</Text>
147            ))}
148          </Box>
149        ))}
150      </Box>
151    )
152  })
153}
154
hooks/audit.ts 78 lines
1// Turns a mod's validator report and source into a risk verdict.
2
3export type Risk = 'green' | 'amber' | 'red'
4
5export type PluginReport = {
6  name: string
7  dir: string
8  isValid: boolean
9  errors: string[]
10  calls: string[]
11  hosts: string[]
12  commands: string[]
13  risk: Risk
14  why: string[]
15}
16
17export type Target = { kind: 'github' | 'url' | 'local'; source: string }
18
19export const parseTarget = (arg: string, home: string): Target | null => {
20  const a = arg.trim()
21  if (!a) return null
22  if (a.startsWith('/')) return { kind: 'local', source: a }
23  if (a.startsWith('~/')) return { kind: 'local', source: `${home}${a.slice(1)}` }
24  if (/^(https?:\/\/|git@)/.test(a)) return { kind: 'url', source: a }
25  const gh = a.replace(/^github\.com\//, '').match(/^([\w.-]+)\/([\w.-]+?)(?:\.git)?$/)
26  return gh ? { kind: 'github', source: `https://github.com/${gh[1]}/${gh[2]}.git` } : null
27}
28
29/** `$.process.run`, `$.http.fetch` … from the validator's "calls:" notes. */
30export const parseCalls = (notes: readonly string[]) => {
31  const calls = new Set<string>()
32  for (const note of notes) {
33    const m = note.match(/\bcalls: (.+)$/)
34    if (!m) continue
35    for (const part of (m[1] ?? '').split(/,\s*(?![^()]*\))/)) {
36      const call = part.replace(/\s*\(via [^)]*\)/, '').trim()
37      if (call.startsWith('$.')) calls.add(call)
38    }
39  }
40  return [...calls].sort()
41}
42
43/** Hosts named in the source, and the programs it runs by name. */
44export const scanSource = (source: string) => {
45  const hosts = new Set<string>()
46  for (const m of source.matchAll(/https?:\/\/([a-z0-9.-]+\.[a-z]{2,})/gi)) hosts.add(m[1]!.toLowerCase())
47  const commands = new Set<string>()
48  for (const m of source.matchAll(/process\.(?:run|spawn)\(\s*(?:\{\s*argv:\s*)?\[\s*['"`]([^'"`]+)['"`]/g)) commands.add(m[1]!)
49  return { hosts: [...hosts].sort(), commands: [...commands].sort() }
50}
51
52const DOCS_HOSTS = /(^|\.)(github\.com|githubusercontent\.com|example\.com|anthropic\.com|claude\.com|claude\.ai|json\.schemastore\.org|npmjs\.com)$/
53
54export const assess = (calls: readonly string[], hosts: readonly string[], commands: readonly string[], isValid: boolean): { risk: Risk; why: string[] } => {
55  const has = (prefix: string) => calls.some(c => c.startsWith(prefix))
56  const why: string[] = []
57  const network = has('$.http.')
58  const runs = has('$.process.')
59  const reads = has('$.fs.read') || has('$.fs.list') || has('$.env.get') || has('$.session.messages')
60  const writes = has('$.fs.write') || has('$.env.set')
61  const drives = has('$.prompt.submit') || has('$.agent.') || has('$.tool.call')
62  const outsideHosts = hosts.filter(h => !DOCS_HOSTS.test(h))
63  const shells = commands.filter(c => /^(sh|bash|zsh|curl|wget|python3?|node|osascript)$/.test(c))
64  if (!isValid) why.push('does not pass claude plugin validate')
65  if (network && (reads || runs)) why.push('reads local data or runs programs, and talks to the network')
66  if (shells.length) why.push(`runs ${shells.join(', ')}`)
67  const isRed = why.length > 0
68  if (network) why.push(`network${outsideHosts.length ? `: ${outsideHosts.join(', ')}` : ''}`)
69  if (runs) why.push(`runs programs${commands.length ? `: ${commands.join(', ')}` : ''}`)
70  if (writes) why.push('writes files or environment')
71  if (drives) why.push('sends prompts, starts agents or calls tools')
72  if (reads) why.push('reads files, environment or the conversation')
73  const risk: Risk = isRed ? 'red' : network || runs || writes || drives ? 'amber' : 'green'
74  return { risk, why: [...new Set(why)] }
75}
76
77export const RISK_LABEL: Record<Risk, string> = { green: 'low reach', amber: 'check what it does', red: 'read the code before installing' }
78
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
hooks/jr/text.ts 13 lines
1// Text from outside (an API, a status page, a cloned repository) is drawn only once it is
2// printable: control characters, escape sequences included, become a visible "�" so nothing
3// can move the cursor, recolour the terminal or hide what follows.
4// Synced into each plugin's hooks/jr/ by scripts/sync-shared.sh.
5
6// C0 controls (tab kept), DEL, C1 controls, and bidi overrides that reorder what is shown.
7const UNPRINTABLE = /[\u0000-\u0008\u000a-\u001f\u007f-\u009f‪-‮⁦-⁩]/g
8
9export const printable = (text: unknown, max = 200) => {
10  const s = String(text ?? '').replace(UNPRINTABLE, '�')
11  return s.length > max ? `${s.slice(0, max - 1)}…` : s
12}
13
types/index.d.ts 27 lines
1export type JrModAuditRisk = 'green' | 'amber' | 'red'
2
3export type JrModAuditPlugin = {
4  name: string
5  dir: string
6  isValid: boolean
7  errors: string[]
8  calls: string[]
9  hosts: string[]
10  commands: string[]
11  risk: JrModAuditRisk
12  why: string[]
13}
14
15export type JrModAuditReport = {
16  target: string
17  status: 'running' | 'done' | 'error'
18  error?: string
19  plugins: JrModAuditPlugin[]
20}
21
22declare module 'claude-code' {
23  interface PluginState {
24    'jr-mod-audit': { report: JrModAuditReport | null }
25  }
26}
27