SLOPSHOPPER

persona

Pick a role for Claude on this machine (UI/UX, Backend, Dev, QA, Docs, PM); edits and shell commands outside the role are denied, and the Claude crab dressed…

newspinnerguardcommandtoastprompt
★ 3v1.0.0MITupdated 2026-10-15omeerdvrn/o-mods/mods/persona
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · persona
› 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 › /persona ⎿ persona: No persona active. Claude can edit any file. Run /persona list. ⎿ persona: ⎿ persona: ○ ui-ux UI/UX Designer: Components, styles, layout and assets only. API, server and database are off limits ⎿ persona: ○ backend Backend Developer: API, server, database and business logic. UI files are off limits. ⎿ persona: ○ dev Developer: All application code, frontend and backend. Secrets, CI and infrastructure are off limit ⎿ persona: ○ qa QA Engineer: Tests, fixtures and test config only. Application code is off limits. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

persona

Sets the role Claude works for on this machine. Each persona adds a role prompt to the system prompt and denies edits and shell commands outside its scope. A denied call never runs. Claude is told to explain the change that's needed instead of working around the block.

With the UI/UX Designer persona, Claude edits a button style but is blocked from changing an API file. The footer shows the active role.

Personas

IdNameMay editBlocked
ui-uxUI/UX DesignerComponents, pages, styles, assets, stories, Tailwind and PostCSS configAPI, server, database, migrations, .sql/.prisma/.graphql, secrets, infra, DB and deploy commands
backendBackend DeveloperEverything except UI.tsx/.jsx/.vue/.svelte, stylesheets, components/, public/, secrets, deploy commands
devDeveloperAll application codeSecrets, CI workflows, Docker, Terraform, Kubernetes, Helm, deploy commands
qaQA EngineerTests, specs, fixtures, mocks, snapshots, test configApplication code, secrets, migrations, deploy commands
docsTechnical WriterMarkdown, MDX, docs/, READMEs, changelogsCode, secrets, DB and deploy commands
pmProduct ManagerNothing in the projectEvery write, git commit/push/reset, package installs, DB and deploy commands

Use

/persona            # status and list
/persona ui-ux      # switch (names work too: /persona designer)
/persona off        # clear

The choice is saved on this machine and applies to every session and project. The persona shows at the end of the hint line under the prompt.

  • In terminals that show pictures through the kitty graphics protocol (kitty, Ghostty, and others with it on), it is the Claude crab dressed for the role, one row tall, then the role's name.
  • In the Claude desktop app, which shows no hint line, the role's name joins the footer's mode labels, beside the model.
  • In every other terminal, including inside tmux and other multiplexers, it is the role's name alone.

The mod finds out which by trying once: it draws the picture, and when the terminal shows the picture's placeholder text instead, it switches to the name.

Showing the crab in WezTerm and herdr

kitty and Ghostty show the crab with no setup. WezTerm, and herdr in front of it, need three settings. persona sets them for you:

CommandWhat it does
/persona picturesLists what this machine needs, file by file. Changes nothing.
/persona pictures fixMakes those changes, keeping a backup of each file, and says what to restart.

The changes:

  • WezTerm config: config.enable_kitty_graphics = true.
  • herdr config, before herdr 0.9: [experimental] kitty_graphics = true. From 0.9, herdr has this on by default.
  • Shell profile (zsh, bash or fish): inside herdr only, CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1. herdr calls itself tmux, and its terminal (libghostty) is not on Claude Code's list of terminals that show pictures, so Claude Code needs telling.

After the fix, quit WezTerm and open it again, then start Claude Code from a new herdr pane.

When the crab cannot show but the terminal can be set up for it, persona says so once, in a notification. /persona status says whether the picture showed, and why not when it did not.

persona never changes these files on its own. They are your terminal's and shell's settings, so they change only when you run /persona pictures fix.

PersonaThe crab
Developerheadphones, a laptop in front
UI/UX Designera beret, a brush held up and a palette
Backend Developera hard hat with a lamp, a server rack
QA Engineera detective's cap, a magnifying glass
Technical Writera pencil behind the ear, an open book
Product Managera clipboard

A persona you define in a file gets the crab alone. The crab is 16×8 pixel art, drawn as a picture four cells wide and one row tall.

To pin a persona without the command, set CLAUDE_PERSONA in ~/.claude/settings.json:

{ "env": { "CLAUDE_PERSONA": "ui-ux" } }

CLAUDE_PERSONA wins over the saved choice. CLAUDE_PERSONA=off disables personas for that shell.

What it checks

  • Edit, Write and NotebookEdit: the target path, and the real path when it is a symbolic link.
  • Bash: each persona's blocked command patterns, plus the files a command visibly writes: redirects (>, >>), tee, sed -i, perl -i, rm, mv, touch, mkdir, copy destinations, and git checkout/restore/rm/mv. patch and git apply are denied under a restricted persona because their target files can't be checked.
  • Rules apply to files inside the session's working directory. Outside it, for example scratch files, memory and plans, only the blocked list applies.

The Bash check is best effort. A script that writes files on its own (python -c, node -e, a build step) isn't read.

Customize

Add a JSON file per persona in ~/.claude/personas/ for this machine, or in <project>/.claude/personas/ for one repository. Both are read at session start and on every /persona call, and the project file wins. A file with a built-in id changes that persona. Any other id adds a new persona.

{
  "id": "ui-ux",
  "extraAllow": ["src/hooks/use*.ts"],
  "extraDeny": ["src/lib/api-client/**"],
  "extraBashDeny": ["\\bnpm\\s+run\\s+db:"]
}
FieldMeaning
id, name, summary, promptIdentity and the text added to the system prompt
writes"none", or { "allow": [globs], "deny": [globs] }. Without allow, any path that isn't denied may be written. A deny match always wins.
bashDenyRegular expressions for blocked commands
extraAllow, extraDeny, extraBashDenyAdded to the existing lists instead of replacing them

Globs are matched against the path relative to the project. ** spans folders, * and ? match within one folder, and {a,b} matches either.

Develop

claude plugin validate mods/persona
claude plugin test mods/persona
Source 4 files
hooks/register.ts 371 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { crabPicture, crabSvg } from './crabs'
4import { planPictures, profileOf } from './pictures'
5import type { Plan, Setup } from './pictures'
6import { BUILTIN, absolute, bashVerdict, findPersona, mergePersonas, writeVerdict } from './personas'
7import type { Persona, PersonaFile } from './personas'
8
9const STORE_KEY = 'active'
10const ENV_KEY = 'CLAUDE_PERSONA'
11
12let personas: Persona[] = BUILTIN
13
14async function readFolder($: EngineInterface, folder: string): Promise<PersonaFile[]> {
15  const entries = await $.fs.list(folder).catch(() => [])
16  const files: PersonaFile[] = []
17  for (const entry of entries) {
18    if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
19    try {
20      const parsed = JSON.parse(await $.fs.read(`${folder}/${entry.name}`)) as PersonaFile
21      files.push({ ...parsed, id: parsed.id ?? entry.name.replace(/\.json$/, '') })
22    } catch {
23      $.ui.toast(`skipped ${folder}/${entry.name}, not valid JSON`)
24    }
25  }
26
27  return files
28}
29
30/** Builtins, then `~/.claude/personas/*.json`, then the project's `.claude/personas/*.json`. */
31async function loadPersonas($: EngineInterface) {
32  const home = await $.env.get('HOME')
33  const cwd = await $.session.cwd()
34  const machine = home ? await readFolder($, `${home}/.claude/personas`) : []
35  const project = await readFolder($, `${cwd}/.claude/personas`)
36  personas = mergePersonas(BUILTIN, [machine, project])
37}
38
39type Active = { persona?: Persona; source?: 'env' | 'store'; unknown?: string }
40
41/** `CLAUDE_PERSONA` wins over the persona saved on this machine with `/persona`. */
42async function activePersona($: EngineInterface): Promise<Active> {
43  const fromEnv = (await $.env.get('CLAUDE_PERSONA'))?.trim()
44  if (fromEnv && fromEnv !== 'off') {
45    const persona = findPersona(personas, fromEnv)
46    return persona ? { persona, source: 'env' } : { persona: undefined, unknown: fromEnv }
47  }
48  if (fromEnv === 'off') return { persona: undefined }
49  const saved = await $.store.get(STORE_KEY)
50  if (typeof saved !== 'string') return { persona: undefined }
51  const persona = findPersona(personas, saved)
52
53  return persona ? { persona, source: 'store' } : { persona: undefined, unknown: saved }
54}
55
56/** Redraws the hint line, where the active persona's name is shown. */
57function showStatus($: EngineInterface, _active: Active) {
58  $.ui.invalidate('ui.render')
59}
60
61function denial(persona: Persona, reason: string): string {
62  return `persona "${persona.name}": ${reason}. Do not try another way to make this change; tell the person what change is needed and who should make it. The person can switch personas with /persona.`
63}
64
65async function checkWrite($: EngineInterface, path: string): Promise<string | undefined> {
66  const active = await activePersona($)
67  if (active.unknown) return `persona "${active.unknown}" is set but not defined; run /persona list. Writes are blocked until it is fixed`
68  if (!active.persona) return undefined
69  const cwd = await $.session.cwd()
70  const target = absolute(path, cwd)
71  const verdict = writeVerdict(active.persona, target, cwd)
72  if (verdict) return denial(active.persona, verdict)
73  // A link inside an allowed folder may lead into a forbidden one.
74  const stat = await $.fs.stat(target, { resolve: true }).catch(() => undefined)
75  const real = stat?.realPath
76  const realVerdict = real && real !== target ? writeVerdict(active.persona, real, cwd) : undefined
77
78  return realVerdict ? denial(active.persona, realVerdict) : undefined
79}
80
81const GUARD_FAILED = 'persona: the guard could not check this call, so it is blocked. Run /persona status.'
82
83function listText(active: Active): string {
84  const rows = personas.map(
85    persona => `${persona.id === active.persona?.id ? '●' : '○'} ${persona.id.padEnd(8)} ${persona.name}: ${persona.summary}`,
86  )
87
88  return [...rows, '', 'Use /persona <id> to switch, /persona off to clear.'].join('\n')
89}
90
91function statusText(active: Active): string {
92  if (active.unknown) return `persona "${active.unknown}" is set but not defined. Run /persona list.`
93  if (!active.persona) return 'No persona active. Claude can edit any file. Run /persona list.'
94  const from = active.source === 'env' ? `from $${ENV_KEY}` : 'saved on this machine'
95  const writes = active.persona.writes
96  const scope =
97    writes === 'none'
98      ? 'read-only'
99      : [writes.allow ? `may edit ${writes.allow.length} file patterns` : 'may edit any file', writes.deny ? `blocks ${writes.deny.length} patterns` : '']
100          .filter(Boolean)
101          .join(', ')
102
103  return `Persona: ${active.persona.name} (${active.persona.id}, ${from}). ${active.persona.summary} Rules: ${scope}.`
104}
105
106/**
107 * The probe repaints the first picture to learn whether pictures show; some terminals (herdr before 0.9)
108 * drop a picture that is replaced in place. So once the probe passes, the crab is drawn under a new key:
109 * a new picture, sent whole, never the replaced one.
110 */
111const PROBE_PICTURE_KEY = 'crab-probe'
112const PICTURE_KEY = 'crab'
113const PROBE_KEY = 'pictureProbe'
114/** The crab is 16×8 pixels: four cells wide by one tall keeps its pixels square. */
115const PICTURE_COLUMNS = 4
116
117/** Whether this terminal shows pictures: unknown until the first one is drawn and tried. */
118let pictures: 'unknown' | 'yes' | 'no' = 'unknown'
119let isProbing = false
120
121/**
122 * Asks the terminal once whether it shows pictures: repainting a drawn picture is refused where the
123 * terminal shows its alt text instead. Where it does not, the name alone is drawn from then on.
124 */
125/** Keeps why pictures did or did not show, with the terminal as the session sees it, for `/persona status`. */
126async function recordProbe($: EngineInterface, outcome: string) {
127  const env = [
128    `TERM=${(await $.env.get('TERM')) ?? ''}`,
129    `TERM_PROGRAM=${(await $.env.get('TERM_PROGRAM')) ?? ''}`,
130    `WEZTERM=${(await $.env.get('WEZTERM_EXECUTABLE')) ? 'yes' : 'no'}`,
131    `HERDR=${(await $.env.get('HERDR_ENV')) ? 'yes' : 'no'}`,
132    `TMUX=${(await $.env.get('TMUX')) ? 'yes' : 'no'}`,
133  ]
134  await $.store.set(PROBE_KEY, `${outcome} (${env.join(' ')})`)
135}
136
137function probePictures($: EngineInterface, requestId: string, source: ReturnType<typeof crabPicture>) {
138  if (isProbing) return
139  isProbing = true
140  $.clock.after(300, () => {
141    void $.ui
142      .blit({ requestId, key: PROBE_PICTURE_KEY, source, columns: PICTURE_COLUMNS, rows: 1 })
143      .then(async result => {
144        const deny = 'deny' in result && result.deny ? String(result.deny) : ''
145        pictures = deny ? 'no' : 'yes'
146        await recordProbe($, deny || 'pictures shown')
147        if (deny) await hintPictures($)
148      })
149      .catch(async (error: unknown) => {
150        pictures = 'no'
151        await recordProbe($, `blit failed: ${error instanceof Error ? error.message : String(error)}`)
152      })
153      .finally(() => {
154        isProbing = false
155        $.ui.invalidate('ui.render')
156      })
157  })
158}
159
160const HINTED_KEY = 'picturesHinted'
161
162/** This machine's terminal chain and shell, as the session sees them. */
163async function readSetup($: EngineInterface): Promise<Setup> {
164  const home = (await $.env.get('HOME')) ?? ''
165  const shellPath = (await $.env.get('SHELL')) ?? ''
166  const isHerdr = Boolean(await $.env.get('HERDR_ENV'))
167  const version = isHerdr ? await $.process.run(['herdr', '--version'], { timeoutMs: 5_000 }).catch(() => null) : null
168
169  return {
170    home,
171    shell: shellPath.split('/').pop() ?? '',
172    isHerdr,
173    herdrVersion: version?.exitCode === 0 ? version.stdout.trim() : null,
174    isWezterm: Boolean(await $.env.get('WEZTERM_EXECUTABLE')) || (await $.env.get('TERM_PROGRAM')) === 'WezTerm',
175    weztermConfig: (await $.env.get('WEZTERM_CONFIG_FILE')) ?? null,
176    isForced: Boolean(await $.env.get('CLAUDE_CODE_FORCE_TERMINAL_IMAGES')),
177  }
178}
179
180/** The plan for this machine, reading each file it may change. */
181async function planFor($: EngineInterface): Promise<Plan> {
182  const setup = await readSetup($)
183  const paths = [
184    setup.weztermConfig ?? `${setup.home}/.config/wezterm/wezterm.lua`,
185    `${setup.home}/.config/herdr/config.toml`,
186    profileOf(setup.home, setup.shell),
187  ].filter((path): path is string => path !== null)
188  const files: Record<string, string | undefined> = {}
189  for (const path of paths) files[path] = await $.fs.read(path).then(text => String(text)).catch(() => undefined)
190
191  return planPictures(setup, files)
192}
193
194/** `/persona pictures` describes what this machine needs for the crab; `/persona pictures fix` does it. */
195async function picturesCommand($: EngineInterface, isFix: boolean): Promise<string> {
196  const plan = await planFor($)
197  const probe = await $.store.get(PROBE_KEY)
198  const now = typeof probe === 'string' ? `Now: ${probe}\n\n` : ''
199  if (plan.edits.length === 0 && plan.manual.length === 0) {
200    return `${now}Nothing to change: this machine is set up for the crab picture.`
201  }
202  if (!isFix) {
203    const edits = plan.edits.map(edit => `- ${edit.label} (${edit.path})`)
204    const manual = plan.manual.map(line => `- ${line}`)
205    return [
206      now.trim(),
207      edits.length > 0 ? `\`/persona pictures fix\` would change, keeping a backup of each file:\n${edits.join('\n')}` : '',
208      manual.length > 0 ? `To do by hand:\n${manual.join('\n')}` : '',
209    ]
210      .filter(Boolean)
211      .join('\n\n')
212  }
213
214  const stamp = new Date((await $.clock.now()) || Date.now()).toISOString().replace(/[-:]/g, '').slice(0, 15)
215  const done: string[] = []
216  for (const edit of plan.edits) {
217    const old = await $.fs.read(edit.path).then(text => String(text)).catch(() => undefined)
218    if (old !== undefined) await $.fs.write(`${edit.path}.bak-${stamp}`, old)
219    await $.fs.write(edit.path, edit.text)
220    done.push(`- ${edit.label}: ${edit.path}${old !== undefined ? ` (backup: ${edit.path}.bak-${stamp})` : ''}`)
221  }
222  // herdr reads its config again on request; the rest takes a restart.
223  if (plan.edits.some(edit => edit.path.endsWith('/herdr/config.toml'))) {
224    await $.process.run(['herdr', 'server', 'reload-config'], { timeoutMs: 10_000 }).catch(() => undefined)
225  }
226  pictures = 'unknown'
227  const restart = plan.restart.filter(line => !line.startsWith('Run `herdr server'))
228
229  return [
230    `Changed:\n${done.join('\n')}`,
231    plan.manual.length > 0 ? `Still to do by hand:\n${plan.manual.map(line => `- ${line}`).join('\n')}` : '',
232    restart.length > 0 ? `Then:\n${restart.map(line => `- ${line}`).join('\n')}` : '',
233  ]
234    .filter(Boolean)
235    .join('\n\n')
236}
237
238/** Once per machine, when the crab cannot show but this setup can be fixed, says how. */
239async function hintPictures($: EngineInterface) {
240  if ((await $.store.get(HINTED_KEY)) === true) return
241  const plan = await planFor($)
242  if (plan.edits.length === 0) return
243  await $.store.set(HINTED_KEY, true)
244  $.ui.toast('This terminal can show the role crab. Run /persona pictures to see what it needs.', { timeoutMs: 12_000 })
245}
246
247export const register: Register = on => {
248  // The desktop app shows no hint line; there the role rides the footer's mode labels instead.
249  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
250    if (e.surface !== 'desktop') return next(e)
251    const active = await activePersona($)
252    if (!active.persona) return next(e)
253
254    return next({ ...e, props: { ...e.props, modes: [...e.props.modes, active.persona.name] } })
255  })
256
257  // Under the prompt, after the hint line: the persona's crab as a one-row picture, then its name. Only
258  // terminals with the kitty graphics protocol show pictures; everywhere else the name alone rides the
259  // engine's own hint line, so its pills stay live.
260  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
261    const active = await activePersona($)
262    if (!active.persona) return next(e)
263    const name = active.persona.name
264    // The desktop app draws vectors: the crab as SVG, crisp at any size.
265    if (e.surface === 'desktop') {
266      const { Box, Svg, Text } = $.ui.resolve(e)
267      return h(
268        Box,
269        { flexDirection: 'row', alignItems: 'center', columnGap: 1 },
270        e.props.hint === '' ? null : h(Text, { dimColor: true }, `${e.props.hint} · `),
271        h(Svg, { source: crabSvg(active.persona.id), width: 32, height: 16, alt: `${name} crab` }),
272        h(Text, {}, name),
273      ) as never
274    }
275    if (pictures === 'no' || e.surface !== 'terminal') {
276      return next({ ...e, props: { ...e.props, tail: e.props.tail ? `${e.props.tail} · ${name}` : name } })
277    }
278    const { Box, Image, Text } = $.ui.resolve(e)
279    const source = crabPicture(active.persona.id)
280    if (pictures === 'unknown') probePictures($, e.requestId, source)
281
282    return h(
283      Box,
284      { flexDirection: 'row' },
285      e.props.hint === '' ? null : h(Text, { dimColor: true }, `${e.props.hint} · `),
286      h(Image, { key: pictures === 'yes' ? PICTURE_KEY : PROBE_PICTURE_KEY, source, columns: PICTURE_COLUMNS, rows: 1, alt: ' ' }),
287      h(Text, {}, ` ${name}`),
288    ) as never
289  })
290
291  on('session.start', async ($, e, next) => {
292    // Not awaited: registering makes Claude Code re-describe every command (~0.7 s), which would hold startup.
293    // Typed as /persona in the terminal; the command file is what the desktop app lists in its menu.
294    void $.command.register({
295      name: 'persona',
296      description: "Set the role Claude works for on this machine: what it may edit and how it answers",
297      argumentHint: '[ui-ux|backend|dev|qa|docs|pm|off|list|status|pictures]',
298    }).catch(() => undefined)
299    await loadPersonas($).catch(() => undefined)
300    showStatus($, await activePersona($))
301
302    return next(e)
303  })
304
305  on('command.run', { command: ['persona', 'persona:persona'] }, async ($, e) => {
306    await loadPersonas($).catch(() => undefined)
307    const wanted = e.args.trim()
308    const before = await activePersona($)
309    if (wanted === '' || wanted === 'status') {
310      const probe = await $.store.get(PROBE_KEY)
311      const pictureLine = typeof probe === 'string' ? `\nCrab picture: ${probe}` : ''
312      return { text: `${statusText(before)}${pictureLine}\n\n${listText(before)}` }
313    }
314    if (wanted === 'list') return { text: listText(before) }
315    if (wanted === 'pictures' || wanted === 'pictures fix') return { text: await picturesCommand($, wanted === 'pictures fix') }
316    const envNote = before.source === 'env' ? `\nNote: $${ENV_KEY} is set and overrides this until it is unset.` : ''
317    if (wanted === 'off') {
318      await $.store.delete(STORE_KEY)
319      const after = await activePersona($)
320      showStatus($, after)
321      return { text: `Persona cleared on this machine.${envNote}` }
322    }
323    const persona = findPersona(personas, wanted)
324    if (!persona) return { text: `No persona "${wanted}".\n\n${listText(before)}` }
325    await $.store.set(STORE_KEY, persona.id)
326    const after = await activePersona($)
327    showStatus($, after)
328
329    return {
330      text: `Persona set to ${persona.name} on this machine. ${persona.summary}${envNote}`,
331      context: [`The active persona is now ${persona.name}. ${persona.prompt}`],
332    }
333  })
334
335  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
336    const reason = await checkWrite($, e.file_path)
337    return reason ? { deny: reason } : next(e)
338  }).catch(($, e, next) => (next.called ? next(e) : { deny: GUARD_FAILED }))
339
340  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
341    const reason = await checkWrite($, e.file_path)
342    return reason ? { deny: reason } : next(e)
343  }).catch(($, e, next) => (next.called ? next(e) : { deny: GUARD_FAILED }))
344
345  on('tool.call', { tool: 'NotebookEdit' }, async ($, e, next) => {
346    const reason = await checkWrite($, e.notebook_path)
347    return reason ? { deny: reason } : next(e)
348  }).catch(($, e, next) => (next.called ? next(e) : { deny: GUARD_FAILED }))
349
350  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
351    const active = await activePersona($)
352    if (!active.persona) return next(e)
353    const cwd = await $.session.cwd()
354    const verdict = bashVerdict(active.persona, e.command, cwd, cwd)
355    return verdict ? { deny: denial(active.persona, verdict) } : next(e)
356  }).catch(($, e, next) => (next.called ? next(e) : { deny: GUARD_FAILED }))
357
358  on('prompt.compose', async ($, e, next) => {
359    const composed = await next(e)
360    const { persona } = await activePersona($)
361    if (!persona) return composed
362    const text = [
363      `# Active persona: ${persona.name}`,
364      persona.prompt,
365      `A hook enforces this persona: edits and shell commands outside its scope are denied. When one is denied, do not work around it (no other tool, script or path); explain what change is needed and who should make it.`,
366    ].join('\n\n')
367
368    return { sections: [...composed.sections, { id: 'persona:active', text, scope: 'session' }] }
369  })
370}
371
hooks/crabs.ts 209 lines
1/**
2 * The Claude crab for each persona: pixel art in half blocks, four rows tall. Each cell is two pixels,
3 * the upper half block's color over its background, so any two colors can share a cell (a hat on the
4 * head, a laptop in front of the body) and the pixels come out square. Half blocks are in every font,
5 * so the crab draws the same in every terminal.
6 */
7
8const COLORS: Record<string, string> = {
9  o: '#D4826A', // the crab
10  e: '#1B1B1B', // its eyes
11  h: '#3A3A3F', // headphone band
12  u: '#8C86E8', // headphone cups
13  L: '#C9CCD3', // laptop
14  p: '#8C86E8', // laptop logo
15  r: '#C9505A', // beret
16  b: '#B08A5A', // brush handle
17  B: '#4E9BE8', // brush tip
18  P: '#E8DCC0', // palette
19  d: '#E05F5F', // paint
20  y: '#E8B931', // hard hat
21  a: '#C98500', // its lamp
22  s: '#4A4F57', // server rack
23  g: '#4FD17A', // server lights
24  k: '#8A6A4A', // detective cap
25  l: '#5BC0DE', // lens
26  m: '#9AA0A6', // magnifier handle
27  n: '#E8C547', // pencil
28  w: '#F2EFE6', // paper
29  c: '#B07A48', // clipboard
30  t: '#4E8DE0', // book
31}
32
33/** The crab, 16×8 pixels: room for a hat in row 0, the head with its eyes, arms out, four legs. */
34const CRAB = [
35  '................',
36  '...oooooooooo...',
37  '...ooeooooeoo...',
38  '...oooooooooo...',
39  '.oooooooooooooo.',
40  '...oooooooooo...',
41  '....o.o..o.o....',
42  '....o.o..o.o....',
43]
44
45/** Each persona's hat and prop over the crab: `.` leaves the crab as it is. */
46const DRESS: Record<string, string[]> = {
47  // Headphones, and a laptop held in front.
48  dev: [
49    '...hhhhhhhhhh...',
50    '..h..........h..',
51    '..u..........u..',
52    '..u..........u..',
53    '....LLLLLLLL....',
54    '....LLLpLLLL....',
55    '....LLLLLLLL....',
56    '................',
57  ],
58  // A beret; a brush held up, a palette in the other hand.
59  'ui-ux': [
60    '.....rrrrrr....B',
61    '...rrrrrrrrrr.bB',
62    '..............b.',
63    '..............b.',
64    'Pd..............',
65    'PP..............',
66    '................',
67    '................',
68  ],
69  // A hard hat with its lamp, and a server rack with its lights on.
70  backend: [
71    '.....yyayyy.....',
72    '..yyyyyyyyyyyy..',
73    '..............ss',
74    '..............gs',
75    '..............ss',
76    '..............gs',
77    '..............ss',
78    '................',
79  ],
80  // A detective's cap, and a magnifying glass.
81  qa: [
82    '....kkkkkkk.....',
83    '...kkkkkkkkkk...',
84    '.............lll',
85    '.............l.l',
86    '............mlll',
87    '................',
88    '................',
89    '................',
90  ],
91  // A pencil behind the ear, and an open book.
92  docs: [
93    '...............n',
94    '..............n.',
95    '.............n..',
96    '................',
97    '....tttttttt....',
98    '....twwwtwwt....',
99    '....tttttttt....',
100    '................',
101  ],
102  // A clipboard with a checklist.
103  pm: [
104    '................',
105    '................',
106    '................',
107    '.....cccccc.....',
108    '.....cwwwwc.....',
109    '.....cwccwc.....',
110    '.....cwwwwc.....',
111    '................',
112  ],
113}
114
115/** One cell of a drawn row: its glyph, the glyph's color and, when both halves are painted, the background. */
116export type Run = { text: string; color?: string; background?: string }
117
118/** The crab dressed for a persona; a persona from a file gets the crab alone. */
119export function dressedArt(id: string): string[] {
120  const dress = DRESS[id]
121  if (!dress) return CRAB
122
123  return CRAB.map((line, row) =>
124    [...line].map((pixel, column) => {
125      const over = dress[row]?.[column] ?? '.'
126      return over === '.' ? pixel : over
127    }).join(''),
128  )
129}
130
131/** Pixel art to rows of cells: each cell is a column's two pixels, top and bottom. */
132export function toRuns(art: string[], colors: Record<string, string>): Run[][] {
133  const width = Math.max(...art.map(line => line.length))
134  const rows: Run[][] = []
135  for (let top = 0; top < art.length; top += 2) {
136    const runs: Run[] = []
137    for (let column = 0; column < width; column++) {
138      const upper = colors[art[top]?.[column] ?? '.']
139      const lower = colors[art[top + 1]?.[column] ?? '.']
140      if (!upper && !lower) runs.push({ text: ' ' })
141      else if (!upper) runs.push({ text: '▄', color: lower })
142      else if (!lower) runs.push({ text: '▀', color: upper })
143      else if (upper === lower) runs.push({ text: '█', color: upper })
144      else runs.push({ text: '▀', color: upper, background: lower })
145    }
146    rows.push(runs)
147  }
148  return rows
149}
150
151/** The persona's crab as four rows of cells. */
152export function crabFor(id: string): Run[][] {
153  return toRuns(dressedArt(id), COLORS)
154}
155
156const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
157
158function toBase64(bytes: Uint8Array): string {
159  let out = ''
160  for (let index = 0; index < bytes.length; index += 3) {
161    const a = bytes[index]!
162    const b = bytes[index + 1]
163    const c = bytes[index + 2]
164    out += ALPHABET[a >> 2]
165    out += ALPHABET[((a & 3) << 4) | ((b ?? 0) >> 4)]
166    out += b === undefined ? '=' : ALPHABET[((b & 15) << 2) | ((c ?? 0) >> 6)]
167    out += c === undefined ? '=' : ALPHABET[c & 63]
168  }
169  return out
170}
171
172/** The persona's crab as a picture: 16×8 RGBA pixels, transparent around it, for an `Image`. */
173export function crabPicture(id: string): { rgba: string; width: number; height: number } {
174  const art = dressedArt(id)
175  const width = Math.max(...art.map(line => line.length))
176  const height = art.length
177  const bytes = new Uint8Array(width * height * 4)
178  art.forEach((line, row) =>
179    [...line].forEach((pixel, column) => {
180      const color = COLORS[pixel]
181      if (!color) return
182      const at = (row * width + column) * 4
183      bytes[at] = Number.parseInt(color.slice(1, 3), 16)
184      bytes[at + 1] = Number.parseInt(color.slice(3, 5), 16)
185      bytes[at + 2] = Number.parseInt(color.slice(5, 7), 16)
186      bytes[at + 3] = 255
187    }),
188  )
189
190  return { rgba: toBase64(bytes), width, height }
191}
192
193/**
194 * The persona's crab as SVG, for surfaces without pictures over the terminal (the desktop app): the same
195 * 16×8 pixels, one square each, drawn crisp at any size.
196 */
197export function crabSvg(id: string): string {
198  const art = dressedArt(id)
199  const width = Math.max(...art.map(line => line.length))
200  const squares = art.flatMap((line, row) =>
201    [...line].map((pixel, column) => {
202      const color = COLORS[pixel]
203      return color ? `<rect x="${column}" y="${row}" width="1" height="1" fill="${color}"/>` : ''
204    }),
205  )
206
207  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${art.length}" shape-rendering="crispEdges">${squares.join('')}</svg>`
208}
209
hooks/pictures.ts 127 lines
1/**
2 * What a machine needs before Claude Code draws pictures (the persona crab), and the edits that give it.
3 *
4 * The chain that works: WezTerm with kitty graphics on, optionally herdr between it and Claude Code with
5 * kitty graphics passed through, and inside herdr Claude Code told to draw pictures anyway: herdr calls
6 * itself tmux and its terminal (libghostty) is not on Claude Code's list. kitty and Ghostty need nothing.
7 */
8
9export type Setup = {
10  home: string
11  shell: string
12  isHerdr: boolean
13  herdrVersion: string | null
14  isWezterm: boolean
15  weztermConfig: string | null
16  isForced: boolean
17}
18
19/** One edit: the file, what it does, and its whole new text. */
20export type Edit = { path: string; label: string; text: string }
21
22/** What to change, what the person must do themselves, and what is fine already. */
23export type Plan = { edits: Edit[]; manual: string[]; restart: string[] }
24
25export const FORCE_VARIABLE = 'CLAUDE_CODE_FORCE_TERMINAL_IMAGES'
26
27/** WezTerm's config with `enable_kitty_graphics = true`, or null when it is on or the file cannot be read for it. */
28export function weztermEdit(text: string): string | null {
29  if (/enable_kitty_graphics\s*=\s*true/.test(text)) return null
30  if (/enable_kitty_graphics\s*=\s*false/.test(text)) return text.replace(/enable_kitty_graphics\s*=\s*false/, 'enable_kitty_graphics = true')
31  // The usual shape: a config table returned at the end; set the field just before.
32  const returned = /^return\s+([A-Za-z_][A-Za-z0-9_]*)\s*$/m.exec(text)
33  if (!returned) return null
34  const line = `-- Pictures over the kitty graphics protocol (the persona crab in Claude Code, among others).\n${returned[1]}.enable_kitty_graphics = true\n\n`
35
36  return text.slice(0, returned.index) + line + text.slice(returned.index)
37}
38
39/** herdr's config with `[experimental] kitty_graphics = true`; null when on already or herdr turns it on itself. */
40export function herdrEdit(text: string, version: string | null): string | null {
41  if (version !== null && isAtLeast(version, [0, 9])) return null
42  if (/^\s*kitty_graphics\s*=\s*true/m.test(text)) return null
43  if (/^\s*kitty_graphics\s*=\s*false/m.test(text)) return text.replace(/^(\s*kitty_graphics\s*=\s*)false/m, '$1true')
44  const note = '# Pictures over the kitty graphics protocol, passed through to the terminal (on by default from herdr 0.9).\n'
45  if (/^\[experimental\]\s*$/m.test(text)) return text.replace(/^\[experimental\]\s*$/m, `[experimental]\n${note}kitty_graphics = true`)
46
47  return `${text.replace(/\n*$/, '\n')}\n[experimental]\n${note}kitty_graphics = true\n`
48}
49
50/** The shell profile with Claude Code told to draw pictures inside herdr only; null when it is there already. */
51export function shellEdit(text: string, shell: string): string | null {
52  if (text.includes(FORCE_VARIABLE)) return null
53  const why =
54    "# herdr's terminal (libghostty) draws pictures but is not on Claude Code's list of picture terminals;\n# tell Claude Code to draw them there (the persona crab, among others).\n"
55  const line = shell === 'fish' ? `if set -q HERDR_ENV; set -gx ${FORCE_VARIABLE} 1; end` : `[[ -n $HERDR_ENV ]] && export ${FORCE_VARIABLE}=1`
56
57  return `${text.replace(/\n*$/, '\n')}\n${why}${line}\n`
58}
59
60/** The profile a shell reads for interactive sessions. */
61export function profileOf(home: string, shell: string): string | null {
62  if (shell === 'zsh') return `${home}/.zshrc`
63  if (shell === 'bash') return `${home}/.bashrc`
64  if (shell === 'fish') return `${home}/.config/fish/config.fish`
65  return null
66}
67
68function isAtLeast(version: string, wanted: number[]): boolean {
69  const parts = (/(\d+(?:\.\d+)*)/.exec(version)?.[1] ?? '0').split('.').map(Number)
70  for (let index = 0; index < wanted.length; index++) {
71    const have = parts[index] ?? 0
72    if (have !== wanted[index]) return have > wanted[index]!
73  }
74  return true
75}
76
77/**
78 * The plan for this machine, from its setup and the current text of each file (undefined: missing).
79 * Only the chain known to work is changed; anything else is described.
80 */
81export function planPictures(setup: Setup, files: Record<string, string | undefined>): Plan {
82  const plan: Plan = { edits: [], manual: [], restart: [] }
83  if (!setup.isWezterm && !setup.isHerdr) {
84    plan.manual.push(
85      'This terminal is neither WezTerm nor herdr. kitty and Ghostty show the crab as they are; other terminals show the role name.',
86    )
87    return plan
88  }
89
90  if (setup.isWezterm) {
91    const path = setup.weztermConfig ?? `${setup.home}/.config/wezterm/wezterm.lua`
92    const text = files[path]
93    if (text === undefined) {
94      plan.manual.push(`Add \`config.enable_kitty_graphics = true\` to your WezTerm config (none found at ${path}).`)
95    } else {
96      const next = weztermEdit(text)
97      if (next !== null) {
98        plan.edits.push({ path, label: 'WezTerm: turn on kitty graphics', text: next })
99        plan.restart.push('Quit WezTerm (Cmd+Q) and open it again, so it starts with graphics on.')
100      } else if (!/enable_kitty_graphics\s*=\s*true/.test(text)) {
101        plan.manual.push(`Add \`enable_kitty_graphics = true\` to the config table in ${path}; its shape is not one this command edits.`)
102      }
103    }
104  }
105
106  if (setup.isHerdr) {
107    const path = `${setup.home}/.config/herdr/config.toml`
108    const next = herdrEdit(files[path] ?? '', setup.herdrVersion)
109    if (next !== null) {
110      plan.edits.push({ path, label: 'herdr: pass pictures through to the terminal', text: next })
111      plan.restart.push('Run `herdr server reload-config`, or restart herdr.')
112    }
113    const profile = profileOf(setup.home, setup.shell)
114    if (profile === null) {
115      plan.manual.push(`Set ${FORCE_VARIABLE}=1 for Claude Code inside herdr in your shell's profile (${setup.shell} is not one this command edits).`)
116    } else {
117      const next = shellEdit(files[profile] ?? '', setup.shell)
118      if (next !== null) {
119        plan.edits.push({ path: profile, label: `${setup.shell}: let Claude Code draw pictures inside herdr`, text: next })
120        if (!setup.isForced) plan.restart.push('Start Claude Code again from a new herdr pane, so it reads the new setting.')
121      }
122    }
123  }
124
125  return plan
126}
127
hooks/personas.ts 445 lines
1// Persona definitions and the pure checks the hooks run against them.
2
3export type Persona = {
4  id: string
5  name: string
6  /** Shown in `/persona list`. */
7  summary: string
8  /** Added to the system prompt while the persona is active. */
9  prompt: string
10  /** `none`: no file inside the project may be written. */
11  writes: 'none' | { allow?: string[]; deny?: string[] }
12  /** Regular expressions; a Bash command matching any of them is denied. */
13  bashDeny?: string[]
14}
15
16/** A persona file under `~/.claude/personas/` or `<project>/.claude/personas/`. */
17export type PersonaFile = Partial<Persona> & {
18  extraAllow?: string[]
19  extraDeny?: string[]
20  extraBashDeny?: string[]
21}
22
23const SECRETS = ['**/.env', '**/.env.*', '**/*.{pem,key,p12,pfx}', '**/{secrets,.secrets}/**']
24
25const INFRA = [
26  '**/{infra,infrastructure,terraform,k8s,kubernetes,helm,deploy,deployment,ops}/**',
27  '**/*.tf',
28  '**/Dockerfile*',
29  '**/docker-compose*.{yml,yaml}',
30  '**/compose*.{yml,yaml}',
31  '.github/workflows/**',
32  '.gitlab-ci.yml',
33]
34
35const BACKEND = [
36  '**/{api,apis,server,backend,services,controllers,routes,handlers,resolvers,graphql,trpc,middleware,middlewares}/**',
37  '**/{db,database,models,entities,repositories,migrations,migration,prisma,drizzle,seeds,seeders,sql,queries}/**',
38  '**/*.{sql,prisma,graphql,gql}',
39  '**/route.{ts,js}',
40  '**/*.server.*',
41  '**/server.{ts,js,mjs,cjs}',
42]
43
44const UI = [
45  '**/*.{tsx,jsx,vue,svelte,astro,css,scss,sass,less,styl}',
46  '**/{components,styles,theme,themes,public,static,assets}/**',
47  '**/tailwind.config.*',
48  '**/postcss.config.*',
49]
50
51const DB_COMMANDS = [
52  '\\bprisma\\s+(migrate|db\\s+(push|execute|seed))',
53  '\\bdrizzle-kit\\s+(push|migrate|drop)',
54  '\\balembic\\s+(upgrade|downgrade|revision)',
55  '\\b(rails|rake)\\s+db:',
56  '\\bknex\\s+(migrate|seed)',
57  '\\bsequelize(-cli)?\\s+db:',
58  '\\bsupabase\\s+(db|migration)\\b',
59  '\\bflyway\\b',
60  '\\b(psql|mysql|mongosh|mongo|sqlite3|redis-cli)\\b',
61  '\\bmanage\\.py\\s+(migrate|makemigrations|flush|loaddata)',
62]
63
64const RELEASE_COMMANDS = ['\\b(terraform|tofu)\\s+(apply|destroy|import)', '\\bkubectl\\s+(apply|delete|edit|patch|scale|rollout)', '\\bhelm\\s+(install|upgrade|uninstall|rollback)']
65
66const NO_API =
67  'Never change API routes, request or response shapes, database schema, migrations, queries, server code or auth. When a design needs a backend change, stop and write the change you need as a note for a developer instead.'
68
69export const BUILTIN: Persona[] = [
70  {
71    id: 'ui-ux',
72    name: 'UI/UX Designer',
73    summary: 'Components, styles, layout and assets only. API, server and database are off limits.',
74    prompt: `The person you are working with is a UI/UX designer. Focus on visual design, layout, interaction states, copy, responsiveness and accessibility. ${NO_API} Explain changes in design terms, with the visible result first.`,
75    writes: {
76      allow: [
77        '**/*.{tsx,jsx,vue,svelte,astro,html,css,scss,sass,less,styl}',
78        '**/*.{svg,png,jpg,jpeg,gif,webp,avif,ico,woff,woff2,ttf,otf}',
79        '**/{components,ui,styles,theme,themes,tokens,assets,public,static,pages,app,views,layouts,icons,fonts,stories}/**',
80        '**/*.stories.*',
81        '**/tailwind.config.*',
82        '**/postcss.config.*',
83      ],
84      deny: [...BACKEND, ...SECRETS, ...INFRA],
85    },
86    bashDeny: [...DB_COMMANDS, ...RELEASE_COMMANDS],
87  },
88  {
89    id: 'backend',
90    name: 'Backend Developer',
91    summary: 'API, server, database and business logic. UI files are off limits.',
92    prompt:
93      'The person you are working with is a backend developer. Focus on APIs, data models, migrations, performance and correctness. Do not change UI components, styles or frontend assets; when an API change affects the UI, describe what the frontend needs instead of editing it.',
94    writes: { deny: [...UI, ...SECRETS] },
95    bashDeny: RELEASE_COMMANDS,
96  },
97  {
98    id: 'dev',
99    name: 'Developer',
100    summary: 'All application code, frontend and backend. Secrets, CI and infrastructure are off limits.',
101    prompt:
102      'The person you are working with is a full-stack developer. Work on any application code, tests and docs. Do not edit secrets, CI pipelines, deployment or infrastructure files, and do not run deploy or infrastructure-changing commands; describe those changes for whoever owns them instead.',
103    writes: { deny: [...SECRETS, ...INFRA] },
104    bashDeny: RELEASE_COMMANDS,
105  },
106  {
107    id: 'qa',
108    name: 'QA Engineer',
109    summary: 'Tests, fixtures and test config only. Application code is off limits.',
110    prompt:
111      'The person you are working with is a QA engineer. Focus on test plans, test cases, reproduction steps and coverage. Write and fix tests, fixtures and test config only. When a test exposes a bug in application code, report it with a reproduction instead of fixing the code.',
112    writes: {
113      allow: [
114        '**/*.{test,spec}.*',
115        '**/*_{test,spec}.*',
116        '**/test_*.py',
117        '**/{test,tests,__tests__,e2e,cypress,playwright,spec,specs,fixtures,__fixtures__,__mocks__,mocks,__snapshots__}/**',
118        '**/{jest,vitest,playwright,cypress}.config.*',
119        '**/*.snap',
120      ],
121      deny: SECRETS,
122    },
123    bashDeny: [...DB_COMMANDS.filter(command => !command.includes('psql|mysql')), ...RELEASE_COMMANDS],
124  },
125  {
126    id: 'docs',
127    name: 'Technical Writer',
128    summary: 'Documentation and Markdown only. Code is off limits.',
129    prompt:
130      'The person you are working with writes documentation. Focus on clear, accurate docs, READMEs, guides and changelogs. Read code to understand it, but never change code; when the docs reveal a bug or a missing feature, describe it instead.',
131    writes: {
132      allow: ['**/*.{md,mdx,rst,txt,adoc}', '**/{docs,doc,documentation}/**', '**/README*', '**/CHANGELOG*'],
133      deny: SECRETS,
134    },
135    bashDeny: [...DB_COMMANDS, ...RELEASE_COMMANDS],
136  },
137  {
138    id: 'pm',
139    name: 'Product Manager',
140    summary: 'Read-only. Explains, explores and plans; changes no project file.',
141    prompt:
142      'The person you are working with is a product manager. Answer questions about how the product and code behave, explore the codebase, estimate scope and write plans, specs and tickets in the conversation. Do not change any file in the project and do not run commands that change state; when work is needed, describe it as a task for the team.',
143    writes: 'none',
144    bashDeny: [...DB_COMMANDS, ...RELEASE_COMMANDS, '\\bgit\\s+(commit|push|merge|rebase|reset|revert|cherry-pick|stash|tag|branch\\s+-[dDmM]|clean)', '\\b(npm|pnpm|yarn|bun)\\s+(i|install|add|remove|uninstall|update|upgrade|publish)\\b'],
145  },
146]
147
148/** Builtins with machine-level then project-level files laid over them, matched by id. */
149export function mergePersonas(builtins: Persona[], layers: PersonaFile[][]): Persona[] {
150  const byId = new Map(builtins.map(persona => [persona.id, persona]))
151  for (const files of layers) {
152    for (const file of files) {
153      if (!file.id) continue
154      const base: Persona = byId.get(file.id) ?? {
155        id: file.id,
156        name: file.name ?? file.id,
157        summary: '',
158        prompt: '',
159        writes: {},
160      }
161      const { extraAllow, extraDeny, extraBashDeny, ...fields } = file
162      const merged: Persona = { ...base, ...fields } as Persona
163      if (merged.writes !== 'none' && (extraAllow || extraDeny)) {
164        merged.writes = {
165          allow: merged.writes.allow || extraAllow ? [...(merged.writes.allow ?? []), ...(extraAllow ?? [])] : undefined,
166          deny: [...(merged.writes.deny ?? []), ...(extraDeny ?? [])],
167        }
168      }
169      if (extraBashDeny) merged.bashDeny = [...(merged.bashDeny ?? []), ...extraBashDeny]
170      byId.set(merged.id, merged)
171    }
172  }
173
174  return [...byId.values()]
175}
176
177/** Accepts `ui-ux`, `UI/UX Designer`, `uiux`, `designer`. */
178export function findPersona(personas: Persona[], wanted: string): Persona | undefined {
179  const key = squash(wanted)
180  if (!key) return undefined
181
182  return (
183    personas.find(persona => squash(persona.id) === key) ??
184    personas.find(persona => squash(persona.name) === key) ??
185    personas.find(persona => squash(persona.name).includes(key))
186  )
187}
188
189function squash(text: string): string {
190  return text.toLowerCase().replace(/[^a-z0-9]/g, '')
191}
192
193const globCache = new Map<string, RegExp>()
194
195/** `**` spans folders, `*` and `?` stay inside one, `{a,b}` picks one. */
196export function globToRegExp(glob: string): RegExp {
197  const cached = globCache.get(glob)
198  if (cached) return cached
199  let out = ''
200  let depth = 0
201  for (let i = 0; i < glob.length; i++) {
202    const char = glob[i]!
203    if (char === '*') {
204      if (glob[i + 1] === '*') {
205        const isFolder = glob[i + 2] === '/'
206        out += isFolder ? '(?:.*/)?' : '.*'
207        i += isFolder ? 2 : 1
208      } else {
209        out += '[^/]*'
210      }
211    } else if (char === '?') out += '[^/]'
212    else if (char === '{') {
213      out += '(?:'
214      depth++
215    } else if (char === '}' && depth > 0) {
216      out += ')'
217      depth--
218    } else if (char === ',' && depth > 0) out += '|'
219    else out += /[.+^$()|[\]\\]/.test(char) ? `\\${char}` : char
220  }
221  const regExp = new RegExp(`^${out}$`)
222  globCache.set(glob, regExp)
223
224  return regExp
225}
226
227function matchesAny(globs: string[] | undefined, path: string): boolean {
228  return (globs ?? []).some(glob => globToRegExp(glob).test(path))
229}
230
231/** The path relative to the project, or undefined when it lies outside it. */
232export function inProject(path: string, root: string): string | undefined {
233  const clean = path.replace(/\/+$/, '')
234  if (clean === root) return ''
235  const base = root.endsWith('/') ? root : `${root}/`
236
237  return clean.startsWith(base) ? clean.slice(base.length) : undefined
238}
239
240export function absolute(path: string, cwd: string): string {
241  if (path.startsWith('/')) return normalize(path)
242
243  return normalize(`${cwd}/${path}`)
244}
245
246function normalize(path: string): string {
247  const parts: string[] = []
248  for (const part of path.split('/')) {
249    if (part === '' || part === '.') continue
250    if (part === '..') parts.pop()
251    else parts.push(part)
252  }
253
254  return `/${parts.join('/')}`
255}
256
257/**
258 * Why the persona may not write `path`, or undefined when it may. Inside the
259 * project every rule applies; outside it (scratch files, memory, plans) only
260 * the deny list does.
261 */
262export function writeVerdict(persona: Persona, path: string, root: string): string | undefined {
263  const relative = inProject(path, root)
264  const writes = persona.writes
265  if (writes === 'none') return relative === undefined ? undefined : 'this persona is read-only'
266  const shown = relative ?? path
267  if (matchesAny(writes.deny, shown) || (relative !== undefined && matchesAny(writes.deny, path))) {
268    return `${shown} is off limits for this persona`
269  }
270  if (relative !== undefined && writes.allow && !matchesAny(writes.allow, relative)) {
271    return `${shown} is outside what this persona may edit`
272  }
273
274  return undefined
275}
276
277const REWRITERS = new Set(['rm', 'rmdir', 'mv', 'touch', 'mkdir', 'truncate', 'chmod', 'chown', 'unlink', 'shred'])
278const COPIERS = new Set(['cp', 'ln', 'install', 'rsync', 'ditto', 'scp'])
279const OPAQUE = /^(?:patch|git\s+apply|git\s+am)\b/
280
281/** A word as the shell reads it; `isRedirect` marks an unquoted `>`/`>>` (with its fd, if any). */
282type Word = { text: string; isRedirect: boolean }
283
284/**
285 * Splits a command into simple commands of words, as the shell would: quotes and backslashes keep
286 * operators literal (a `|` inside `'get|patch'` splits nothing), and heredoc bodies are data, not commands.
287 */
288function segments(command: string): Word[][] {
289  const all: Word[][] = []
290  let words: Word[] = []
291  let text = ''
292  let hasWord = false
293  let isRedirect = false
294  const heredocs: { tag: string; isIndented: boolean }[] = []
295
296  const endWord = () => {
297    if (hasWord) words.push({ text, isRedirect })
298    text = ''
299    hasWord = false
300    isRedirect = false
301  }
302  const endCommand = () => {
303    endWord()
304    if (words.length > 0) all.push(words)
305    words = []
306  }
307
308  let i = 0
309  while (i < command.length) {
310    const char = command[i]!
311    if (char === '\\') {
312      if (command[i + 1] !== '\n') {
313        text += command[i + 1] ?? ''
314        hasWord = true
315      }
316      i += 2
317    } else if (char === "'") {
318      const close = command.indexOf("'", i + 1)
319      const stop = close < 0 ? command.length : close
320      text += command.slice(i + 1, stop)
321      hasWord = true
322      i = stop + 1
323    } else if (char === '"') {
324      i++
325      while (i < command.length && command[i] !== '"') {
326        if (command[i] === '\\' && i + 1 < command.length) i++
327        text += command[i]
328        i++
329      }
330      hasWord = true
331      i++
332    } else if (char === '#' && !hasWord) {
333      while (i < command.length && command[i] !== '\n') i++
334    } else if (char === '\n') {
335      endCommand()
336      i++
337      // Heredoc bodies start on the next line and run to their tag: skip them.
338      for (const { tag, isIndented } of heredocs.splice(0)) {
339        while (i < command.length) {
340          const lineEnd = command.indexOf('\n', i)
341          const stop = lineEnd < 0 ? command.length : lineEnd
342          const line = command.slice(i, stop)
343          i = stop + 1
344          if ((isIndented ? line.replace(/^\t+/, '') : line) === tag) break
345        }
346      }
347    } else if (char === '<' && command[i + 1] === '<' && command[i + 2] !== '<') {
348      endWord()
349      i += 2
350      const isIndented = command[i] === '-'
351      if (isIndented) i++
352      while (command[i] === ' ' || command[i] === '\t') i++
353      const tag = command.slice(i).match(/^(['"]?)([\w.-]+)\1/)
354      if (tag) {
355        heredocs.push({ tag: tag[2]!, isIndented })
356        i += tag[0].length
357      }
358    } else if (char === '>') {
359      // A redirect: its fd (`2>`) or `&>` sticks to it; the target is the next word or the rest of this one.
360      const fd = /^\d+$/.test(text) || text === '&' ? text : ''
361      if (fd === '') endWord()
362      text = ''
363      hasWord = false
364      i += command[i + 1] === '>' || command[i + 1] === '|' ? 2 : 1
365      if (command[i] === '&') {
366        // `>&2`, `2>&1`: a duplicated fd, not a file.
367        while (i < command.length && /[&\d-]/.test(command[i]!)) i++
368        continue
369      }
370      while (command[i] === ' ' || command[i] === '\t') i++
371      words.push({ text: '>', isRedirect: true })
372    } else if (char === ';' || char === '|' || char === '&' || char === '(' || char === ')') {
373      endCommand()
374      i++
375    } else if (char === ' ' || char === '\t') {
376      endWord()
377      i++
378    } else {
379      text += char
380      hasWord = true
381      i++
382    }
383  }
384  endCommand()
385
386  return all
387}
388
389export type BashTargets = { paths: string[]; isOpaque: boolean }
390
391/** The files a command visibly writes: redirect targets, `tee`, `sed -i`, `rm`, `mv`, copy destinations. */
392export function bashWriteTargets(command: string): BashTargets {
393  const paths: string[] = []
394  let isOpaque = false
395  for (const segment of segments(command)) {
396    const plain: string[] = []
397    for (let i = 0; i < segment.length; i++) {
398      if (!segment[i]!.isRedirect) {
399        plain.push(segment[i]!.text)
400        continue
401      }
402      const target = segment[i + 1]
403      if (target && !target.isRedirect) {
404        if (target.text !== '/dev/null') paths.push(target.text)
405        i++
406      }
407    }
408    let words = plain
409    while (words.length > 0 && /^\w+=/.test(words[0]!)) words = words.slice(1)
410    if (words[0] === 'sudo' || words[0] === 'command' || words[0] === 'env') words = words.slice(1)
411    const [head, ...rest] = words
412    if (!head) continue
413    const args = rest.filter(word => !word.startsWith('-'))
414    if (OPAQUE.test(words.join(' '))) isOpaque = true
415    else if (REWRITERS.has(head) || head === 'tee') paths.push(...args)
416    else if (COPIERS.has(head) && args.length > 1) paths.push(args[args.length - 1]!)
417    else if ((head === 'sed' || head === 'perl') && rest.some(word => /^-[a-zA-Z]*i/.test(word))) {
418      const operands = rest.filter((word, i) => !word.startsWith('-') && !(head === 'sed' && rest[i - 1] === '-i' && word === ''))
419      const hasScriptFlag = rest.includes('-e')
420      paths.push(...operands.slice(hasScriptFlag ? 0 : 1).filter(word => word !== ''))
421    } else if (head === 'git' && ['checkout', 'restore', 'rm', 'mv'].includes(args[0] ?? '')) {
422      const dashes = rest.indexOf('--')
423      paths.push(...(dashes >= 0 ? rest.slice(dashes + 1) : args.slice(1)))
424    }
425  }
426
427  return { paths, isOpaque }
428}
429
430/** Why the persona may not run `command`, or undefined when it may. */
431export function bashVerdict(persona: Persona, command: string, cwd: string, root: string): string | undefined {
432  const pattern = (persona.bashDeny ?? []).find(source => new RegExp(source).test(command))
433  if (pattern) return 'this command is off limits for this persona'
434  const isRestricted = persona.writes === 'none' || Boolean(persona.writes.allow?.length || persona.writes.deny?.length)
435  if (!isRestricted) return undefined
436  const { paths, isOpaque } = bashWriteTargets(command)
437  if (isOpaque) return 'it applies changes whose files the persona cannot check; use Edit or Write instead'
438  for (const path of paths) {
439    const verdict = writeVerdict(persona, absolute(path, cwd), root)
440    if (verdict) return verdict
441  }
442
443  return undefined
444}
445